feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync

This commit is contained in:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
+9
View File
@@ -1,3 +1,4 @@
venv/
venv_openshorts/
__pycache__/
*.pyc
@@ -8,8 +9,16 @@ models/
*.avi
*.mkv
output/
openshorts/output/
done_split/
uploaded/
logs/
demo_and_images/
.vs/
.atl/
yolov8n.pt
yt_video.webm
*.ass
*.srt
*.words.json
*.log
+9
View File
@@ -0,0 +1,9 @@
[submodule "ai-youtube-shorts-generator"]
path = ai-youtube-shorts-generator
url = https://github.com/samuraigpt/ai-youtube-shorts-generator
[submodule "brainrotinator"]
path = brainrotinator
url = https://github.com/lukesorvik/brainrotinator
[submodule "openshorts"]
path = openshorts
url = https://github.com/mutonby/openshorts
Submodule ai-youtube-shorts-generator deleted from 063f9e950f
@@ -0,0 +1,156 @@
---
name: youtube-shorts-generator
description: Generate viral 9:16 YouTube Shorts (or TikTok/Reels clips) from a long-form YouTube URL or local video. Triggers on requests like "make shorts from this video", "extract viral clips from this YouTube link", "auto-clip this podcast", "find the best moments and crop vertical". Pipeline downloads the source, transcribes via MuAPI /openai-whisper, ranks highlights through a virality framework (hook / emotional peak / opinion bomb / revelation / conflict / quotable / story peak / practical value), dedupes overlapping candidates, and vertically auto-crops the top N as mp4s.
---
# YouTube Shorts Generator
End-to-end pipeline that turns one long video into N viral-ready vertical clips. Each clip ships with a viral score (0100), an opening hook line, and a one-sentence reason it should perform.
Reference implementation: https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator
## When to use this skill
- "Generate shorts from this YouTube video"
- "Find the most viral 60-second clips in this podcast"
- "Auto-crop this interview to 9:16"
- "Give me TikTok clips from this lecture"
If the user only wants transcription, summarization, or thumbnails — this is the wrong skill.
## Inputs to collect before running
Ask once, then proceed:
1. **Source** — YouTube URL (preferred) or path/URL to an mp4
2. **`num_clips`** — default 3
3. **`aspect_ratio`** — default `9:16` (also: `1:1`, `4:5`)
4. **`language`** — default auto-detect (forwarded to MuAPI Whisper as ISO-639-1)
5. **Output JSON path** — optional; if set, dump full result there
If the user gave a URL and nothing else, use defaults and don't block on questions.
## Prerequisites (verify before first run)
- Python 3.10+
- A MuAPI key — set `MUAPI_API_KEY` in `.env`. Powers download, transcription, highlight ranking, and clipping. If missing, stop and ask the user for it; do not invent one.
- `pip install -r requirements.txt` inside a venv
If the repo isn't cloned yet, clone `https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git` into the working directory.
## Pipeline (what to execute)
Run the eight stages in order. Each maps to a module in `shorts_generator/`.
1. **Download** (`downloader.py`) — pull the source video at the requested resolution (`360`/`480`/`720`/`1080`, default `720`).
2. **Transcribe** (`transcriber.py`) — MuAPI `/openai-whisper` runs Whisper server-side and returns timestamped `verbose_json` segments. Billed per minute of audio.
3. **Classify content type** — LLM tags the video (podcast / interview / tutorial / vlog / lecture / monologue) and density. Tune the highlight prompt per type.
4. **Chunk if long** (`highlights.py`) — videos > `LONG_VIDEO_THRESHOLD` (1800s default) are split into `CHUNK_SIZE_SECONDS` (1200s default) windows with `CHUNK_OVERLAP_SECONDS` (60s default) overlap so cross-boundary highlights aren't missed.
5. **Rank highlights** — LLM scans each chunk through `VIRALITY_CRITERIA`:
- **Hook moments** — strong opening line that stops the scroll
- **Emotional peaks** — laughter, anger, vulnerability, awe
- **Opinion bombs** — spicy, contrarian, debate-bait takes
- **Revelation moments** — "wait, what?" reframes
- **Conflict** — disagreement, tension, callouts
- **Quotable lines** — tight, screenshot-worthy phrasing
- **Story peaks** — climax of a narrative arc
- **Practical value** — actionable insight a viewer will save
Each candidate gets `start_time`, `end_time`, `score` 0100, `title`, `hook_sentence`, `virality_reason`. Aim for 3075s clips unless content dictates otherwise.
6. **Dedupe** — collapse overlaps. Rule: if two candidates overlap > 50%, keep the higher score, drop the other.
7. **Top-N selection** — sort surviving candidates by score, take `num_clips`.
8. **Vertical auto-crop** (`clipper.py`) — render each highlight at `aspect_ratio`. Auto-handles face tracking and screen recordings; no Haar cascades.
## Invocation
CLI (the standard path):
```bash
python main.py "<YOUTUBE_URL>" \
--num-clips 5 \
--aspect-ratio 9:16 \
--output-json result.json
```
Python API (when embedding in another pipeline):
```python
from shorts_generator import generate_shorts
result = generate_shorts(
"<URL>",
num_clips=5,
aspect_ratio="9:16",
)
for short in result["shorts"]:
print(short["score"], short["title"], short["clip_url"])
```
Batch mode — `urls.txt` with one URL per line:
```bash
xargs -a urls.txt -I{} python main.py "{}"
```
## CLI flags reference
| Flag | Default | Notes |
|------|---------|-------|
| `--num-clips` | `3` | How many shorts to render |
| `--aspect-ratio` | `9:16` | `9:16` for TikTok/Reels, `1:1` square, anything else by flag |
| `--format` | `720` | Source download resolution |
| `--language` | auto | Whisper language code (e.g. `en`) |
| `--output-json` | — | Dump full result (transcript + all candidates + clip URLs) |
## Output schema
```json
{
"source_video_url": "...",
"transcript": { "duration": 1873.4, "segments": [...] },
"highlights": [ /* every candidate, before top-N cut */ ],
"shorts": [
{
"title": "The one mistake that cost me $50K",
"start_time": 124.3,
"end_time": 187.6,
"score": 92,
"hook_sentence": "Nobody talks about this, but it killed my first startup...",
"virality_reason": "Opens with a number + regret, peaks on a contrarian lesson",
"clip_url": "https://.../short_1.mp4"
}
]
}
```
When reporting back to the user, surface for each clip: rank, score, time range, title, hook, and clip URL. Skip the raw transcript unless asked.
## Tunable knobs
- `shorts_generator/highlights.py`
- `VIRALITY_CRITERIA` — reorder or extend signals
- `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema
- `CHUNK_SIZE_SECONDS` — 1200s default
- `LONG_VIDEO_THRESHOLD` — 1800s default
- `CHUNK_OVERLAP_SECONDS` — 60s default
- `shorts_generator/config.py` (or env vars)
- `MUAPI_POLL_INTERVAL` — 5s
- `MUAPI_POLL_TIMEOUT` — 1800s
## Whisper transcription
Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`, billed per minute). The CLI passes `--language` straight through; leave it empty for auto-detection, or pass an ISO-639-1 code (e.g. `en`) to lock it.
## Failure modes — handle, don't paper over
- **Whisper produced no segments** — likely no detectable speech or a hard language. Retry with `--language <code>` (correct ISO-639-1) before declaring failure.
- **API key missing or rejected** — surface the exact error; never fabricate a key.
- **Job timed out** — bump `MUAPI_POLL_TIMEOUT` and retry; don't silently truncate.
- **Highlight ranker returned <`num_clips`** — return what survived dedupe with a note; don't pad with low-score filler.
## Done criteria
The skill is done when:
1. `result["shorts"]` has up to `num_clips` entries, each with a working `clip_url`.
2. The user has been shown the ranked list (score, time range, title, hook, URL).
3. If `--output-json` was set, the file exists and parses.
If any clip URL 404s on a HEAD check, re-run just the crop stage for that highlight rather than re-running the whole pipeline.
+12
View File
@@ -0,0 +1,12 @@
# API mode (default)
MUAPI_API_KEY=your_muapi_key_here
# Local mode (--mode local)
LLM_PROVIDER=openai # openai or gemini
OPENAI_API_KEY=your_openai_key_here
OPENAI_MODEL=gpt-4o-mini
GEMINI_API_KEY=your_gemini_key_here
GEMINI_MODEL=gemini-2.5-flash
LOCAL_WHISPER_MODEL=base
LOCAL_WHISPER_DEVICE=auto
LOCAL_OUTPUT_DIR=output
+8
View File
@@ -0,0 +1,8 @@
.env
__pycache__/
*.pyc
.venv/
venv/
output/
*.mp4
.DS_Store
+307
View File
@@ -0,0 +1,307 @@
# AI YouTube Shorts Generator
[![Powered by MuAPI](https://img.shields.io/badge/Powered%20by-MuAPI-6366f1?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNGgtMnYtMmg0djZoLTJ6bTAtOFY2aDJ2MmgtMnoiLz48L3N2Zz4=)](https://muapi.ai?utm_source=github&utm_medium=badge&utm_campaign=ai-youtube-shorts-generator)
**The open-source alternative to Opus Clip, Vidyo.ai, Klap, SubMagic, 2short.ai, and other AI clipping tools.** Drop in any long-form YouTube video and get back ranked, viral-ready 9:16 shorts — for free, with no per-clip credits, no watermarks, and full control over the highlight algorithm.
Built for creators, agencies, and developers who don't want to pay $20$300/month or be capped on minutes processed. Uses GPT-class LLM highlight detection and Whisper transcription to extract the most viral-worthy moments and auto-crop them vertically for TikTok, Reels, and Shorts.
> **Building your own Opus Clipstyle SaaS?** Skip the infra and ship on the same APIs that power this repo:
> - [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — end-to-end clip selection + render
> - [Auto-Crop API](https://muapi.ai/playground/autocrop?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — vertical reframing only
![longshorts](https://github.com/user-attachments/assets/3f5d1abf-bf3b-475f-8abf-5e253003453a)
<p align="center">
<a href="https://github.com/Anil-matcha/awesome-generative-ai-apps">
<img src="https://img.shields.io/badge/Part%20of-Awesome%20Generative%20AI%20Apps-FFD700?style=for-the-badge&logo=github&logoColor=black" alt="Awesome Generative AI Apps">
</a>
</p>
> 🎨 **[Explore 50+ more open-source AI apps →](https://github.com/Anil-matcha/awesome-generative-ai-apps)**
## Why Use This Instead of Opus Clip / Vidyo.ai / Klap?
| | This repo | Opus Clip / Vidyo.ai / Klap / SubMagic |
|---|---|---|
| **Price** | Free + open source (pay only for API usage) | $20$300/month subscriptions |
| **Per-clip credits** | None — process unlimited videos | Monthly minute caps, overage fees |
| **Watermarks** | Never | On free tiers |
| **Highlight algorithm** | Fully editable virality framework | Black box |
| **Output format** | Any aspect ratio, any resolution | Locked presets |
| **Batch processing** | `xargs` an entire URL list | Manual upload one-by-one |
| **JSON / API output** | Built-in (`--output-json`) | Limited or paid tier only |
| **Self-hostable** | Yes — runs on your machine or server | SaaS only, your videos sit on their servers |
| **White-label / embeddable** | Yes — MIT licensed, import as Python lib | No |
## Features
- **🎬 YouTube In, Vertical Out**: Hand it any YouTube URL — get back N viral-ready 9:16 mp4s
- **🔀 Two Modes — API (fast) or Local (offline)**: Default `--mode api` uses MuAPI for download/transcription/cropping; `--mode local` runs entirely on your machine with `yt-dlp`, `faster-whisper`, and `ffmpeg`/`opencv`, and lets you pick OpenAI or Gemini for highlight ranking
- **🤖 Virality-Aware Highlight Selection**: Clips ranked on hooks, emotional peaks, opinion bombs, revelation moments, conflict, quotable lines, story peaks, and practical value — not just generic "interesting"
- **📈 Score + Hook + Reason for Every Clip**: Each highlight comes with a viral score, an opening hook line, and a one-sentence explanation of why it works
- **🎤 Whisper Transcription, Your Choice**: Cloud (`/openai-whisper` via MuAPI) or local (`faster-whisper`, CPU or CUDA) — same downstream output shape
- **🧩 Long-Video Aware**: Videos over 30 minutes are auto-chunked with overlap so nothing gets missed
- **♻️ Smart Dedupe**: Overlapping highlights are collapsed by score so you never get two near-duplicate clips
- **🎯 Smart Vertical Crop**: API mode uses MuAPI's auto-crop; local mode runs OpenCV face tracking with motion smoothing
- **📱 Any Aspect Ratio**: 9:16 for TikTok/Reels/Shorts, 1:1 for square, anything else by flag
- **🧰 CLI + Python Library**: Use it from the shell or import `generate_shorts(...)` into your own pipeline
- **📦 JSON Output**: `--output-json` dumps the full result (transcript + every candidate highlight + final clip URLs/paths) for downstream automation
## Quick Start (No Setup)
Don't want to self-host? The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) gives you the same Opus Clipstyle pipeline as a single HTTP call — no Python, no dependencies, pay-per-clip instead of monthly subscriptions.
---
## Installation (Self-Hosted)
### Prerequisites
- Python 3.10+
- For **API mode (default)**: a MuAPI key — powers download, transcription, highlight ranking, and clipping in a single dependency
- For **Local mode** (`--mode local`): `ffmpeg` on your PATH and an LLM API key (`OPENAI_API_KEY` or `GEMINI_API_KEY`; only the LLM step is remote)
### Steps
1. **Clone the repository:**
```bash
git clone https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git
cd AI-Youtube-Shorts-Generator
```
2. **Create and activate a virtual environment:**
```bash
python3.10 -m venv venv
source venv/bin/activate
```
3. **Install Python dependencies:**
```bash
pip install -r requirements.txt
# Only if you plan to use --mode local:
pip install -r requirements-local.txt
```
4. **Set up environment variables:**
Create a `.env` file in the project root:
```bash
# API mode (default)
MUAPI_API_KEY=your_muapi_key_here
# Local mode (--mode local)
LLM_PROVIDER=openai # openai or gemini
OPENAI_API_KEY=your_openai_key_here
OPENAI_MODEL=gpt-4o-mini # optional, default gpt-4o-mini
GEMINI_API_KEY=your_gemini_key_here
GEMINI_MODEL=gemini-2.5-flash # optional, default gemini-2.5-flash
LOCAL_WHISPER_MODEL=base # tiny / base / small / medium / large-v3
LOCAL_WHISPER_DEVICE=auto # auto / cpu / cuda
LOCAL_OUTPUT_DIR=output # where local mp4s land
```
## Usage
### Single video (API mode — default)
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID"
```
### Single video (Local mode — runs offline except for the LLM call)
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID" --mode local
```
Local mode writes the rendered shorts to `./output/short_01.mp4`, `short_02.mp4`, … (override with `LOCAL_OUTPUT_DIR`).
### With options
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID" \
--mode api \
--num-clips 5 \
--aspect-ratio 9:16 \
--output-json result.json
```
### Local file or path
In `--mode local`, you can pass a `file://` URL or a direct filesystem path and skip YouTube entirely:
```bash
python main.py "/Users/you/Videos/input.mp4" --mode local
python main.py "file:///Users/you/Videos/input.mp4" --mode local
```
The Python API works the same way:
```python
from shorts_generator import generate_shorts
result = generate_shorts(
"/Users/you/Videos/input.mp4",
num_clips=5,
aspect_ratio="9:16",
mode="local",
)
for short in result["shorts"]:
print(short["score"], short["title"], short["clip_url"])
```
Local transcription is cached as an `.srt` file in `LOCAL_OUTPUT_DIR` using the
video's base name. If the cache already exists and is newer than the source
file, the app reuses it instead of running Whisper again.
Local downloads are also cached in `LOCAL_OUTPUT_DIR` as
`source_<youtube_id>.mp4` when the input is a YouTube URL. If that file already
exists, the app skips `yt-dlp` and reuses the cached video.
### Batch processing
Create a `urls.txt` file with one URL per line, then:
```bash
xargs -a urls.txt -I{} python main.py "{}"
```
### CLI flags
| Flag | Default | Notes |
|------|---------|-------|
| `--mode` | `api` | `api` (MuAPI, fast, no setup) or `local` (remote URL, `file://`, or local path + faster-whisper + LLM provider + ffmpeg) |
| `--num-clips` | `3` | How many shorts to render |
| `--aspect-ratio` | `9:16` | Any ratio; `9:16` for TikTok/Reels, `1:1` for square |
| `--format` | `720` | Source download resolution: `360` / `480` / `720` / `1080` |
| `--language` | auto | Force Whisper language code (e.g. `en`) |
| `--output-json` | — | Dump the full result (transcript + all candidates) to a file |
### API mode vs Local mode
| Step | API mode (`--mode api`) | Local mode (`--mode local`) |
|---|---|---|
| Download | MuAPI `/youtube-download` | `yt-dlp` for remote URLs, direct file path for local inputs |
| Transcription | MuAPI `/openai-whisper` | `faster-whisper` (CPU or CUDA) |
| Highlight LLM | MuAPI `gpt-5-mini` | `LLM_PROVIDER=openai` uses OpenAI (`gpt-4o-mini` by default), `LLM_PROVIDER=gemini` uses Gemini (`gemini-2.5-flash` by default) |
| Vertical crop | MuAPI `/autocrop` | `ffmpeg` + OpenCV face tracking |
| Output | hosted URLs | local mp4 paths |
| Required keys | `MUAPI_API_KEY` | `OPENAI_API_KEY` or `GEMINI_API_KEY` (+ `ffmpeg` on PATH) |
## How It Works
1. **Download**: Fetches the source video from YouTube
2. **Transcribe**: MuAPI `/openai-whisper` produces a timestamped transcript (verbose_json segments)
3. **Detect content type**: An LLM classifies the video (podcast, interview, tutorial, vlog, etc.) and density, so the prompt can be tuned per content style
4. **Long-video chunking**: Videos > 30 min are split into 20-min overlapping chunks
5. **Highlight ranking**: An LLM scans the transcript through a virality framework — hook moments, emotional peaks, opinion bombs, revelations, conflict, quotables, story peaks, practical value — and emits ranked candidates with scores 0100
6. **Dedupe**: Overlapping candidates are collapsed by score (>50% overlap → keep the higher score)
7. **Top-N selection**: The top `--num-clips` candidates are selected
8. **Auto-crop**: Each highlight is rendered as a vertical short at the requested aspect ratio
**Output**: a list of mp4 URLs plus, for each clip, its title, viral score, hook sentence, and a one-line reason explaining why it should perform.
## Output
Console output looks like:
```
========================================================================
Highlights: 7 candidates → kept top 3
========================================================================
#1 score=92 124.3s → 187.6s
title: The one mistake that cost me $50K
hook: "Nobody talks about this, but it killed my first startup..."
clip: https://.../short_1.mp4
#2 score=88 ...
```
`--output-json result.json` produces:
```json
{
"source_video_url": "...",
"transcript": { "duration": 1873.4, "segments": [...] },
"highlights": [ {...}, {...}, ... ],
"shorts": [
{
"title": "...",
"start_time": 124.3,
"end_time": 187.6,
"score": 92,
"hook_sentence": "...",
"virality_reason": "...",
"clip_url": "https://.../short_1.mp4"
}
]
}
```
## Configuration
### Highlight selection criteria
Edit `shorts_generator/highlights.py`:
- **Virality framework**: `VIRALITY_CRITERIA` — the ranked list of signals the LLM optimizes for
- **System prompt**: `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema
- **Chunk size**: `CHUNK_SIZE_SECONDS` (default 1200) — chunk length for long videos
- **Long-video threshold**: `LONG_VIDEO_THRESHOLD` (default 1800) — videos longer than this are chunked
- **Chunk overlap**: `CHUNK_OVERLAP_SECONDS` (default 60) — overlap between chunks so cross-boundary clips aren't missed
### Polling / timeout
Edit `shorts_generator/config.py` (or set env vars):
- `MUAPI_POLL_INTERVAL` (default 5s) — seconds between job-status polls
- `MUAPI_POLL_TIMEOUT` (default 1800s) — give up after this long
### Whisper transcription
Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`). Pass `--language <code>` to lock the recognition to a specific language; otherwise it auto-detects.
## Project Structure
```
AI-Youtube-Shorts-Generator/
├── main.py CLI entry point
├── requirements.txt core deps (api mode)
├── requirements-local.txt optional deps for --mode local
├── .env.example
└── shorts_generator/
├── config.py env / settings (MuAPI + local LLM + Whisper)
├── muapi.py generic submit + poll wrapper
├── downloader.py API mode: YouTube download via MuAPI
├── transcriber.py API mode: MuAPI /openai-whisper client
├── highlights.py shared LLM virality ranking (pluggable backend)
├── clipper.py API mode: MuAPI /autocrop
├── pipeline.py mode dispatcher (api ↔ local)
└── local/ --mode local backends (offline)
├── downloader.py yt-dlp download
├── transcriber.py faster-whisper transcription
├── llm.py OpenAI or Gemini client selector
└── clipper.py ffmpeg cut + OpenCV vertical crop
```
## Troubleshooting
### Whisper produced no segments
The video may have no detectable speech, or it may be in a language Whisper struggles with. Try passing `--language en` (or the correct ISO-639-1 code) to skip auto-detection.
### Looking for better results?
The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) uses an improved algorithm that produces higher-quality clips with better highlight detection.
## Contributing
Contributions are welcome! Please fork the repository and submit a pull request.
## License
This project is licensed under the MIT License.
## Related Projects
- [AI Influencer Generator](https://github.com/SamurAIGPT/AI-Influencer-Generator)
- [Text to Video AI](https://github.com/SamurAIGPT/Text-To-Video-AI)
- [Faceless Video Generator](https://github.com/SamurAIGPT/Faceless-Video-Generator)
- [AI B-roll Generator](https://github.com/Anil-matcha/AI-B-roll)
- [No-code YouTube Shorts Generator](https://www.vadoo.tv/clip-youtube-video)
+73
View File
@@ -0,0 +1,73 @@
"""CLI entry point.
Usage:
python main.py "https://www.youtube.com/watch?v=..." \
--num-clips 3 --aspect-ratio 9:16
"""
import argparse
import json
import sys
# Windows uses 'charmap' by default, which can't encode Unicode characters
# like →. Reconfigure stdout/stderr to UTF-8 so output works on all platforms.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
from shorts_generator import generate_shorts
def main() -> int:
parser = argparse.ArgumentParser(description="AI YouTube Shorts Generator")
parser.add_argument("url", help="YouTube URL, file:// URL, or local file path")
parser.add_argument(
"--mode",
choices=["api", "local"],
default="api",
help="api (default, MuAPI) or local (remote URL, file://, or local path + faster-whisper + LLM provider + ffmpeg).",
)
parser.add_argument("--num-clips", type=int, default=3, help="How many shorts to render (default: 3)")
parser.add_argument("--aspect-ratio", default="9:16", help="Output aspect ratio (default: 9:16)")
parser.add_argument("--format", default="720", help="Source download resolution: 360 / 480 / 720 / 1080 (default: 720)")
parser.add_argument("--language", default=None, help="Force Whisper language code, e.g. 'en' (default: auto-detect)")
parser.add_argument("--output-json", default=None, help="Write the full result JSON to this path")
args = parser.parse_args()
try:
result = generate_shorts(
youtube_url=args.url,
num_clips=args.num_clips,
aspect_ratio=args.aspect_ratio,
download_format=args.format,
language=args.language,
mode=args.mode,
)
except Exception as e:
print(f"\nFAILED: {e}", file=sys.stderr)
return 1
print("\n" + "=" * 72)
print(f"Mode: {result.get('mode', args.mode)}")
print(f"Source video: {result['source_video_url']}")
print(f"Highlights: {len(result['highlights'])} candidates → kept top {len(result['shorts'])}")
print("=" * 72)
for i, s in enumerate(result["shorts"], 1):
print(f"\n#{i} score={s.get('score')} {s.get('start_time'):.1f}s → {s.get('end_time'):.1f}s")
print(f" title: {s.get('title')}")
print(f" hook: {s.get('hook_sentence')}")
if s.get("clip_url"):
print(f" clip: {s['clip_url']}")
else:
print(f" clip: FAILED ({s.get('error')})")
if args.output_json:
with open(args.output_json, "w") as f:
json.dump(result, f, indent=2)
print(f"\nFull JSON written to {args.output_json}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,10 @@
-r requirements.txt
# Optional dependencies for --mode local.
yt-dlp>=2024.1.1
faster-whisper>=1.0.0
openai>=1.0.0
google-genai>=1.0.0
opencv-python>=4.8.0
# torch is only needed if you want CUDA Whisper. CPU works without it.
# torch>=2.0
@@ -0,0 +1,2 @@
requests>=2.31
python-dotenv>=1.0
@@ -0,0 +1,3 @@
from .pipeline import generate_shorts
__all__ = ["generate_shorts"]
@@ -0,0 +1,41 @@
"""Per-clip cropping via MuAPI /autocrop.
Given the source video URL plus a highlight's start/end and a target aspect
ratio, MuAPI returns a vertically-cropped short ready for posting.
"""
from typing import Dict
from . import muapi
from .downloader import _extract_video_url
def crop_clip(source_video_url: str, start_time: float, end_time: float, aspect_ratio: str = "9:16") -> str:
"""Submit one autocrop job and return the URL of the rendered short."""
payload = {
"video_url": source_video_url,
"start_time": float(start_time),
"end_time": float(end_time),
"aspect_ratio": aspect_ratio,
}
print(f"[clip] {start_time:.1f}s → {end_time:.1f}s @ {aspect_ratio}", flush=True)
result = muapi.run("autocrop", payload, label=f"autocrop({start_time:.0f}-{end_time:.0f})")
return _extract_video_url(result)
def crop_highlights(source_video_url: str, highlights: list, aspect_ratio: str = "9:16") -> list:
"""Crop every highlight, attaching the resulting URL back onto the dict."""
out = []
for i, h in enumerate(highlights, 1):
print(f"[clip] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
try:
url = crop_clip(
source_video_url,
h["start_time"],
h["end_time"],
aspect_ratio=aspect_ratio,
)
out.append({**h, "clip_url": url})
except Exception as e:
print(f"[clip] {i} failed: {e}", flush=True)
out.append({**h, "clip_url": None, "error": str(e)})
return out
@@ -0,0 +1,67 @@
import os
from dotenv import load_dotenv
load_dotenv()
MUAPI_API_KEY = os.getenv("MUAPI_API_KEY", "").strip()
MUAPI_BASE_URL = os.getenv("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1").rstrip("/")
POLL_INTERVAL_SECONDS = float(os.getenv("MUAPI_POLL_INTERVAL", "5"))
POLL_TIMEOUT_SECONDS = float(os.getenv("MUAPI_POLL_TIMEOUT", "600"))
# Local-mode (--mode local) settings — only consulted when running offline.
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").strip().lower()
LOCAL_WHISPER_MODEL = os.getenv("LOCAL_WHISPER_MODEL", "base")
LOCAL_WHISPER_DEVICE = os.getenv("LOCAL_WHISPER_DEVICE", "auto") # auto / cpu / cuda
LOCAL_OUTPUT_DIR = os.getenv("LOCAL_OUTPUT_DIR", "output")
# VAD (Voice Activity Detection) settings for faster-whisper
# Default threshold is 0.5; lower = more sensitive, higher = less sensitive
# Default min_speech_duration_ms is 250ms; increase to avoid tiny false positives
# Default min_silence_duration_ms is 2000ms; increase to avoid splitting mid-sentence
# DISABLED by default because VAD is too aggressive on mixed speech/music content
LOCAL_WHISPER_VAD_FILTER = os.getenv("LOCAL_WHISPER_VAD_FILTER", "false").strip().lower() == "true"
_vad_params_env = os.getenv("LOCAL_WHISPER_VAD_PARAMETERS", "")
if _vad_params_env:
import json
LOCAL_WHISPER_VAD_PARAMETERS = json.loads(_vad_params_env)
else:
# Match faster-whisper defaults when VAD is enabled
LOCAL_WHISPER_VAD_PARAMETERS = {
"threshold": 0.5,
"min_speech_duration_ms": 250,
"max_speech_duration_s": float("inf"),
"min_silence_duration_ms": 2000,
"speech_pad_ms": 400,
}
def require_api_key() -> str:
if not MUAPI_API_KEY:
raise RuntimeError(
"MUAPI_API_KEY is not set. Add it to your .env file or export it as an env var."
)
return MUAPI_API_KEY
def require_openai_key() -> str:
if not OPENAI_API_KEY:
raise RuntimeError(
"OPENAI_API_KEY is not set. Local mode needs an OpenAI key for highlight ranking. "
"Add it to your .env or export it, or switch back to --mode api."
)
return OPENAI_API_KEY
def require_gemini_key() -> str:
if not GEMINI_API_KEY:
raise RuntimeError(
"GEMINI_API_KEY is not set. Local mode needs a Gemini key when LLM_PROVIDER=gemini. "
"Add it to your .env or export it, or switch LLM_PROVIDER back to openai."
)
return GEMINI_API_KEY
@@ -0,0 +1,36 @@
"""YouTube source video download via MuAPI /youtube-download."""
from typing import Dict
from . import muapi
def _extract_video_url(result: Dict) -> str:
"""MuAPI result shapes vary by endpoint — try common keys."""
for key in ("video_url", "url", "output_url", "result_url"):
v = result.get(key)
if isinstance(v, str) and v.startswith("http"):
return v
output = result.get("outputs") or result.get("output") or result.get("result") or {}
if isinstance(output, dict):
for key in ("video_url", "url", "output_url"):
v = output.get(key)
if isinstance(v, str) and v.startswith("http"):
return v
if isinstance(output, list) and output and isinstance(output[0], str) and output[0].startswith("http"):
return output[0]
raise RuntimeError(f"Could not find downloaded video URL in MuAPI response: {result}")
def download_youtube(video_url: str, fmt: str = "720") -> str:
"""Hand a YouTube URL to MuAPI; return a hosted mp4 URL we can read from."""
print(f"[download] requesting {video_url} @ {fmt}p", flush=True)
result = muapi.run(
"youtube-download",
{"video_url": video_url, "format": fmt},
label="youtube-download",
)
out = _extract_video_url(result)
print(f"[download] ready: {out}", flush=True)
return out
@@ -0,0 +1,306 @@
"""Find the most viral-worthy highlights in a transcript.
Logic ported from ViralVadoo's transcript_analysis/highlight_generator.py:
- content-type / density detection
- chunking for long videos with overlap
- virality-criteria prompt
- score-based dedupe with overlap suppression
The LLM call is pluggable via the `llm_fn` argument so the same prompts can
drive either MuAPI (default, --mode api) or a direct local LLM client
(--mode local).
"""
import json
import re
from typing import Callable, Dict, List, Optional
from . import muapi
LLMFn = Callable[[str], str]
CONTENT_TYPE_PROMPT = """Analyze this video transcript sample and classify the content type.
Choose one: podcast, interview, tutorial, lecture, commentary, debate, vlog, other.
Also estimate content density: low (mostly filler/chit-chat), medium, or high (dense info/stories).
Respond with JSON only: {"content_type": "...", "density": "..."}"""
VIRALITY_CRITERIA = """
Virality signals to prioritize (ranked by impact):
1. HOOK MOMENTS — statements that create immediate curiosity ("The secret is...", "Nobody talks about...", "I was completely wrong about...")
2. EMOTIONAL PEAKS — genuine surprise, laughter, anger, vulnerability, excitement; raw unscripted reactions
3. OPINION BOMBS — strong, polarizing or counter-intuitive statements that trigger agree/disagree
4. REVELATION MOMENTS — surprising facts, stats, or confessions that reframe how the viewer thinks
5. CONFLICT/TENSION — disagreement, pushback, or a problem being confronted head-on
6. QUOTABLE ONE-LINERS — a sentence that works as a standalone quote card
7. STORY PEAKS — the climax or twist of an anecdote; the payoff moment
8. PRACTICAL VALUE — a concrete tip, hack, or insight the viewer can immediately apply
"""
HIGHLIGHT_SYSTEM_PROMPT = """You are an elite short-form video editor who has studied thousands of viral clips on TikTok, Instagram Reels, and YouTube Shorts. You know exactly what makes viewers stop scrolling, watch to the end, and share.
{virality_criteria}
Content type: {content_type} | Density: {density}
Your task: identify the most viral-worthy highlights from the transcript.
Rules:
- Every highlight must open with a strong HOOK — a line that grabs attention within the first 3 seconds
- Duration sweet spot: 45-90 seconds. Go shorter (20-44s) only for a perfect standalone one-liner. Go longer (91-180s) only when a story arc needs full context to land
- Never cut mid-sentence or mid-thought — each clip must feel complete and self-contained
- Clips must not overlap significantly with each other
- Score 0-100 on viral potential (not general quality)
- {num_clips_instruction}
- For each highlight, identify the single best "hook_sentence" — the opening line that would make someone stop scrolling
- Explain in one sentence why this clip is viral ("virality_reason")
Respond ONLY with valid JSON (no markdown, no explanation):
{{"highlights":[{{"title":"string","start_time":float,"end_time":float,"score":int,"hook_sentence":"string","virality_reason":"string"}}]}}"""
CHUNK_SIZE_SECONDS = 1200 # 20-min chunks for long videos
LONG_VIDEO_THRESHOLD = 1800 # chunk videos longer than 30 min
CHUNK_OVERLAP_SECONDS = 60
GPT_CALL_TIMEOUT_SECONDS = 300 # cap LLM polls at 5 min — a wedged call should fail fast
MAX_HIGHLIGHT_API_ATTEMPTS = 3
def call_muapi_llm(prompt: str) -> str:
"""Default LLM backend: MuAPI gpt-5-mini."""
result = muapi.run(
"gpt-5-mini",
{"prompt": prompt},
label="gpt-5-mini",
timeout=GPT_CALL_TIMEOUT_SECONDS,
)
outputs = result.get("outputs")
if isinstance(outputs, list) and outputs and isinstance(outputs[0], str) and outputs[0].strip():
return outputs[0]
for key in ("output", "text", "response", "result", "content"):
v = result.get(key)
if isinstance(v, str) and v.strip():
return v
if isinstance(v, dict):
inner = v.get("text") or v.get("content")
if isinstance(inner, str) and inner.strip():
return inner
if isinstance(v, list) and v and isinstance(v[0], str):
return v[0]
raise RuntimeError(f"Could not extract gpt-5-mini text from response: {result}")
def _parse_json_loose(raw: str) -> Dict:
"""gpt-5-4 sometimes wraps JSON in markdown fences — strip and parse."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1:
return json.loads(text[start:end + 1])
raise
def _coerce_float(value: object, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _coerce_int(value: object, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _sanitize_highlights(raw_highlights: object, duration: float) -> List[Dict]:
"""Normalize model output into the expected shape; skip invalid entries."""
if not isinstance(raw_highlights, list):
return []
max_end = duration if duration > 0 else float("inf")
cleaned: List[Dict] = []
for item in raw_highlights:
if not isinstance(item, dict):
continue
start = _coerce_float(item.get("start_time"), default=-1.0)
end = _coerce_float(item.get("end_time"), default=-1.0)
if start < 0 or end <= start:
continue
if max_end != float("inf"):
start = min(start, max_end)
end = min(end, max_end)
if end <= start:
continue
cleaned.append(
{
"title": str(item.get("title") or "Untitled Highlight").strip(),
"start_time": start,
"end_time": end,
"score": max(0, min(100, _coerce_int(item.get("score"), default=0))),
"hook_sentence": str(item.get("hook_sentence") or "").strip(),
"virality_reason": str(item.get("virality_reason") or "").strip(),
}
)
return cleaned
def detect_content_type(transcript: Dict, llm_fn: LLMFn = call_muapi_llm) -> Dict[str, str]:
segments = transcript.get("segments", [])
sample = " ".join(s["text"] for s in segments[:25])[:3000]
prompt = f"{CONTENT_TYPE_PROMPT}\n\nTranscript sample:\n{sample}"
try:
raw = llm_fn(prompt)
return _parse_json_loose(raw)
except Exception:
return {"content_type": "other", "density": "medium"}
def build_transcript_text(transcript: Dict) -> str:
segments = transcript.get("segments", [])
return "\n".join(f"[{s['start']:.1f}s] {s['text'].strip()}" for s in segments)
def chunk_transcript(transcript: Dict) -> List[Dict]:
segments = transcript.get("segments", [])
duration = transcript.get("duration", segments[-1]["end"] if segments else 0)
chunks = []
start = 0
while start < duration:
end = min(start + CHUNK_SIZE_SECONDS, duration)
chunk_segs = [
s for s in segments
if s["start"] >= start and s["end"] <= end + CHUNK_OVERLAP_SECONDS
]
if chunk_segs:
chunk = dict(transcript)
chunk["segments"] = chunk_segs
chunk["duration"] = end - start
chunk["_offset"] = start
chunks.append(chunk)
start += CHUNK_SIZE_SECONDS - CHUNK_OVERLAP_SECONDS
return chunks
def call_highlight_api(
transcript_text: str,
content_info: Dict,
duration: float,
num_clips: int,
is_chunk: bool = False,
llm_fn: LLMFn = call_muapi_llm,
) -> Dict:
# Ask for ~2× the user's target so dedupe has headroom, but cap so the model
# doesn't have to generate a huge JSON payload (which times out gpt-5-mini).
target = max(num_clips * 2, 5)
natural_max = max(2 if is_chunk else 3, int(duration / 90))
min_clips = min(target, natural_max, 8)
system = HIGHLIGHT_SYSTEM_PROMPT.format(
virality_criteria=VIRALITY_CRITERIA,
content_type=content_info.get("content_type", "other"),
density=content_info.get("density", "medium"),
num_clips_instruction=f"Generate at least {min_clips} highlights",
)
base_prompt = f"{system}\n\nTranscript:\n{transcript_text}"
prompt = base_prompt
last_error = "unknown"
for attempt in range(1, MAX_HIGHLIGHT_API_ATTEMPTS + 1):
raw = llm_fn(prompt)
try:
parsed = _parse_json_loose(raw)
highlights = _sanitize_highlights(parsed.get("highlights"), duration=duration)
if highlights:
return {"highlights": highlights}
last_error = "no valid highlights in response"
except Exception as e:
last_error = str(e)
if attempt < MAX_HIGHLIGHT_API_ATTEMPTS:
print(
f"[highlights] invalid model output on attempt {attempt}/{MAX_HIGHLIGHT_API_ATTEMPTS}; retrying",
flush=True,
)
prompt = (
base_prompt
+ "\n\nIMPORTANT: Return ONLY valid JSON with a top-level 'highlights' array."
+ " Each item must include: title, start_time, end_time, score, hook_sentence, virality_reason."
+ " No markdown fences, no commentary."
)
raise RuntimeError(
f"Highlight generator produced invalid output after {MAX_HIGHLIGHT_API_ATTEMPTS} attempts: {last_error}"
)
def dedupe_highlights(highlights: List[Dict]) -> List[Dict]:
"""Drop a highlight if it overlaps >50% with a higher-scoring one already kept."""
highlights = sorted(highlights, key=lambda x: int(x.get("score", 0)), reverse=True)
kept: List[Dict] = []
for h in highlights:
h_start = float(h["start_time"])
h_end = float(h["end_time"])
h_dur = h_end - h_start
overlapping = False
for k in kept:
latest_start = max(h_start, float(k["start_time"]))
earliest_end = min(h_end, float(k["end_time"]))
overlap = earliest_end - latest_start
if overlap > 0 and overlap > 0.5 * h_dur:
overlapping = True
break
if not overlapping:
kept.append(h)
return kept
def get_highlights(
transcript: Dict,
num_clips: int = 3,
llm_fn: Optional[LLMFn] = None,
) -> Dict:
"""Main entry point — returns {highlights: [...]} sorted by score.
`llm_fn` swaps the underlying LLM. Defaults to MuAPI gpt-5-mini; local
mode passes in a local LLM-backed callable.
"""
llm_fn = llm_fn or call_muapi_llm
duration = transcript.get("duration", 0)
content_info = detect_content_type(transcript, llm_fn=llm_fn)
print(f"[highlights] content={content_info.get('content_type')} density={content_info.get('density')} duration={duration:.0f}s", flush=True)
if duration >= LONG_VIDEO_THRESHOLD:
chunks = chunk_transcript(transcript)
print(f"[highlights] long video — splitting into {len(chunks)} chunks", flush=True)
all_highlights: List[Dict] = []
for i, chunk in enumerate(chunks):
offset = chunk.get("_offset", 0)
text = build_transcript_text(chunk)
print(f"[highlights] chunk {i + 1}/{len(chunks)} (offset {offset:.0f}s)", flush=True)
result = call_highlight_api(text, content_info, chunk["duration"], num_clips=num_clips, is_chunk=True, llm_fn=llm_fn)
for h in result.get("highlights", []):
h["start_time"] = float(h["start_time"]) + offset
h["end_time"] = float(h["end_time"]) + offset
all_highlights.append(h)
highlights = dedupe_highlights(all_highlights)
else:
text = build_transcript_text(transcript)
result = call_highlight_api(text, content_info, duration, num_clips=num_clips, llm_fn=llm_fn)
highlights = dedupe_highlights(result.get("highlights", []))
return {"highlights": highlights}
@@ -0,0 +1,6 @@
"""Local-mode backends — no MuAPI calls, runs on your machine.
Used when the pipeline is invoked with mode="local". Requires the optional
deps in requirements-local.txt (yt-dlp, faster-whisper, openai, google-genai,
opencv, moviepy) plus an LLM API key for highlight ranking.
"""
@@ -0,0 +1,167 @@
"""Local clipping: ffmpeg subclip + OpenCV face-aware vertical crop.
Two stages per highlight:
1. Cut the source video to [start, end] with ffmpeg (re-encoded, audio kept).
2. Reframe the cut to the target aspect ratio. For 9:16 we slide a vertical
window horizontally across the frame to keep faces centred (Haar
cascade — same approach as the original repo, no external models).
"""
import os
import subprocess
from typing import Dict, List, Optional, Tuple
from ..config import LOCAL_OUTPUT_DIR
def _ratio(aspect_ratio: str) -> float:
"""Parse '9:16' → 9/16, '1:1' → 1.0."""
try:
w, h = aspect_ratio.split(":")
return float(w) / float(h)
except (ValueError, ZeroDivisionError):
return 9.0 / 16.0
def _cut_subclip(source_path: str, start: float, end: float, out_path: str) -> str:
"""ffmpeg -ss start -to end → re-encoded mp4 with audio."""
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", source_path,
"-ss", f"{start:.3f}",
"-to", f"{end:.3f}",
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-c:a", "aac", "-b:a", "128k",
out_path,
]
subprocess.run(cmd, check=True)
return out_path
def _reframe_vertical(in_path: str, out_path: str, aspect_ratio: str) -> str:
"""Crop the cut clip to the target aspect ratio, tracking faces if possible."""
try:
import cv2 # type: ignore
except ImportError as e:
raise RuntimeError(
"opencv-python is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
target_ratio = _ratio(aspect_ratio)
cap = cv2.VideoCapture(in_path)
if not cap.isOpened():
raise RuntimeError(f"could not open {in_path}")
src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
# Compute the largest crop that fits inside the frame at the target ratio.
if target_ratio < src_w / src_h:
crop_h = src_h
crop_w = int(crop_h * target_ratio)
else:
crop_w = src_w
crop_h = int(crop_w / target_ratio)
crop_w = max(2, crop_w - (crop_w % 2))
crop_h = max(2, crop_h - (crop_h % 2))
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
silent_path = out_path + ".silent.mp4"
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(silent_path, fourcc, fps, (crop_w, crop_h))
last_center: Optional[Tuple[int, int]] = None
smoothing = 0.15 # how aggressively to chase a new face position
while True:
ret, frame = cap.read()
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
if len(faces) > 0:
# Pick the largest face — usually the speaker.
x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
cx = x + w // 2
cy = y + h // 2
if last_center is None:
last_center = (cx, cy)
else:
lx, ly = last_center
last_center = (
int(lx + (cx - lx) * smoothing),
int(ly + (cy - ly) * smoothing),
)
if last_center is None:
last_center = (src_w // 2, src_h // 2)
cx, cy = last_center
x0 = max(0, min(src_w - crop_w, cx - crop_w // 2))
y0 = max(0, min(src_h - crop_h, cy - crop_h // 2))
cropped = frame[y0:y0 + crop_h, x0:x0 + crop_w]
writer.write(cropped)
cap.release()
writer.release()
# Mux audio from the cut clip back onto the silent reframed video.
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", silent_path,
"-i", in_path,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k",
"-map", "0:v:0", "-map", "1:a:0?",
"-shortest",
out_path,
]
subprocess.run(cmd, check=True)
os.remove(silent_path)
return out_path
def crop_clip_local(
source_path: str,
start_time: float,
end_time: float,
aspect_ratio: str,
out_path: str,
) -> str:
"""Cut + reframe one highlight, returning the local mp4 path."""
cut_path = out_path + ".cut.mp4"
try:
_cut_subclip(source_path, start_time, end_time, cut_path)
_reframe_vertical(cut_path, out_path, aspect_ratio)
finally:
if os.path.exists(cut_path):
os.remove(cut_path)
return out_path
def crop_highlights_local(
source_path: str,
highlights: List[Dict],
aspect_ratio: str = "9:16",
out_dir: Optional[str] = None,
) -> List[Dict]:
out_dir = out_dir or LOCAL_OUTPUT_DIR
os.makedirs(out_dir, exist_ok=True)
results: List[Dict] = []
for i, h in enumerate(highlights, 1):
out_path = os.path.join(out_dir, f"short_{i:02d}.mp4")
print(f"[clip/local] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
try:
crop_clip_local(
source_path,
float(h["start_time"]),
float(h["end_time"]),
aspect_ratio,
out_path,
)
results.append({**h, "clip_url": out_path})
except Exception as e:
print(f"[clip/local] {i} failed: {e}", flush=True)
results.append({**h, "clip_url": None, "error": str(e)})
return results
@@ -0,0 +1,135 @@
"""Local YouTube download via yt-dlp.
Returns a local mp4 path so the rest of the local pipeline can read it
directly off disk.
"""
import os
import re
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from typing import Optional
from ..config import LOCAL_OUTPUT_DIR
def _import_ytdlp():
try:
import yt_dlp # type: ignore
except ImportError as e:
raise RuntimeError(
"yt-dlp is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
return yt_dlp
def _format_for(fmt: str) -> str:
"""Map our '720' / '1080' shorthand to a yt-dlp format selector."""
try:
height = int(fmt)
except ValueError:
height = 720
return (
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/"
f"best[height<={height}][ext=mp4]/best"
)
def _extract_youtube_video_id(source: str) -> Optional[str]:
"""Best-effort extraction of a YouTube video id from a URL."""
parsed = urlparse(source)
host = (parsed.netloc or "").lower()
if host.startswith("www."):
host = host[4:]
if host in ("youtu.be", "www.youtu.be"):
video_id = parsed.path.lstrip("/").split("/", 1)[0]
return video_id or None
if "youtube.com" in host:
if parsed.path.startswith("/watch"):
qs = parse_qs(parsed.query)
video_id = qs.get("v", [""])[0]
return video_id or None
match = re.search(r"/(?:shorts|embed|live)/([^/?#&]+)", parsed.path)
if match:
return match.group(1)
return None
def _resolve_local_path(source: str) -> Optional[str]:
"""Return a local filesystem path if the input already points at one."""
parsed = urlparse(source)
if parsed.scheme == "file":
raw_path = unquote(parsed.path)
if parsed.netloc and parsed.netloc not in ("", "localhost"):
raw_path = f"//{parsed.netloc}{raw_path}"
candidate = Path(raw_path).expanduser()
if candidate.exists() and candidate.is_file():
return str(candidate.resolve())
raise RuntimeError(f"Local file URL does not exist: {source}")
if parsed.scheme in ("http", "https"):
return None
candidate = Path(source).expanduser()
if candidate.exists() and candidate.is_file():
return str(candidate.resolve())
if any(sep in source for sep in (os.sep, "/")) or source.startswith("~") or source.startswith("."):
raise RuntimeError(f"Local file path does not exist: {source}")
return None
def _existing_download(out_dir: str, video_id: str) -> Optional[str]:
"""Return a cached download path if we already have this YouTube id."""
for ext in (".mp4", ".mkv", ".webm"):
candidate = os.path.join(out_dir, f"source_{video_id}{ext}")
if os.path.exists(candidate):
return candidate
return None
def download_youtube_local(video_url: str, fmt: str = "720", out_dir: Optional[str] = None) -> str:
"""Download a remote URL or return a local file path unchanged."""
local_path = _resolve_local_path(video_url)
if local_path:
print(f"[download/local] using local file: {local_path}", flush=True)
return local_path
yt_dlp = _import_ytdlp()
out_dir = out_dir or LOCAL_OUTPUT_DIR
os.makedirs(out_dir, exist_ok=True)
video_id = _extract_youtube_video_id(video_url)
if video_id:
cached = _existing_download(out_dir, video_id)
if cached:
print(f"[download/local] reusing cached download: {cached}", flush=True)
return cached
print(f"[download/local] {video_url} @ {fmt}p → {out_dir}/", flush=True)
ydl_opts = {
"format": _format_for(fmt),
"outtmpl": os.path.join(out_dir, "source_%(id)s.%(ext)s"),
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"noprogress": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
path = ydl.prepare_filename(info)
# merge_output_format may rename the extension after merge
if not os.path.exists(path):
stem, _ = os.path.splitext(path)
for ext in (".mp4", ".mkv", ".webm"):
if os.path.exists(stem + ext):
path = stem + ext
break
print(f"[download/local] ready: {path}", flush=True)
return path
@@ -0,0 +1,62 @@
"""Local LLM backend — OpenAI or Gemini, selected by LLM_PROVIDER."""
from ..config import (
GEMINI_MODEL,
LLM_PROVIDER,
OPENAI_MODEL,
require_gemini_key,
require_openai_key,
)
def call_openai_llm(prompt: str) -> str:
"""OpenAI Chat Completions backend used by --mode local."""
try:
from openai import OpenAI # type: ignore
except ImportError as e:
raise RuntimeError(
"openai is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
client = OpenAI(api_key=require_openai_key())
response = client.chat.completions.create(
model=OPENAI_MODEL,
temperature=0.7,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content or ""
def call_gemini_llm(prompt: str) -> str:
"""Gemini backend used by --mode local when LLM_PROVIDER=gemini."""
try:
from google import genai # type: ignore
except ImportError as e:
raise RuntimeError(
"google-genai is required for LLM_PROVIDER=gemini. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
client = genai.Client(api_key=require_gemini_key())
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=prompt,
config={
"temperature": 0.2,
"response_mime_type": "application/json",
"max_output_tokens": 8192,
},
)
return response.text or ""
def call_local_llm(prompt: str) -> str:
"""Dispatch to the configured local LLM provider."""
provider = (LLM_PROVIDER or "openai").strip().lower()
if provider == "openai":
return call_openai_llm(prompt)
if provider == "gemini":
return call_gemini_llm(prompt)
raise RuntimeError(
f"Unknown LLM_PROVIDER={provider!r}. Use 'openai' or 'gemini'."
)
@@ -0,0 +1,162 @@
"""Local transcription via faster-whisper.
Reads a local media file and returns the same shape the highlight generator
expects: {duration, segments[start, end, text]}.
"""
import os
import re
from pathlib import Path
from typing import Dict, Optional
from ..config import LOCAL_OUTPUT_DIR, LOCAL_WHISPER_DEVICE, LOCAL_WHISPER_MODEL
def _transcript_cache_path(media_path: str) -> Path:
"""Return the .srt cache path for a media file."""
cache_dir = Path(LOCAL_OUTPUT_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / (Path(media_path).stem + ".srt")
def _format_srt_timestamp(seconds: float) -> str:
total_ms = max(0, int(round(seconds * 1000)))
ms = total_ms % 1000
total_s = total_ms // 1000
s = total_s % 60
total_m = total_s // 60
m = total_m % 60
h = total_m // 60
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _parse_srt_timestamp(value: str) -> float:
match = re.fullmatch(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", value.strip())
if not match:
raise ValueError(f"Invalid SRT timestamp: {value!r}")
hours, minutes, seconds, millis = map(int, match.groups())
return hours * 3600 + minutes * 60 + seconds + (millis / 1000.0)
def _write_srt_cache(media_path: str, transcript: Dict) -> Path:
cache_path = _transcript_cache_path(media_path)
lines = []
for idx, segment in enumerate(transcript.get("segments", []), start=1):
start = _format_srt_timestamp(float(segment["start"]))
end = _format_srt_timestamp(float(segment["end"]))
text = str(segment.get("text", "")).strip().replace("\r", "").replace("\n", " ")
lines.append(str(idx))
lines.append(f"{start} --> {end}")
lines.append(text)
lines.append("")
cache_path.write_text("\n".join(lines), encoding="utf-8")
return cache_path
def _load_srt_cache(cache_path: Path) -> Dict:
content = cache_path.read_text(encoding="utf-8-sig").strip()
if not content:
return {"duration": 0.0, "segments": []}
segments = []
for block in re.split(r"\n\s*\n", content):
lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip()]
if not lines:
continue
if "-->" not in lines[0] and len(lines) > 1 and "-->" in lines[1]:
lines = lines[1:]
if not lines or "-->" not in lines[0]:
continue
start_raw, end_raw = [part.strip() for part in lines[0].split("-->", 1)]
text = "\n".join(lines[1:]).strip()
segments.append(
{
"start": _parse_srt_timestamp(start_raw),
"end": _parse_srt_timestamp(end_raw),
"text": text,
}
)
duration = segments[-1]["end"] if segments else 0.0
return {"duration": duration, "segments": segments}
def _resolve_device() -> str:
if LOCAL_WHISPER_DEVICE != "auto":
return LOCAL_WHISPER_DEVICE
try:
import torch # type: ignore
if torch.cuda.is_available():
# Test that CUDA actually works (catches missing cuBLAS/cuDNN libs)
torch.zeros(1, device="cuda")
return "cuda"
except (ImportError, OSError, RuntimeError):
pass
return "cpu"
def transcribe_local(media_path: str, language: Optional[str] = None) -> Dict:
"""Run faster-whisper on a local file path, caching the result as .srt."""
cache_path = _transcript_cache_path(media_path)
if cache_path.exists():
source_mtime = os.path.getmtime(media_path)
cache_mtime = cache_path.stat().st_mtime
if cache_mtime >= source_mtime:
print(f"[transcribe/local] reusing cached transcript: {cache_path}", flush=True)
cached = _load_srt_cache(cache_path)
# Treat empty cache as invalid (likely from a failed/partial run) — delete and re-transcribe
if not cached["segments"] or cached["duration"] <= 0.0:
print(f"[transcribe/local] cache is empty/invalid, deleting: {cache_path}", flush=True)
cache_path.unlink(missing_ok=True)
else:
print(
f"[transcribe/local] {len(cached['segments'])} cached segments, "
f"{cached['duration']:.0f}s of audio",
flush=True,
)
return cached
try:
from faster_whisper import WhisperModel # type: ignore
except ImportError as e:
raise RuntimeError(
"faster-whisper is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
device = _resolve_device()
compute_type = "float16" if device == "cuda" else "int8"
print(f"[transcribe/local] faster-whisper model={LOCAL_WHISPER_MODEL} device={device}", flush=True)
from ..config import LOCAL_WHISPER_VAD_FILTER, LOCAL_WHISPER_VAD_PARAMETERS
model = WhisperModel(LOCAL_WHISPER_MODEL, device=device, compute_type=compute_type)
transcribe_kwargs = {
"audio": media_path,
"language": language,
"beam_size": 5,
"condition_on_previous_text": False,
}
if LOCAL_WHISPER_VAD_FILTER:
transcribe_kwargs["vad_filter"] = True
transcribe_kwargs["vad_parameters"] = LOCAL_WHISPER_VAD_PARAMETERS
else:
transcribe_kwargs["vad_filter"] = False
segments_iter, info = model.transcribe(**transcribe_kwargs)
segments = []
for s in segments_iter:
segments.append({
"start": float(s.start),
"end": float(s.end),
"text": (s.text or "").strip(),
})
duration = float(getattr(info, "duration", 0.0)) or (segments[-1]["end"] if segments else 0.0)
print(f"[transcribe/local] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
transcript = {"duration": duration, "segments": segments}
cache_path = _write_srt_cache(media_path, transcript)
print(f"[transcribe/local] wrote cache: {cache_path}", flush=True)
return transcript
@@ -0,0 +1,97 @@
"""Thin MuAPI client: submit a job, poll until it finishes, return the result."""
import time
from typing import Any, Dict, Optional
import requests
from .config import (
MUAPI_BASE_URL,
POLL_INTERVAL_SECONDS,
POLL_TIMEOUT_SECONDS,
require_api_key,
)
class MuAPIError(RuntimeError):
pass
def _headers() -> Dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": require_api_key(),
}
def submit(endpoint: str, payload: Dict[str, Any], retries: int = 3) -> str:
"""POST to /api/v1/{endpoint} and return the request_id; retry transient errors."""
url = f"{MUAPI_BASE_URL}/{endpoint.lstrip('/')}"
last_err: Optional[Exception] = None
for _ in range(retries):
try:
resp = requests.post(url, json=payload, headers=_headers(), timeout=120)
if resp.status_code >= 400:
raise MuAPIError(f"{endpoint} submit failed [{resp.status_code}]: {resp.text}")
data = resp.json()
request_id = data.get("request_id") or data.get("id")
if not request_id:
raise MuAPIError(f"{endpoint} response had no request_id: {data}")
return str(request_id)
except (requests.Timeout, requests.ConnectionError) as e:
last_err = e
time.sleep(2)
raise MuAPIError(f"{endpoint} submit failed after {retries} retries: {last_err}")
def fetch_result(request_id: str, retries: int = 3) -> Dict[str, Any]:
"""GET the latest result for a request_id; retry on transient timeouts."""
url = f"{MUAPI_BASE_URL}/predictions/{request_id}/result"
last_err: Optional[Exception] = None
for _ in range(retries):
try:
resp = requests.get(url, headers=_headers(), timeout=90)
if resp.status_code >= 400:
raise MuAPIError(f"poll failed [{resp.status_code}]: {resp.text}")
return resp.json()
except (requests.Timeout, requests.ConnectionError) as e:
last_err = e
time.sleep(2)
raise MuAPIError(f"poll failed after {retries} retries: {last_err}")
def poll(
request_id: str,
interval: float = POLL_INTERVAL_SECONDS,
timeout: float = POLL_TIMEOUT_SECONDS,
label: Optional[str] = None,
) -> Dict[str, Any]:
"""Block until the prediction is done; return the final payload."""
deadline = time.time() + timeout
last_status = None
while time.time() < deadline:
data = fetch_result(request_id)
status = (data.get("status") or "").lower()
if status and status != last_status:
print(f"[muapi] {label or request_id}: {status}", flush=True)
last_status = status
if status in ("completed", "succeeded", "success"):
return data
if status in ("failed", "error"):
raise MuAPIError(f"{label or request_id} failed: {data}")
time.sleep(interval)
raise MuAPIError(f"{label or request_id} timed out after {timeout}s")
def run(
endpoint: str,
payload: Dict[str, Any],
label: Optional[str] = None,
interval: float = POLL_INTERVAL_SECONDS,
timeout: float = POLL_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
"""Submit then poll. Returns the final result payload."""
request_id = submit(endpoint, payload)
return poll(request_id, interval=interval, timeout=timeout, label=label or endpoint)
@@ -0,0 +1,123 @@
"""End-to-end orchestrator.
Two modes:
* mode="api" (default) — MuAPI does download / transcribe / LLM / autocrop.
Fast, no local deps, pay-per-call.
* mode="local" — yt-dlp + faster-whisper + OpenAI or Gemini + ffmpeg/opencv.
Self-hosted, LLM_PROVIDER selects OpenAI or Gemini.
"""
from typing import Dict, List, Optional
from .clipper import crop_highlights
from .downloader import download_youtube
from .highlights import call_muapi_llm, get_highlights
from .transcriber import transcribe
def _run_local(
youtube_url: str,
num_clips: int,
aspect_ratio: str,
download_format: str,
language: Optional[str],
) -> Dict:
from .local.clipper import crop_highlights_local
from .local.downloader import download_youtube_local
from .local.llm import call_local_llm
from .local.transcriber import transcribe_local
source_path = download_youtube_local(youtube_url, fmt=download_format)
transcript = transcribe_local(source_path, language=language)
if not transcript["segments"]:
raise RuntimeError(
"Whisper produced no segments. The video may have no detectable speech."
)
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_local_llm)
all_highlights: List[Dict] = highlights_result.get("highlights", [])
if not all_highlights:
raise RuntimeError("Highlight generator returned zero clips.")
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
print(f"[pipeline/local] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
shorts = crop_highlights_local(source_path, top, aspect_ratio=aspect_ratio)
return {
"mode": "local",
"source_video_url": source_path,
"transcript": transcript,
"highlights": all_highlights,
"shorts": shorts,
}
def _run_api(
youtube_url: str,
num_clips: int,
aspect_ratio: str,
download_format: str,
language: Optional[str],
) -> Dict:
source_url = download_youtube(youtube_url, fmt=download_format)
transcript = transcribe(source_url, language=language)
if not transcript["segments"]:
raise RuntimeError(
"Whisper produced no segments. The video may have no detectable speech."
)
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_muapi_llm)
all_highlights: List[Dict] = highlights_result.get("highlights", [])
if not all_highlights:
raise RuntimeError("Highlight generator returned zero clips.")
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
print(f"[pipeline] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
shorts = crop_highlights(source_url, top, aspect_ratio=aspect_ratio)
return {
"mode": "api",
"source_video_url": source_url,
"transcript": transcript,
"highlights": all_highlights,
"shorts": shorts,
}
def generate_shorts(
youtube_url: str,
num_clips: int = 3,
aspect_ratio: str = "9:16",
download_format: str = "720",
language: Optional[str] = None,
mode: str = "api",
) -> Dict:
"""Run the full pipeline and return a structured result.
Args:
youtube_url: source URL.
num_clips: how many shorts to render.
aspect_ratio: e.g. "9:16", "1:1".
download_format: source resolution ("360" / "480" / "720" / "1080").
language: ISO-639-1 to force Whisper language detection.
mode: "api" (default, MuAPI) or "local" (yt-dlp + faster-whisper +
OpenAI or Gemini + ffmpeg).
Returns:
{
"mode": "api" | "local",
"source_video_url": str, # hosted URL (api) or local path (local)
"transcript": {...},
"highlights": [...], # all candidates ranked
"shorts": [...], # top `num_clips` with clip_url / local path
}
"""
mode = (mode or "api").lower()
if mode == "local":
return _run_local(youtube_url, num_clips, aspect_ratio, download_format, language)
if mode == "api":
return _run_api(youtube_url, num_clips, aspect_ratio, download_format, language)
raise ValueError(f"Unknown mode: {mode!r}. Use 'api' or 'local'.")
@@ -0,0 +1,76 @@
"""Transcription via MuAPI /openai-whisper.
Sends a hosted media URL to MuAPI's Whisper endpoint and returns the segment
shape expected by the highlight generator: {duration, segments[start,end,text]}.
The API runs verbose_json server-side, so we get per-segment timestamps for free.
"""
import json
from typing import Dict, Optional
from . import muapi
def _coerce_verbose(raw) -> Dict:
"""The /openai-whisper result can land as a dict or a JSON string depending on
how the worker stored it. Normalise to a dict with `duration` and `segments`."""
if isinstance(raw, str):
try:
return json.loads(raw)
except (TypeError, ValueError):
return {}
if isinstance(raw, dict):
return raw
return {}
def _extract_verbose_payload(result: Dict) -> Dict:
"""MuAPI wraps results inconsistently across endpoints. Hunt for the
verbose_json blob (which has `segments` + `duration`)."""
for key in ("output", "result", "outputs"):
v = result.get(key)
if isinstance(v, dict) and "segments" in v:
return v
if isinstance(v, list) and v:
first = v[0]
decoded = _coerce_verbose(first)
if "segments" in decoded:
return decoded
if isinstance(v, str):
decoded = _coerce_verbose(v)
if "segments" in decoded:
return decoded
if "segments" in result:
return result
raise RuntimeError(f"Could not find Whisper segments in MuAPI response: {result}")
def transcribe(media_url: str, language: Optional[str] = None) -> Dict:
"""Run MuAPI /openai-whisper on a hosted media URL.
Returns {duration: float, segments: [{start, end, text}, ...]} so it slots
straight into the highlight generator.
"""
print(f"[transcribe] muapi /openai-whisper on {media_url}", flush=True)
payload = {
"audio_url": media_url,
"response_format": "verbose_json",
}
if language:
payload["language"] = language
result = muapi.run("openai-whisper", payload, label="openai-whisper")
verbose = _extract_verbose_payload(result)
segments = []
for s in verbose.get("segments") or []:
segments.append({
"start": float(s.get("start", 0.0)),
"end": float(s.get("end", 0.0)),
"text": (s.get("text") or "").strip(),
})
duration = float(verbose.get("duration") or (segments[-1]["end"] if segments else 0.0))
print(f"[transcribe] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
return {"duration": duration, "segments": segments}
Submodule brainrotinator deleted from 76983b03b7
+47
View File
@@ -0,0 +1,47 @@
#ignore these directories and files
#done_split/
#to_split/
__pycache__/
#subtitles/
Python upload bot client secrets/
Python2 client secrets/
#profile/cookies #docker does not support gui dont want to setup server
#vosk-model-en-us-0.42-gigaspeech/
vosk-model-en-us-0.42-gigaspeech.zip
#ignore files ending in .mp4 in done_split folder
done_split/*.mp4
done_split/uploaded/*.mp4
done_split/temp/*.mp4
done_split/vosk vs whisper/
done_split/Unuploaded Before Reworks/
to_split/*.mp4
to_split/edited/*.mp4
subtitles/*
to_split/edited/*
to_split/video/*
to_split/audio/*
#Python upload bot client secrets/
#Python2 client secrets/
#ignore these files
client_secrets.json
main.py-oauth2.json
#main.py-oauth2.json
geckodriver.log
geckodriver
#ignore this type of file
*.mp3
*.mp4
# AI Models and Data Folders (We map these with volumes instead of baking into img)
models/
to_split/
done_split/
subtitles/
.git/
+49
View File
@@ -0,0 +1,49 @@
#ignore these directories and files
#done_split/
#to_split/
__pycache__/
#subtitles/
Python upload bot client secrets/
Python2 client secrets/
profile/cookies
TiktokAutoUploader/
vosk-model-en-us-0.42-gigaspeech/
vosk-model-en-us-0.42-gigaspeech.zip
#ignore files ending in .mp4 in done_split folder
done_split/*.mp4
done_split/uploaded/*.mp4
done_split/temp/*.mp4
done_split/vosk vs whisper/
done_split/Unuploaded Before Reworks/
to_split/*.mp4
to_split/edited/*.mp4
subtitles/*
to_split/edited/*
to_split/video/*
to_split/audio/*
#Python upload bot client secrets/
#Python2 client secrets/
#ignore these files
client_secrets.json
main.py-oauth2.json
#main.py-oauth2.json
geckodriver.log
geckodriver
#ignore this type of file
*.mp3
*.mp4
models--TinyLlama--TinyLlama-1.1B-Chat-v1.0
profile/
.mypy_cache/
.claude/
.claude/*
.claude
+47
View File
@@ -0,0 +1,47 @@
# Use an official Python runtime as a base image
FROM python:3.12.2
# Set the working directory in the container
WORKDIR /app
# System deps: git for pip-from-git, ffmpeg (with libass for subtitle burn-in), fonts, debugging tools.
RUN apt-get update && apt-get install -y --no-install-recommends \
git bash nano wget ca-certificates \
ffmpeg fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Verify libass is built into ffmpeg (subtitle burn-in needs it).
RUN ffmpeg -hide_banner -filters 2>/dev/null | grep -q "ass " || (echo "ffmpeg lacks ass filter" && exit 1)
# Upgrade pip and pin setuptools < 71 (newer versions break legacy setup.py
# packages like openai-whisper that import pkg_resources at module top-level).
RUN pip install --upgrade pip "setuptools<71" wheel
# Copy requirements first so Docker can cache the installed packages layer
COPY requirements.txt /app/
# Install Python deps. --no-build-isolation lets setup.py-based packages reuse
# the setuptools we just pinned, instead of pip pulling latest into an isolated env.
RUN pip install --no-cache-dir --no-build-isolation -r requirements.txt
# Copy the rest of the application code
COPY . /app
# Geckodriver for the (optional) selenium uploaders.
RUN wget -q https://github.com/mozilla/geckodriver/releases/download/v0.32.0/geckodriver-v0.32.0-linux64.tar.gz \
&& tar -xzf geckodriver-v0.32.0-linux64.tar.gz \
&& mv geckodriver /usr/local/bin/ \
&& rm geckodriver-v0.32.0-linux64.tar.gz \
&& geckodriver --version
# Firefox for selenium uploaders (optional). firefox-esr ships in Debian stable
# so we don't need to add the `unstable` repo (which causes openssl conflicts).
RUN apt-get update \
&& apt-get install -y --no-install-recommends firefox-esr \
&& rm -rf /var/lib/apt/lists/*
# Gradio default port
EXPOSE 7860
# Default: launch the Gradio UI. Override with `docker run ... bash` for the CLI.
CMD ["python", "app.py"]
+371
View File
@@ -0,0 +1,371 @@
"""Gradio UI for brainrotinator.
Tabs:
- Edit: upload a file or YouTube URL, run the splitter, watch logs stream.
- Library: list / preview / delete clips in done_split/.
- Settings: read/write config.json.
The CLI (`main.py -e/-u`) still works; this UI is additive.
"""
from __future__ import annotations
import io
import json
import logging
import os
import re
import shutil
import sys
import threading
import time
from contextlib import redirect_stdout
from pathlib import Path
import gradio as gr
from brainrotinator.video_editor import VideoEditor
from config import Config
from downloader.downloadVid import download_vid
PROJECT_ROOT = Path(__file__).resolve().parent
TO_SPLIT = PROJECT_ROOT / "to_split"
DONE_SPLIT = PROJECT_ROOT / "done_split"
EDITED = TO_SPLIT / "edited"
class _StreamCapture(io.TextIOBase):
"""Tee writes into a thread-safe buffer so a Gradio generator can drain it."""
def __init__(self):
self._buf: list[str] = [""]
self._lock = threading.Lock()
self._ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
def write(self, s: str) -> int:
with self._lock:
# Strip ANSI color codes so the Gradio textbox is readable
clean_s = self._ansi_escape.sub('', s)
# Handle carriage returns so tqdm progress bars don't spam multiple lines
if '\r' in clean_s:
parts = clean_s.split('\r')
if parts[0]:
self._buf[-1] += parts[0]
for p in parts[1:]:
if p:
self._buf[-1] = p
else:
if '\n' in clean_s:
lines = clean_s.split('\n')
self._buf[-1] += lines[0]
self._buf.extend(lines[1:])
else:
self._buf[-1] += clean_s
return len(s)
def drain(self) -> str:
with self._lock:
# We join the buffer but we don't clear it.
# This allows Gradio updates to be built cleanly over carriage returns without duplicates.
return "\n".join(self._buf).strip()
ACTIVE_EDITOR = None
def _list_to_split() -> list[str]:
if not TO_SPLIT.exists():
return []
return sorted(p.name for p in TO_SPLIT.glob("*.*") if p.suffix.lower() in [".mp4", ".mov", ".mkv"])
def _run_edit_job(
uploaded_file: str | None,
youtube_url: str,
existing_file: str | None,
chunk_duration: int,
blur: bool,
use_whisper: bool,
filter_profanity: bool,
subtitle_font_size: int,
subtitle_margin_v: int,
):
"""Generator that yields cumulative log text as the edit job runs."""
global ACTIVE_EDITOR
TO_SPLIT.mkdir(exist_ok=True)
DONE_SPLIT.mkdir(exist_ok=True)
EDITED.mkdir(exist_ok=True)
cfg = Config.load()
capture = _StreamCapture()
def get_state(final=False):
if final:
return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False)
return gr.update(value="Running...", interactive=False), gr.update(value="Stop", interactive=True)
def emit_sys(line: str, final=False):
# Directly write system log changes to the stream capture buffer
capture.write(line)
st = get_state(final)
return capture.drain(), st[0], st[1]
yield emit_sys("Preparing input...\n")
# Resolve input.
if uploaded_file:
dest = TO_SPLIT / Path(uploaded_file).name
shutil.copy(uploaded_file, dest)
input_path = dest
yield emit_sys(f"Copied uploaded file to {dest}\n")
elif existing_file:
input_path = TO_SPLIT / existing_file
yield emit_sys(f"Using existing file: {input_path}\n")
elif youtube_url.strip():
yield emit_sys(f"Downloading {youtube_url}...\n")
try:
with redirect_stdout(capture):
download_vid(youtube_url.strip(), str(TO_SPLIT))
st = get_state()
yield capture.drain(), st[0], st[1]
except Exception as e:
yield emit_sys(f"Download failed: {e}\n", final=True)
return
# Pick the newest mp4 in to_split that isn't in edited/.
mp4s = sorted(
[p for p in TO_SPLIT.glob("*.mp4") if p.is_file()],
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not mp4s:
yield emit_sys("No mp4 found after download.\n", final=True)
return
input_path = mp4s[0]
else:
yield emit_sys("Provide a file or a YouTube URL.\n", final=True)
return
yield emit_sys(f"Editing: {input_path}\n")
editor = VideoEditor(
input_path=str(input_path),
output_folder=str(DONE_SPLIT),
chunk_duration=int(chunk_duration),
name=input_path.stem,
useWhisper=use_whisper,
filterProfanityInSubtitles=filter_profanity,
voskModelDir=cfg.voskModelDir,
tinyLlamaDir=cfg.tinyLlamaDir,
subtitleFontSize=int(subtitle_font_size),
subtitleMarginV=int(subtitle_margin_v),
)
ACTIVE_EDITOR = editor
# Run the splitter on a background thread so we can stream prints.
error_box: list[BaseException] = []
def _runner():
try:
with redirect_stdout(capture):
if blur:
editor.split_video_into_chunks_blur()
else:
editor.split_video_into_chunks()
except BaseException as e:
error_box.append(e)
t = threading.Thread(target=_runner, daemon=True)
t.start()
while t.is_alive():
time.sleep(0.4)
chunk = capture.drain()
if chunk:
st = get_state()
yield chunk, st[0], st[1]
chunk = capture.drain()
if chunk:
st = get_state()
yield chunk, st[0], st[1]
if error_box:
yield emit_sys(f"\nERROR: {error_box[0]}\n", final=True)
ACTIVE_EDITOR = None
return
# Move source to edited/.
try:
shutil.move(str(input_path), str(EDITED / input_path.name))
yield emit_sys(f"\nMoved source to {EDITED / input_path.name}\n")
except Exception as e:
yield emit_sys(f"\nCouldn't move source: {e}\n")
yield emit_sys("\nDone.\n", final=True)
ACTIVE_EDITOR = None
def _list_clips() -> list[str]:
if not DONE_SPLIT.exists():
return []
return sorted(str(p) for p in DONE_SPLIT.glob("*.mp4"))
def _delete_clip(path: str) -> tuple[list[str], str]:
if path and os.path.exists(path):
os.remove(path)
return _list_clips(), f"Deleted {path}"
return _list_clips(), "Nothing to delete."
def _update_preview(font_size: int, margin_v: int) -> str:
"""Returns HTML for a phone-framed 9:16 preview matching the ASS subtitle render."""
# ASS renders font_size on a 1080x1920 canvas.
# 1% cqw = container_width/100; font occupies font_size/1080 of canvas width.
font_size_pct = (font_size / 1080) * 100
# margin_v is pixels from top on a 1920px canvas.
top_pct = (margin_v / 1920) * 100
# ASS outline=3 at font_size=100 → ~3% of font size; 0.04em is a reasonable approximation.
shadow = "0.04em 0.04em 0 #000, -0.04em -0.04em 0 #000, 0.04em -0.04em 0 #000, -0.04em 0.04em 0 #000, 0 0.04em 0 #000, 0 -0.04em 0 #000, 0.04em 0 0 #000, -0.04em 0 0 #000"
return f'''
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">
<div style="display:flex; justify-content:center; padding:16px 8px;">
<!-- Phone outer shell -->
<div style="position:relative; width:200px; background:#111; border-radius:38px; padding:10px;
box-shadow: 0 0 0 1px #3a3a3a, inset 0 0 0 1px #222, 0 24px 64px rgba(0,0,0,0.7);
border: 2px solid #444;">
<!-- Volume buttons (left) -->
<div style="position:absolute; left:-4px; top:70px; width:4px; height:26px; background:#333; border-radius:3px 0 0 3px;"></div>
<div style="position:absolute; left:-4px; top:106px; width:4px; height:44px; background:#333; border-radius:3px 0 0 3px;"></div>
<div style="position:absolute; left:-4px; top:160px; width:4px; height:44px; background:#333; border-radius:3px 0 0 3px;"></div>
<!-- Power button (right) -->
<div style="position:absolute; right:-4px; top:110px; width:4px; height:60px; background:#333; border-radius:0 3px 3px 0;"></div>
<!-- Screen -->
<div style="width:100%; aspect-ratio:9/16; border-radius:28px; overflow:hidden;
position:relative; container-type:inline-size;">
<!-- Blurred video-like background -->
<div style="position:absolute; inset:-10%; width:120%; height:120%;
background: linear-gradient(160deg,
#0d0d0d 0%, #1a1a2e 15%, #16213e 30%,
#0f3460 45%, #533483 55%,
#0f3460 65%, #16213e 78%,
#1a1a2e 90%, #0d0d0d 100%);
filter:blur(18px);"></div>
<!-- Darkening overlay for contrast -->
<div style="position:absolute; inset:0; background:rgba(0,0,0,0.35);"></div>
<!-- Dynamic island -->
<div style="position:absolute; top:8px; left:50%; transform:translateX(-50%);
width:72px; height:22px; background:#000; border-radius:11px; z-index:10;"></div>
<!-- Subtitle text -->
<div style="position:absolute; top:{top_pct}%; left:50%; transform:translateX(-50%);
color:#fff; font-family:'Bangers', Impact, sans-serif; font-weight:700;
font-size:{font_size_pct}cqw; text-align:center;
text-shadow:{shadow};
width:90%; word-wrap:break-word; line-height:1.15; z-index:5;
letter-spacing:0.02em;">
I finally found a use case...
</div>
</div>
</div>
</div>
'''
def _stop_job():
global ACTIVE_EDITOR
if ACTIVE_EDITOR:
ACTIVE_EDITOR.abort_flag = True
from brainrotinator import ffmpeg_ops
ffmpeg_ops.cancel()
return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False)
def build_ui() -> gr.Blocks:
cfg = Config.load()
with gr.Blocks(title="Brainrotinator") as demo:
gr.Markdown("# Brainrotinator\nPodcast clip automation — FFmpeg edition.")
with gr.Tab("Edit"):
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### Input Video")
file_in = gr.File(label="Upload mp4", file_types=[".mp4"], type="filepath")
url_in = gr.Textbox(label="...or YouTube URL")
with gr.Row():
to_split_in = gr.Dropdown(label="...or existing file in to_split/", choices=[None] + _list_to_split(), value=None)
refresh_files_btn = gr.Button("", size="sm")
gr.Markdown("### Edit Settings")
chunk_dur = gr.Slider(15, 120, value=cfg.chunkDuration, step=1, label="Chunk duration (s)")
blur = gr.Checkbox(value=cfg.blurTopBottomOfClip, label="Blur top/bottom (letterbox)")
whisper = gr.Checkbox(value=cfg.useWhisperForTranscription, label="Use Whisper (else Vosk)")
filt = gr.Checkbox(value=cfg.filterProfanityInSubtitles, label="Filter profanity in burned subtitles")
gr.Markdown("### Subtitle Settings")
sub_size = gr.Slider(10, 200, value=cfg.subtitleFontSize, step=1, label="Font Size")
sub_margin = gr.Slider(0, 1920, value=cfg.subtitleMarginV, step=10, label="Margin From Top (px)")
with gr.Row():
run_btn = gr.Button("Run", variant="primary")
stop_btn = gr.Button("Stop", variant="stop", interactive=False)
with gr.Column(scale=1):
gr.Markdown("### Subtitle Preview")
preview_html = gr.HTML(value=_update_preview(cfg.subtitleFontSize, cfg.subtitleMarginV))
log_box = gr.Textbox(label="Logs", lines=20, max_lines=20, autoscroll=True)
sub_size.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html)
sub_margin.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html)
refresh_files_btn.click(lambda: gr.update(choices=[None] + _list_to_split(), value=None), outputs=to_split_in)
run_click_event = run_btn.click(
_run_edit_job,
inputs=[file_in, url_in, to_split_in, chunk_dur, blur, whisper, filt, sub_size, sub_margin],
outputs=[log_box, run_btn, stop_btn],
)
stop_btn.click(_stop_job, outputs=[run_btn, stop_btn], cancels=[run_click_event])
with gr.Tab("Library"):
gr.Markdown("> **Tip:** Click any filename in the list below to download it to your computer.")
gallery = gr.Files(label="Clips in done_split/", value=_list_clips())
refresh = gr.Button("Refresh")
with gr.Row():
to_delete = gr.Textbox(label="Path to delete")
delete_btn = gr.Button("Delete", variant="stop")
delete_status = gr.Textbox(label="Status", interactive=False)
refresh.click(lambda: _list_clips(), outputs=gallery)
delete_btn.click(_delete_clip, inputs=to_delete, outputs=[gallery, delete_status])
with gr.Tab("Settings"):
gr.Markdown("Edit `config.json`. Uploader fields are still consumed by the CLI uploader.")
settings_box = gr.Code(value=json.dumps(cfg.model_dump(), indent=4), language="json", label="config.json")
save_btn = gr.Button("Save")
save_status = gr.Textbox(label="Status", interactive=False)
def _save(text: str) -> str:
try:
new_cfg = Config.model_validate_json(text)
except Exception as e:
return f"Invalid: {e}"
new_cfg.save()
return "Saved."
save_btn.click(_save, inputs=settings_box, outputs=save_status)
return demo
if __name__ == "__main__":
ui = build_ui()
ui.queue().launch(server_name="0.0.0.0", server_port=7860)
Binary file not shown.
Binary file not shown.
+722
View File
@@ -0,0 +1,722 @@
absofuckinglutely
anal
arse
arse-hole
arse-holes
arsehole
arseholes
arses
ass-hat
ass-hole
ass-holes
ass-pirate
assbag
assbandit
assbanger
assbite
assclown
asscock
asscracker
asses
assface
assfuck
assfucker
assfuckers
assfucks
assgoblin
asshat
asshats
asshead
assholes
asshole
asshopper
assjacker
asskiss
asskisser
asskissers
asslick
asslicker
asslickers
asslicks
asslover
asslovers
assmonkey
assmunch
assmuncher
assnigger
asspirate
assshit
assshole
asssucker
asswad
asswipe
ass
ballsack
ballsacks
bastards
bastard
batshit
batshits
beshitted
birdshit
birdshits
bitchass
bitches
bitchin
bitching
bitchings
bitchslap
bitchslaps
bitchtits
bitchy
bitch
blow job
blow jobs
blowjob
blowjobs
bogshit
boner
boshits
brotherfucker
bugfuck
bugfucker
bugfuckers
bugfucking
bugfucks
bugshit
bugshits
bullshits
bullshitted
bullshitting
bullshittings
bullshit
bumblefuck
bumblefucks
bumfuck
bumfucks
butt plug
butt-fucker
butt-fuckers
butt-pirate
buttfuck
buttfucka
buttfucker
buttfuckers
buttfucks
carpetmuncher
carpetmunchers
catshit
catshits
chickenshit
chickenshits
clit
clitface
clitfuck
clitoris
clits
clusterfuck
cock
cock sucker
cock suckers
cock-sucker
cock-suckers
cockass
cockbite
cockblock
cockblocker
cockblockers
cockblocks
cockburger
cockface
cockfucker
cockhead
cockjockey
cockknoker
cocklicker
cocklickers
cocklover
cocklovers
cockmaster
cockmongler
cockmongruel
cockmonkey
cockmuncher
cocknose
cocknugget
cocks
cockshit
cocksmith
cocksmoker
cocksuck
cocksucked
cocksucker
cocksuckers
cocksucking
cocksuckings
cocksucks
cocktease
cockteases
coochie
coochy
cooter
cornhole
cornholes
cowshit
cowshits
cum
cumbubble
cumdumpster
cumfest
cumfests
cumguzzler
cumjockey
cumjockeys
cumming
cummings
cumquat
cumquats
cumshot
cumshots
cumslut
cumtart
cunilingus
cunillingus
cunnie
cunnilingus
cunt
cuntface
cuntfuck
cuntfucker
cuntfuckers
cuntfucks
cunthole
cuntlick
cuntlicker
cuntlickers
cuntlicking
cuntlickings
cuntlicks
cuntrag
cunts
cuntslut
cuntsucker
cuntsuckers
dafuck
dammit
damned
damnit
damn
dickbag
dickbeaters
dickbrain
dickbrains
dickface
dickforbrains
dickfuck
dickfucker
dickhead
dickheads
dickhole
dickjuice
dickless
dicklick
dicklicker
dicklickers
dicklicks
dickmilk
dickmonger
dickslap
dicksucker
dicks
dickwad
dickwads
dickweasel
dickweed
dickweeds
dickwod
dick
diddlyfuck
diddlyshit
diddlyshits
dildo
dildos
dipshit
dipshits
dipstick
dipsticks
dogfuck
dogfucked
dogfucker
dogfucks
doggiestyle
doggystyle
donkyshit
donkyshits
doochbag
doodlyfuck
doodlyfucks
douche
douche-fag
douchebag
douchewaffle
dumass
dumb ass
dumbass
dumbbitch
dumbbitches
dumbfuck
dumbfucks
dumbshit
dumshit
dyke
dykes
everfucking
facefucker
facefuckers
fag
fagbag
fagfucker
faggit
faggot
faggotcock
fags
fagtard
fannyfucker
fannyfuckers
fat-ass
fat-assed
fatass
fatassed
fatfuck
fatfucker
fatfuckers
fatfucks
fellatio
feltch
fiddlyfuck
fiddlyfucker
fiddlyfucking
fiddlyfucks
fingerfuck
fingerfucked
fingerfucker
fingerfuckers
fingerfucking
fingerfuckings
fingerfucks
fistfuck
fistfucked
fistfucker
fistfuckers
fistfucking
fistfuckings
fistfucks
flamer
footfuck
footfucker
footfuckers
footfucks
footlicker
footlickers
freakfuck
freakfucks
freakyfucker
freakyfuckers
fucck
fuccks
fuck
fucka
fuckable
fuckables
fuckadelic
fuckah
fuckahs
fuckar
fuckaree
fuckarees
fuckaroo
fuckaroos
fuckaround
fuckarounds
fuckarow
fuckarows
fuckars
fuckas
fuckass
fuckathon
fuckathons
fuckbag
fuckbags
fuckbook
fuckbooks
fuckboy
fuckbrain
fuckbuddies
fuckbuddy
fuckbutt
fuckdollies
fuckdolly
fuckdub
fuckdubs
fucked
fuckedup
fuckedups
fuckem
fucker
fuckers
fuckersucker
fuckery
fuckface
fuckfaces
fuckfest
fuckfests
fuckfinger
fuckfingers
fuckfreak
fuckfreaks
fuckfriend
fuckfriends
fuckhea
fuckhead
fuckheads
fuckher
fuckhers
fuckhole
fuckies
fuckin
fuckina
fuckinas
fucking
fuckingbitch
fuckingbitchs
fuckings
fuckink
fuckinnuts
fuckinright
fuckinrights
fuckins
fuckit
fuckits
fuckknob
fuckknobs
fuckload
fuckloads
fuckme
fuckmehard
fuckmehards
fuckmes
fuckmonkey
fuckmonkeys
fuckmovie
fuckmovies
fucknut
fucknuts
fucknutt
fucko
fuckoff
fuckoffs
fuckos
fuckpig
fuckpigs
fuckpuppet
fuckpuppets
fucks
fucksome
fuckstick
fucksticks
fucktard
fucktards
fuckton
fucktons
fuckum
fuckup
fuckups
fuckwad
fuckwads
fuckwhore
fuckwhores
fuckwit
fuckwits
fuckwitt
fucky
fuckya
fuckyas
fuckybrook
fuckybrooks
fuckyou
fuckyous
fudgepacker
fudgepackers
fuuck
fuucks
gayass
gaybob
gaydo
gayfuck
gayfuckist
gaylord
gaytard
gaywad
getthefuckout
goddamnmuthafucker
goddamn
godshit
godshits
handjob
handjobs
hardon
headfuck
headfucks
henshit
henshits
homo
homodumbshit
honkey
horseshit
horseshits
hotdamns
humping
jackass
jackoff
jackoffs
jigaboo
jizz
joyshit
joyshits
kickass
kick-ass
kitchenshit
kitchenshits
kooch
kootch
kunt
labia
lezzie
mcfagget
mcfucking
Mifuckinlady
mindfuck
mindfucked
mindfucking
mindfuckings
mindfucks
minge
monsterfucker
monsterfuckers
mothafuck
mothafucka
mothafuckah
mothafuckahs
mothafuckas
mothafuckaz
mothafuckazs
mothafucked
mothafucker
mothafuckers
mothafuckin
mothafucking
mothafuckings
mothafuckins
mothafucks
motherfuck
motherfucked
motherfucker
motherfuckers
motherfuckin
motherfucking
motherfuckings
motherfuckins
motherfucks
muff
muffdive
muffdiver
muffdivers
muffdives
muffindiver
muffindivers
munging
muthafuck
muthafucka
muthafuckah
muthafuckahs
muthafuckas
muthafucks
numbfuck
numbfucks
nunfucker
nunfuckers
nut sack
nutsack
ohfuck
oldfuck
oldfucker
oldfuckers
oldfucks
omigod
omygod
overfuckingjoyed
papeshit
papeshits
pecker
peckerhead
penis
penisfucker
penispuffer
pigshit
pigshits
pissflaps
polesmoker
pollock
poon
poonani
poonany
poontang
punanny
pussies
pussy
pussylicking
puto
queef
queer
queerbait
queerhole
ratfuck
ratfucks
ratshit
ratshits
richfuck
richfucks
rimjob
rimjobs
scrote
sex
shita
shitas
shitass
shitasses
shitbag
shitbagger
shitbox
shitboxes
shitbrains
shitbreath
shitbug
shitbugs
shitcan
shitcans
shitcunt
shitdick
shitdicks
shite
shiteater
shiteaters
shited
shites
shitface
shitfaced
shitfaces
shitfire
shitfired
shitfires
shitforbrains
shitfuck
shitfucker
shitfuckers
shitfucks
shitfull
shithead
shitheads
shithole
shithouse
shithouses
shitless
shitlicker
shitlickers
shitload
shitloads
shitmonkey
shitmonkeys
shitmouse
shitmouses
shitpot
shitpots
shits
shitspitter
shitsplat
shitsplats
shitstain
shitstains
shitted
shitter
shitters
shittier
shittiest
shittin
shitting
shittings
shitty
shit
poop
skank
skullfuck
slutbag
smeg
sonofabitch
sonofbitch
sonsofbitches
splooge
spooge
strapon
strapons
stupidfuck
stupidfucker
stupidfuckers
stupidfucks
sweetfucked
sweetfucker
sweetfuckers
porn
porno
tard
testicle
thefuck
thundercunt
tit
titfuck
titfucker
titfuckers
titfuckin
titfucks
tits
tittie
titties
titty
tittyfuck
tittys
twat
twatlips
twats
twatwaffle
unclefucker
unfuck
unfucked
va-j-j
vag
vagina
vjayjay
wakethefuckup
wank
wanker
wankers
wanking
wankings
wanks
whafuck
whorebag
whoreface
whorefucker
whorefuckers
whore
harlot
wormshit
wormshits
+12
View File
@@ -0,0 +1,12 @@
_______ __ __ __ __
/ \ / | / | / | / |
$$$$$$$ | ______ ______ $$/ _______ ______ ______ _$$ |_ $$/ _______ ______ _$$ |_ ______ ______
$$ |__$$ | / \ / \ / |/ \ / \ / \ / $$ | / |/ \ / \ / $$ | / \ / \
$$ $$< /$$$$$$ |$$$$$$ |$$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |$$$$$$/ $$ |$$$$$$$ | $$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ |
$$$$$$$ |$$ | $$/ / $$ |$$ |$$ | $$ |$$ | $$/ $$ | $$ | $$ | __ $$ |$$ | $$ | / $$ | $$ | __ $$ | $$ |$$ | $$/
$$ |__$$ |$$ | /$$$$$$$ |$$ |$$ | $$ |$$ | $$ \__$$ | $$ |/ |$$ |$$ | $$ |/$$$$$$$ | $$ |/ |$$ \__$$ |$$ |
$$ $$/ $$ | $$ $$ |$$ |$$ | $$ |$$ | $$ $$/ $$ $$/ $$ |$$ | $$ |$$ $$ | $$ $$/ $$ $$/ $$ |
$$$$$$$/ $$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$/ $$$$/ $$/ $$/ $$/ $$$$$$$/ $$$$/ $$$$$$/ $$/
+171
View File
@@ -0,0 +1,171 @@
"""Thin wrappers around ffmpeg / ffprobe.
Replaces the moviepy + ImageMagick + cleanvid stack with direct FFmpeg calls.
Each helper builds a command list and runs it; callers handle orchestration.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from typing import Iterable
_current_proc: subprocess.Popen | None = None
_cancelled: bool = False
def cancel() -> None:
"""Kill the ffmpeg subprocess currently running in _run(), if any."""
global _current_proc, _cancelled
if _current_proc is not None:
_cancelled = True
_current_proc.kill()
def _run(cmd: list[str], quiet: bool = False) -> None:
global _current_proc, _cancelled
_cancelled = False
if not quiet:
print("[ffmpeg] " + " ".join(cmd))
# Use PIPE so subprocess never calls fileno() on sys.stdout/stderr —
# Gradio replaces them with StringIO wrappers that don't have real fds.
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_current_proc = proc
out, err = proc.communicate()
_current_proc = None
if out:
sys.stdout.write(out.decode(errors="replace"))
if err:
sys.stderr.write(err.decode(errors="replace"))
if proc.returncode != 0 and not _cancelled:
raise subprocess.CalledProcessError(proc.returncode, cmd)
def probe_duration(path: str) -> float:
out = subprocess.check_output([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "json", path,
])
return float(json.loads(out)["format"]["duration"])
def probe_dimensions(path: str) -> tuple[int, int]:
out = subprocess.check_output([
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json", path,
])
s = json.loads(out)["streams"][0]
return int(s["width"]), int(s["height"])
def extract_audio_wav(input_path: str, output_wav: str, sample_rate: int = 16000) -> None:
"""Extract mono PCM wav. Vosk + Whisper both accept wav directly."""
_run([
"ffmpeg", "-y", "-loglevel", "error",
"-i", input_path,
"-vn", "-ac", "1", "-ar", str(sample_rate),
"-f", "wav", output_wav,
])
def cut_crop_scale_burn(
input_path: str,
output_path: str,
start: float,
end: float,
target_w: int,
target_h: int,
ass_path: str | None,
blur_letterbox: bool,
mute_ranges: Iterable[tuple[float, float]] = (),
fonts_dir: str | None = None,
) -> None:
"""One-pass cut + format + (optional) blur + (optional) subtitle burn + (optional) mute.
`start`/`end` are absolute seconds in the source.
`mute_ranges` are absolute seconds too — converted to chunk-relative below.
"""
src_w, src_h = probe_dimensions(input_path)
duration = end - start
if blur_letterbox:
# Background: blur+scale to target. Foreground: scaled source centered.
fg_h = target_h
fg_w = int(round(src_w * (fg_h / src_h)))
if fg_w > target_w:
fg_w = target_w
fg_h = int(round(src_h * (fg_w / src_w)))
vf = (
f"[0:v]split=2[bg][fg];"
f"[bg]scale={target_w}:{target_h}:force_original_aspect_ratio=increase,"
f"crop={target_w}:{target_h},gblur=sigma=20[bgb];"
f"[fg]scale={fg_w}:{fg_h}[fgs];"
f"[bgb][fgs]overlay=(W-w)/2:(H-h)/2[v]"
)
else:
# 9:16 center crop, then scale.
target_ar = target_w / target_h
src_ar = src_w / src_h
if src_ar > target_ar:
crop_h = src_h
crop_w = int(round(src_h * target_ar))
else:
crop_w = src_w
crop_h = int(round(src_w / target_ar))
vf = (
f"[0:v]crop={crop_w}:{crop_h}:(in_w-{crop_w})/2:(in_h-{crop_h})/2,"
f"scale={target_w}:{target_h}[v]"
)
if ass_path:
ass_for_filter = _escape_ass_path(ass_path)
ass_arg = ass_for_filter
if fonts_dir:
ass_arg += f":fontsdir={_escape_ass_path(fonts_dir)}"
vf = vf.replace("[v]", f"[vbase];[vbase]ass={ass_arg}[v]")
# Audio mute filter — convert absolute ranges to chunk-relative.
af_parts = []
for ms_start, ms_end in mute_ranges:
rel_start = max(0.0, ms_start - start)
rel_end = max(0.0, ms_end - start)
if rel_end <= 0 or rel_start >= duration:
continue
rel_end = min(rel_end, duration)
af_parts.append(
f"volume=enable='between(t,{rel_start:.3f},{rel_end:.3f})':volume=0"
)
af = ",".join(af_parts) if af_parts else None
cmd = [
"ffmpeg", "-y",
"-ss", f"{start:.3f}", "-to", f"{end:.3f}",
"-i", input_path,
"-filter_complex", vf,
"-map", "[v]",
"-map", "0:a?",
]
if af:
cmd += ["-af", af]
cmd += [
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart",
output_path,
]
_run(cmd)
def _escape_ass_path(path: str) -> str:
"""ffmpeg's filtergraph parser needs Windows backslashes and `:` (drive letter) escaped."""
p = path.replace("\\", "/")
# Escape drive-letter colon for filter syntax: C:/foo -> C\:/foo
if len(p) >= 2 and p[1] == ":":
p = p[0] + "\\:" + p[2:]
return p
@@ -0,0 +1,92 @@
"""A Python library to check for (and clean) profanity in strings.
Forked from: https://github.com/ben174/profanity
All credit goes to them
I just edited line 70 so that if we have "pass" it doesnt become "p@@@"
so now it only filters whole words
"""
import os
import random
import re
lines = None
words = None
_censor_chars = '@#$%!'
_censor_pool = []
_ROOT = os.path.abspath(os.path.dirname(__file__))
def get_data(path):
return os.path.join(_ROOT, 'data', path)
def get_words():
if not words:
load_words()
return words
def get_censor_char():
"""Plucks a letter out of the censor_pool. If the censor_pool is empty,
replenishes it. This is done to ensure all censor chars are used before
grabbing more (avoids ugly duplicates).
"""
global _censor_pool
if not _censor_pool:
# censor pool is empty. fill it back up.
_censor_pool = list(_censor_chars)
return _censor_pool.pop(random.randrange(len(_censor_pool)))
def set_censor_characters(censor_chars):
"""Sets the pool of censor characters. Input should be a single string
containing all the censor charcters you'd like to use.
Example: "@#$%^"
"""
global _censor_chars
_censor_chars = censor_chars
def contains_profanity(input_text):
"""Checks the input_text for any profanity and returns True if it does.
Otherwise, returns False.
"""
return input_text != censor(input_text)
def censor(input_text, filterAnyOccurance:bool = False):
""" Returns the input string with profanity replaced with a random string
of characters plucked from the censor_characters pool.
"""
ret = input_text
words = get_words()
for word in words:
if filterAnyOccurance:
curse_word = re.compile(re.escape(word), re.IGNORECASE)
#old implementation, filters any occurance of the word even if its part of another word
curse_word = re.compile(r'\b' + re.escape(word) + r'\b', re.IGNORECASE)
cen = "".join(get_censor_char() for i in list(word))
ret = curse_word.sub(cen, ret)
return ret
def load_words(wordlist=None):
""" Loads and caches the profanity word list. Input file (if provided)
should be a flat text file with one profanity entry per line.
"""
global words
if not wordlist:
# no wordlist was provided, load the wordlist from the local store
filename = get_data('wordlist.txt')
f = open(filename)
wordlist = f.readlines()
wordlist = [w.strip() for w in wordlist if w]
words = wordlist
+103
View File
@@ -0,0 +1,103 @@
"""Subtitle helpers: SRT -> styled ASS, plus profanity mute-range extraction.
Replaces moviepy's TextClip/SubtitlesClip and the cleanvid subprocess.
"""
from __future__ import annotations
import os
import re
from typing import Iterable
import pysrt
import pysubs2
def srt_to_ass(
srt_path: str,
ass_path: str,
font_name: str = "Bangers",
font_size: int = 90,
primary_color: str = "&H00FFFFFF", # white (AABBGGRR)
outline_color: str = "&H00000000", # black
outline: int = 3,
shadow: int = 0,
alignment: int = 8, # top-center; 2 = bottom-center, 5 = middle-center
margin_v: int = 480, # vertical offset; tuned for 1920px tall video
play_res_x: int = 1080,
play_res_y: int = 1920,
) -> None:
"""Convert SRT to ASS with a styled `Default` style.
Designed for vertical 1080x1920 output. Caller still needs the .ttf font
available to libass — either installed system-wide or referenced via
`fontsdir=...` in the ffmpeg `ass=` filter.
"""
subs = pysubs2.load(srt_path, encoding="utf-8")
subs.info["PlayResX"] = str(play_res_x)
subs.info["PlayResY"] = str(play_res_y)
subs.info["ScaledBorderAndShadow"] = "yes"
style = subs.styles["Default"]
style.fontname = font_name
style.fontsize = font_size
style.primarycolor = pysubs2.Color(255, 255, 255, 0)
style.outlinecolor = pysubs2.Color(0, 0, 0, 0)
style.bold = True
style.outline = outline
style.shadow = shadow
style.alignment = alignment
style.marginv = margin_v
subs.save(ass_path, format_="ass")
def load_swears(swears_path: str) -> list[str]:
with open(swears_path, "r", encoding="utf-8") as f:
return [w.strip().lower() for w in f if w.strip()]
def mute_ranges_from_srt(
srt_path: str,
swears: Iterable[str],
pad_seconds: float = 0.05,
) -> list[tuple[float, float]]:
"""Return (start, end) seconds for every subtitle line containing a swear.
Uses word-boundary matching so 'ass' doesn't match 'class'.
Pads each range by `pad_seconds` on both sides to account for transcription drift.
"""
swear_list = [s for s in swears if s]
if not swear_list:
return []
pattern = re.compile(
r"\b(" + "|".join(re.escape(w) for w in swear_list) + r")\b",
re.IGNORECASE,
)
ranges: list[tuple[float, float]] = []
for sub in pysrt.open(srt_path, encoding="utf-8"):
if not pattern.search(sub.text):
continue
start_s = _pysrt_to_seconds(sub.start) - pad_seconds
end_s = _pysrt_to_seconds(sub.end) + pad_seconds
ranges.append((max(0.0, start_s), end_s))
return _merge_overlapping(ranges)
def _pysrt_to_seconds(t) -> float:
return t.hours * 3600 + t.minutes * 60 + t.seconds + t.milliseconds / 1000.0
def _merge_overlapping(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]:
if not ranges:
return []
ranges = sorted(ranges)
merged = [ranges[0]]
for s, e in ranges[1:]:
ps, pe = merged[-1]
if s <= pe:
merged[-1] = (ps, max(pe, e))
else:
merged.append((s, e))
return merged
+339
View File
@@ -0,0 +1,339 @@
from io import BytesIO
import re
import subprocess
import requests
from tqdm import tqdm
import torch
from vosk import Model, KaldiRecognizer, SetLogLevel
import os
from huggingface_hub import snapshot_download
from termcolor import colored
from . import profanity
import whisper
from whisper.utils import get_writer
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
import zipfile
class Transcribe:
#@param output_path - path to the output srt file
def __init__(self, audioPath, output_path, name, filterProfanityInSubtitles: bool, voskModelDir, tinyLlamaDir):
self.audioPath = audioPath #path for where the mp3 file is
self.output_path = output_path #subtitles save path
self.name = name #name of the video (so we can name the srt correctly)
self.filterProfanityInSubtitles = filterProfanityInSubtitles #if true, then we filter out profanity in the subtitles
self.tinyLlamaDir = tinyLlamaDir
self.voskModelDir = voskModelDir
def transcribeVideoVosk(self) -> None:
#transcribe the video to srt file
print(colored("Transcribing video...", "green"))
unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt")
print(colored(f"File name: {unFilteredSrtFileName}", "yellow"))
#check if the file already exists, if it does then return
if os.path.exists(unFilteredSrtFileName):
print(colored("SRT file already exists. Skipping...", "red"))
return
currentWorkingDirectory = os.getcwd()
#vosk model
#------------------------------------------------------------------------------------
#if model does not exist, download it
#RUN apt-get install -y unzip \
#if voskmodel directory is empty string, not provided used working diretory
voskDir = ''
if not self.voskModelDir:
voskDir= currentWorkingDirectory
else:
voskDir = self.voskModelDir
modelPath = os.path.join(voskDir, "vosk-model-en-us-0.42-gigaspeech")
url = "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"
zip_filename = "vosk-model-en-us-0.42-gigaspeech.zip"
if not os.path.exists(modelPath):
print(f"Downloading Vosk model from {url}...")
# Streamed download with a tqdm progress bar so users can see progress on slow links.
with requests.get(url, stream=True, timeout=60) as response:
response.raise_for_status()
total = int(response.headers.get("content-length", 0))
with open(zip_filename, "wb") as f, tqdm(
total=total, unit="B", unit_scale=True, desc="Vosk model"
) as bar:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
f.write(chunk)
bar.update(len(chunk))
print("Extracting...")
with zipfile.ZipFile(zip_filename, "r") as zip_ref:
zip_ref.extractall(voskDir)
os.remove(zip_filename)
else:
print(f"Model already exists at {modelPath}.")
SAMPLE_RATE = 16000
SetLogLevel(-1)
print("Vosk model path: ", modelPath)
subtitles = ''
model = Model(model_path=modelPath)
rec = KaldiRecognizer(model, SAMPLE_RATE)
rec.SetWords(True)
with subprocess.Popen(["ffmpeg", "-loglevel", "quiet", "-i",
self.audioPath,
"-ar", str(SAMPLE_RATE) , "-ac", "1", "-f", "s16le", "-"],
stdout=subprocess.PIPE).stdout as stream:
data = stream.read()
stream = BytesIO(data) #have to copy the stream into a buffer in memory
subtitles = rec.SrtResult(stream, words_per_line=1) #srt save has to be first read from stream or the timestamp will be wrong
print(subtitles)
result : str = extract_words_from_srt(None, subtitles)
print(result)
stream.close()
f= open(unFilteredSrtFileName, "w")
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {unFilteredSrtFileName}", "yellow"))
transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt")
with open(transcriptionPath, "w") as f:
f.write(result)
f.close()
#------------------------------------------------------------------------------------
#Filter out profanity in the subtitles, then save
#if profanity filter off do not filter
cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt")
f = open(cleanSrtFileName, "w")
if self.filterProfanityInSubtitles:
subtitles = self.filterProfanity(subtitles)
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow"))
#------------------------------------------------------------------------------------
#get the Ai summary
summary :str = self.llmSummarize(result)
print(colored(f"Summary before: {summary}", "yellow"))
summary = self.filterProfanity(summary) #filter out profanity
summary = summary.replace("\"", "") #regex any " within summary
print(colored(f"Summary after all filters: {summary}", "green"))
summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt")
f = open(summary_save_path, "w")
f.write(summary)
f.close()
print(colored(f"Summary saved to {summary_save_path}", "yellow"))
print(colored("Transcription complete.", "green"))
def transcribeVideoWhisper(self) -> None:
#transcribe the video to srt file
print(colored("Transcribing video...", "green"))
unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt")
print(colored(f"File name: {unFilteredSrtFileName}", "yellow"))
if os.path.exists(unFilteredSrtFileName):
print(colored("SRT file already exists. Skipping...", "red"))
return
currentWorkingDirectory = os.getcwd()
#Whisper model
#------------------------------------------------------------------------------------
subtitles = ''
model_name = "large-v3"
device = "cuda" if torch.cuda.is_available() else "cpu"
model = whisper.load_model(model_name).to(device)
filename =self.name + "_notClean.srt"
language = "en" if model_name.endswith(".en") else None
subtitles : dict = model.transcribe(self.audioPath, language=language, temperature=0.0, word_timestamps=True)
transcript = str(subtitles["text"])
print(subtitles)
print(colored(f"Transcript: {transcript}", "yellow"))
transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt")
with open(transcriptionPath, "w") as f:
f.write(transcript)
f.close()
writer = get_writer("srt", output_dir=self.output_path) #returns a writer object
writer(subtitles, filename, max_words_per_line=1 ) #writes the result to the file, specify keyword argument (kwargs) to set max words per line
del model
torch.cuda.empty_cache()
#------------------------------------------------------------------------------------
with open(unFilteredSrtFileName, "r") as f:
subtitles = f.read()
f.close()
print(subtitles)
#filter out profanity, then save
#we use this srt for the final video, and the unfiltered srt for detecting when to mute profanity
cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt")
f = open(cleanSrtFileName, "w")
if self.filterProfanityInSubtitles:
subtitles = self.filterProfanity(subtitles)
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow"))
#------------------------------------------------------------------------------------
#Get the Ai summary
summary :str = self.llmSummarize(transcript)
print(colored(f"Summary before: {summary}", "yellow"))
#filter out profanity in the summary
summary = self.filterProfanity(summary)
summary = summary.replace("\"", "") #regex any " within summary
print(colored(f"Summary after all filters: {summary}", "green"))
summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt")
f = open(summary_save_path, "w")
f.write(summary)
f.close()
print(colored(f"Summary saved to {summary_save_path}", "yellow"))
print(colored("Transcription complete.", "green"))
def filterProfanity (self, input: str) -> str :
print(colored("Filtering profanity...", "green"))
profanity.set_censor_characters("*#@!")
#Set swearWords to the contents of the file
swearsFileLocation = os.path.join(os.getcwd(), "assets", "swears.txt")
print(colored(f"swearsFileLocation: {swearsFileLocation}", "yellow"))
f = open(swearsFileLocation, "r")
swearWords = f.readlines()
swearWords = [w.strip() for w in swearWords if w]
f.close()
profanity.load_words(swearWords)
#print(swearWords)
return profanity.censor(input)
def llmSummarize(self, input:str) -> None:
print(colored("Summarizing text using Tinyllama...", "green"))
llamaDir = ''
if not self.tinyLlamaDir: #if tinyLlamaDir is empty string, use current working directory
llamaDir= os.getcwd()
else:
llamaDir = self.tinyLlamaDir
localModelPath = os.path.join(llamaDir, "models--TinyLlama--TinyLlama-1.1B-Chat-v1.0")
model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
if not os.path.exists(localModelPath):
print(f"Downloading {model_name} -> {localModelPath} (resumable)...")
# snapshot_download is resumable and shows progress, unlike from_pretrained's silent download.
snapshot_download(
repo_id=model_name,
local_dir=localModelPath,
local_dir_use_symlinks=False,
)
else:
print(f"Model already exists at {localModelPath}.")
pipe = pipeline("text-generation", model=localModelPath, torch_dtype=torch.bfloat16, device_map="auto")
input = input + "\n Summarize the above transcript into a catchy title for youtube. Do not include any additional text before or after the title. Make the title one sentence long."
# We use the tokenizer's chat template to format each message - see https://huggingface.co/docs/transformers/main/en/chat_templating
messages = [
{
"role": "system",
"content": "You always respond with one catchy youtube title, no longer than one sentence. Do not include any additional text before or after the title.",
},
{"role": "user", "content": input},
]
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
outputs = pipe(prompt, max_new_tokens=256, do_sample=True, temperature=0.7, top_k=50, top_p=0.95)
generatedText = outputs[0]["generated_text"]
print(generatedText)
#keep anything after <|assistant|> in the output
generatedText = generatedText.split("<|assistant|>")[1]
print(colored(f"{generatedText}", "yellow"))
del pipe
torch.cuda.empty_cache()
#filter out any whitespace before
generatedText = generatedText.strip()
#only include the /line in string
generatedText = generatedText.split("\n")[0]
#filter out any mention of catchy youube title that chatbot sometimes includes
generatedText = re.sub(r'(?i)Catchy YouTube title', '', generatedText)
generatedText = re.sub(r'(?i)YouTube Title', '', generatedText)
generatedText = re.sub(r'(?i)Chatbot', '', generatedText)
generatedText = re.sub(r'(?i)one sentence', '', generatedText)
print(colored(f"Ai title after filter out catchy youtube title: \n{generatedText}", "green"))
#filter any whitespace after
generatedText = generatedText.strip()
print(colored(f"Ai title after filter whitespace & only include first line: \n{generatedText}", "green"))
#truncate the text to 100 characters (youtbe limits titles to 100 chars)
generatedText = generatedText[:100]
print(colored(f" \n Ai title after trunkate to 100 chars: \n {generatedText} \n", "yellow"))
return generatedText
def extract_words_from_srt(srt_file = None, input:str = None):
content =''
if srt_file:
with open(srt_file, 'r', encoding='utf-8') as file:
content = file.read()
if input:
content = input
# Remove numbers (sequence numbers), timestamps, and extra lines
cleaned_text = re.sub(r'\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}', '', content)
# Remove any remaining empty lines or numbers
cleaned_text = re.sub(r'\n\d+\n', '\n', cleaned_text)
# Remove any leftover newlines that appear more than twice in a row
cleaned_text = re.sub(r'\n+', '\n', cleaned_text).strip()
#merge all lines into one line
cleaned_text = cleaned_text.replace("\n", " ")
return cleaned_text
@@ -0,0 +1,153 @@
"""Video editor — FFmpeg-only.
Replaces the old moviepy/ImageMagick/cleanvid stack. For each chunk we do:
1. Extract wav for transcription (via ffmpeg).
2. Run Vosk or Whisper -> {name}_notClean.srt + {name}.srt + {name}_summary.txt.
3. Convert {name}.srt -> {name}.ass with our subtitle style.
4. Build mute ranges from {name}_notClean.srt against swears.txt.
5. Single ffmpeg pass: cut + crop/scale (or blur letterbox) + burn ASS + mute.
"""
from __future__ import annotations
import os
from termcolor import colored
from . import ffmpeg_ops
from . import subtitles as subs_mod
from .transcribe import Transcribe
TARGET_W = 1080
TARGET_H = 1920
class VideoEditor:
def __init__(
self,
input_path: str,
output_folder: str,
chunk_duration: int,
name: str,
useWhisper: bool,
filterProfanityInSubtitles: bool,
voskModelDir: str,
tinyLlamaDir: str,
subtitleFontSize: int = 100,
subtitleMarginV: int = 400,
):
self.input_path = input_path
self.output_folder = output_folder
self.chunk_duration = chunk_duration
self.name = name
self.useWhisper = useWhisper
self.filterProfanityInSubtitles = filterProfanityInSubtitles
self.voskModelDir = voskModelDir
self.tinyLlamaDir = tinyLlamaDir
self.subtitleFontSize = subtitleFontSize
self.subtitleMarginV = subtitleMarginV
self.abort_flag = False
def split_video_into_chunks(self):
self._split(blur_letterbox=False)
def split_video_into_chunks_blur(self):
self._split(blur_letterbox=True)
def _split(self, blur_letterbox: bool) -> None:
print(colored(f"\n Splitting video: {self.input_path}", "green"))
duration = int(ffmpeg_ops.probe_duration(self.input_path))
os.makedirs(self.output_folder, exist_ok=True)
project_root = os.getcwd()
subtitles_path = os.path.join(project_root, "subtitles")
os.makedirs(subtitles_path, exist_ok=True)
assets_dir = os.path.join(project_root, "assets")
fonts_dir = os.path.join(assets_dir, "fonts")
font_path = os.path.join(fonts_dir, "Bangers.ttf")
swears_path = os.path.join(assets_dir, "swears.txt")
swears = subs_mod.load_swears(swears_path) if os.path.exists(swears_path) else []
for start in range(0, duration, self.chunk_duration):
if self.abort_flag:
print(colored("\n[!] Editing aborted by user.", "red"))
break
end = min(start + self.chunk_duration, duration)
num = int(end / 60)
chunk_name = f"{self.name}_{num}"
output_video = os.path.join(self.output_folder, f"{chunk_name}.mp4")
if os.path.exists(output_video):
print(colored(f"File {chunk_name} already exists. Skipping...", "yellow"))
continue
wav_path = os.path.join(subtitles_path, f"{chunk_name}.wav")
chunk_for_transcription = os.path.join(subtitles_path, f"{chunk_name}_src.mp4")
# 1. Cut a temp source clip + extract wav for transcription.
# (We re-encode the final clip in step 5; this temp is just for audio.)
ffmpeg_ops._run([
"ffmpeg", "-y", "-loglevel", "error",
"-ss", str(start), "-to", str(end),
"-i", self.input_path,
"-c", "copy",
chunk_for_transcription,
])
ffmpeg_ops.extract_audio_wav(chunk_for_transcription, wav_path)
# 2. Transcribe.
print(colored(f"Transcribing: {wav_path}", "yellow"))
transcribe = Transcribe(
wav_path,
subtitles_path,
name=chunk_name,
filterProfanityInSubtitles=self.filterProfanityInSubtitles,
voskModelDir=self.voskModelDir,
tinyLlamaDir=self.tinyLlamaDir,
)
if self.useWhisper:
transcribe.transcribeVideoWhisper()
else:
transcribe.transcribeVideoVosk()
os.remove(wav_path)
os.remove(chunk_for_transcription)
srt_clean = os.path.join(subtitles_path, f"{chunk_name}.srt")
srt_unfiltered = os.path.join(subtitles_path, f"{chunk_name}_notClean.srt")
ass_path = os.path.join(subtitles_path, f"{chunk_name}.ass")
# 3. Build styled ASS for burn-in.
subs_mod.srt_to_ass(
srt_clean,
ass_path,
font_name="Bangers",
font_size=self.subtitleFontSize,
margin_v=self.subtitleMarginV,
)
# 4. Compute mute ranges from the unfiltered SRT (absolute seconds in source = chunk-relative + start).
chunk_relative_ranges = subs_mod.mute_ranges_from_srt(srt_unfiltered, swears)
absolute_ranges = [(s + start, e + start) for s, e in chunk_relative_ranges]
if absolute_ranges:
print(colored(f"Muting {len(absolute_ranges)} profanity range(s)", "yellow"))
# 5. Final single-pass render.
print(colored(f"Rendering: {output_video}", "green"))
ffmpeg_ops.cut_crop_scale_burn(
input_path=self.input_path,
output_path=output_video,
start=float(start),
end=float(end),
target_w=TARGET_W,
target_h=TARGET_H,
ass_path=ass_path,
blur_letterbox=blur_letterbox,
mute_ranges=absolute_ranges,
fonts_dir=fonts_dir,
)
print(colored(f"Saved: {output_video}", "green"))
print(colored("-------------------------------------------", "green"))
+30
View File
@@ -0,0 +1,30 @@
{
"tags": [
"chuckle Sandwich",
"jschlatt",
"ted nivison",
"slimecicle",
"gaming",
"comedy",
"tucker",
"jambo"
],
"description": "#shorts",
"howManyUploads": 1,
"howManyHoursBetweenSchedule": 0,
"howManyMinsBetweenUpload": 5,
"howManyHoursLongToSleep": 23,
"sleepXMinsBeforeStartingUploader": 0,
"chunkDuration": 58,
"uploadToYoutube": true,
"uploadToInstagram": true,
"uploadToTiktok": false,
"blurTopBottomOfClip": true,
"useWhisperForTranscription": false,
"filterProfanityInSubtitles": false,
"subtitleFontSize": 100,
"subtitleMarginV": 480,
"firefoxHeadless": true,
"voskModelDir": "models",
"tinyLlamaDir": "models"
}
+59
View File
@@ -0,0 +1,59 @@
"""Typed config for brainrotinator.
Replaces the old `global` block in main.py and the dict access scattered
across the codebase. Loaded from `config.json` at the project root.
"""
from __future__ import annotations
import json
from pathlib import Path
from pydantic import BaseModel, Field
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
class Config(BaseModel):
# Upload metadata
tags: list[str] = Field(default_factory=list)
description: str = "#shorts"
# Upload scheduling
howManyUploads: int = 1
howManyHoursBetweenSchedule: int = 0
howManyMinsBetweenUpload: int = 5
howManyHoursLongToSleep: int = 23
sleepXMinsBeforeStartingUploader: int = 0
# Editing
chunkDuration: int = 58
blurTopBottomOfClip: bool = True
useWhisperForTranscription: bool = False
filterProfanityInSubtitles: bool = False
# Subtitles
subtitleFontSize: int = 100
subtitleMarginV: int = 480
# Upload targets
uploadToYoutube: bool = True
uploadToInstagram: bool = True
uploadToTiktok: bool = False
# Selenium
firefoxHeadless: bool = True
# Model dirs (empty = use cwd)
voskModelDir: str = "models"
tinyLlamaDir: str = "models"
@classmethod
def load(cls, path: Path | str = CONFIG_PATH) -> "Config":
with open(path) as f:
return cls.model_validate(json.load(f))
def save(self, path: Path | str = CONFIG_PATH) -> None:
with open(path, "w") as f:
json.dump(self.model_dump(), f, indent=4)
+16
View File
@@ -0,0 +1,16 @@
version: '3.8'
services:
brainrotinator:
build: .
image: brainrotinator:latest
ports:
- "7860:7860"
volumes:
# Video input and output
- ./to_split:/app/to_split
- ./done_split:/app/done_split
# AI Models (so they aren't re-downloaded on container restart)
- ./models:/app/models
- ./models/whisper:/root/.cache/whisper
command: python app.py
@@ -0,0 +1,30 @@
import os
import subprocess
"""
Function to combine the audio and video of a video using ffmpeg-python
@param videoPath: the path to the video
@param audioPath: the path to the audio
@param outputPath: the path to save the output to
@param outputName: the name of the output file
"""
def CombineAudioVideo(videoPath, audioPath, outputPath, outputName):
try:
outputFilePath = os.path.join(outputPath, outputName)
subprocess_command = [
'ffmpeg',
'-y', # Overwrite output file without asking
'-i', videoPath, # Input video
'-i', audioPath, # Input audio
'-c', 'copy', # Codec copy (no re-encoding)
outputFilePath # Output path
]
subprocess.run(subprocess_command, check=True)
print(f"Combined audio and video saved to: {outputFilePath}")
except Exception as e:
print(f"Error combining audio and video: {e}")
+63
View File
@@ -0,0 +1,63 @@
import sys
import os
import subprocess
import re
from termcolor import colored
def _sanitize_title(title: str) -> str:
return re.sub(r'[^\w\-]', '_', title)[:100]
def download_vid(url: str, path: str) -> None:
os.makedirs(path, exist_ok=True)
# Probe the video title first so we can predict the output filename.
title_result = subprocess.run(
["yt-dlp", "--print", "title", "--no-playlist", url],
capture_output=True, text=True, check=True,
)
raw_title = title_result.stdout.strip()
safe_title = _sanitize_title(raw_title)
output_path = os.path.join(path, safe_title + ".mp4")
print(colored(f"Downloading: {raw_title}", "green"))
print(colored(f"Output: {output_path}", "blue"))
proc = subprocess.Popen(
[
"yt-dlp",
"--no-playlist",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best",
"--merge-output-format", "mp4",
"-o", output_path,
url,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = proc.communicate()
if out:
sys.stdout.write(out.decode(errors="replace"))
if err:
sys.stderr.write(err.decode(errors="replace"))
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, ["yt-dlp"])
print(colored(f"Saved to: {output_path}", "green"))
return output_path
def main():
if len(sys.argv) < 3:
print("Usage: python downloadVid.py <url> <path>")
sys.exit(1)
url = sys.argv[1]
path = sys.argv[2]
print(f"URL: {url}\nPath: {path}")
download_vid(url, path)
if __name__ == "__main__":
main()
+426
View File
@@ -0,0 +1,426 @@
from datetime import datetime, timedelta
import os
import time
import argparse
import random
import shutil
import json
from brainrotinator.video_editor import VideoEditor
from uploaders.uploader_selenium import upload_video_youtube, upload_video_Instagram
from uploaders.upload_tiktok import upload_video_Tiktok
from downloader.downloadVid import download_vid
from config import Config
class Color:
PURPLE = '\033[95m'
CYAN = '\033[96m'
DARKCYAN = '\033[36m'
BLUE = '\033[94m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
END = '\033[0m'
"""
Function to upload the chunks to YouTube
Uploads videos from the done_split folder, uploads until reaches howManyUploads, then sleeps for 23 hours
time between uploads = howManyMinsBetweenUpload + random wait of 0-5 mins
@param output_directory - path to the output directory(folder with the clips of our videos)
@param howManyUploads - how many times to upload before sleeping for 24 hours
@param howManyHoursBetweenUpload - how many hours delay between each upload scheduled to be uploaded
@param howManyMinsBetweenUpload - how many minutes to wait between each upload
@param howManyHoursLongToSleep - how many hours to sleep after uploading howManyUploads times
@param tags - list of tags to add to the video
@param description_video - description of the video
"""
def uploadVideos(doneVideosDirectory : str,
cfg: Config,
) -> None:
howManyUploads = cfg.howManyUploads
howManyHoursBetweenSchedule = cfg.howManyHoursBetweenSchedule
howManyMinsBetweenUpload = cfg.howManyMinsBetweenUpload
howManyHoursLongToSleep = cfg.howManyHoursLongToSleep
tags = cfg.tags
description_video = cfg.description
print(Color.GREEN + "Done Videos directory: " + doneVideosDirectory + Color.END)
print(Color.BLUE + "Video Upload Order order: \n" + str(os.listdir(doneVideosDirectory)) + "\n\n" + Color.END)
i : int = 0
for videoFileName in os.listdir(doneVideosDirectory):
print(Color.BLUE+ "Video file: " + videoFileName + Color.END)
if videoFileName.endswith(".mp4"):
print(Color.GREEN + f"i = {i} \n" + videoFileName + Color.END)
#if we have uploaded howmanyUploads times and is not first upload since starting program, sleep for howManyHoursLongToSleep
if(i % howManyUploads == 0 and i != 0) :
timeToSleepInHours = (howManyHoursLongToSleep*60*60)
print(Color.GREEN + f"Sleeping for {howManyHoursLongToSleep} hrs since uploaded {howManyUploads} vids... \n calculated: {timeToSleepInHours/60/60} hrs " + Color.END)
nextUploadTime = datetime.now() + timedelta(seconds=timeToSleepInHours)
print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END)
#Sleep for howManyHoursLongToSleep hours
time.sleep(timeToSleepInHours)
i = 0 #reset the counter to 0 after sleeping for 24 hours
#if we are not on the first video, sleep for hour since we have uploaded a video
if(i != 0) :
print(Color.GREEN + "Time now: " + str(datetime.now()) + Color.END)
randomSleepSeconds = random.randint(0, 300)
sleepTimeSeconds = (howManyMinsBetweenUpload * 60) + randomSleepSeconds
now = datetime.now()
nextUploadTime = now + timedelta(seconds=sleepTimeSeconds)
print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END)
print(Color.GREEN + "Sleeping for:"+str(sleepTimeSeconds /60) + "minutes" + Color.END)
time.sleep(sleepTimeSeconds) #sleep for 1 hour before uploading the next video
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
if cfg.uploadToYoutube:
print(Color.GREEN+ "Uploading video to YouTube..." + Color.END)
videoFilePath :str = os.path.join(doneVideosDirectory, videoFileName)
print(Color.GREEN+"Uploading video from: \n" + videoFilePath + Color.END)
videoFileNameWithoutMP4ForSummary :str = os.path.splitext(videoFileName)[0]
print(Color.GREEN + "Base name: " + videoFileNameWithoutMP4ForSummary + Color.END)
#if "_filtered" in videoFileNameWithoutMP4: remove the "_filtered" from the name
#ai summary file is named without the "_filtered" so we need to remove it from the name
if "_filtered" in videoFileNameWithoutMP4ForSummary:
videoFileNameWithoutMP4ForSummary = videoFileNameWithoutMP4ForSummary.replace("_filtered", "")
print(Color.GREEN + "Base name after removing '_filtered': " + videoFileNameWithoutMP4ForSummary + Color.END)
project_root :str = os.getcwd()
subtitles_path :str = os.path.join(project_root, "subtitles")
print(Color.YELLOW + "Subtitles path: " + subtitles_path + Color.END)
AISummaryPath: str = os.path.join(subtitles_path, f"{videoFileNameWithoutMP4ForSummary}_summary.txt")
print(Color.YELLOW + "Summary path: " + AISummaryPath + Color.END)
AiTitle :str = ""
try:
with open(AISummaryPath, "r") as f:
AiTitle = f.read()
except FileNotFoundError:
print(Color.RED + "Summary file not found: " + AISummaryPath + Color.END)
AiTitle = "default"
print(Color.YELLOW + "Ai Title: " + AiTitle + Color.END)
print(Color.BLUE+ f" => Scheduling video to be uploaded in {i * howManyHoursBetweenSchedule} hours" + Color.END)
# Calculate the scheduled time based on the number of iterations
now = datetime.now()
random_mins = random.randint(0, 10)
howManyHoursLaterToSchedule = now + timedelta(hours=(i * howManyHoursBetweenSchedule), minutes=random_mins) #hr+rand mins to avoid getting banned
# Format the time in ISO 8601 format
formatted_time = howManyHoursLaterToSchedule.strftime("%m/%d/%Y, %H:%M")
print("Current time:", now)
print(f"{i*howManyHoursBetweenSchedule} hours later:", howManyHoursLaterToSchedule)
print("ISO format:", formatted_time)
#initialize the json object
upload_json = {}
#if we are on the first video, upload immediately or if we are not scheduling the video
if (i == 0 or howManyHoursBetweenSchedule == 0):
print(Color.GREEN + "Uploading immediately..." + Color.END)
upload_json = {
"title": AiTitle,
"description": description_video,
"tags": tags,
}
#if we are not on the first video, schedule the video to be uploaded in future
else :
print(Color.GREEN + "Scheduling video to be uploaded in the future..." + Color.END)
upload_json = {
"title": AiTitle,
"description": description_video,
"tags": tags,
"schedule": formatted_time
}
json_file_path :str = os.path.join(doneVideosDirectory, "upload.json")
with open(json_file_path, "w") as json_file:
json.dump(upload_json, json_file)
print(Color.GREEN + "JSON file path: " + json_file_path + Color.END)
if cfg.uploadToYoutube:
upload_video_youtube(videoFilePath, json_file_path, headless=cfg.firefoxHeadless)
print(Color.GREEN+ "Uploaded video to youtube: " + videoFileName + Color.END)
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
copyOfTags = tags.copy() #copy the tags so we dont modify the original list
for x in range(len(copyOfTags)):
copyOfTags[x] = "#" + copyOfTags[x].replace(" ", "")
AiTitle = AiTitle + " " + " ".join(copyOfTags)
print(Color.GREEN + "Title for TikTok+Instagram: " + AiTitle + Color.END)
upload_json = {
"title": AiTitle,
"description": AiTitle,
}
with open(json_file_path, "w") as json_file:
json.dump(upload_json, json_file)
if cfg.uploadToInstagram:
print(Color.GREEN+ "Uploading video to Instagram..." + Color.END)
upload_video_Instagram(videoFilePath, json_file_path, headless=cfg.firefoxHeadless)
print(Color.GREEN + "Uploaded to Instagram " + Color.END)
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
cookies : str = os.path.join(os.getcwd(), "cookies-tiktok.txt")
if cfg.uploadToTiktok:
print(Color.GREEN + "Uploading to TikTok..." + Color.END)
upload_video_Tiktok(video_path=videoFilePath, description= AiTitle, cookies = cookies)
print(Color.GREEN + "Uploaded to TikTok " + Color.END)
#-----------------------------------------------------------------------------
move_files_to_uploaded(videoFileName, doneVideosDirectory, videoFilePath) #move the uploaded video to the uploaded folder
i += 1 #increment the counter (only if we just uploaded a video, if not then dont increment hour time)
# Move the uploaded video to the "uploaded" directory, so we don't upload it again if the script crashes
def move_files_to_uploaded(videoFileName: str, doneVideoDirectory: str, videoFilePath) -> None:
uploaded_directory = os.path.join(doneVideoDirectory, "uploaded")
print(Color.GREEN + "Uploaded directory: " + uploaded_directory + Color.END)
newVideoFilePath = os.path.join(uploaded_directory, videoFileName)
shutil.move(videoFilePath, newVideoFilePath)
print(Color.GREEN + f"Moved uploaded video to: {newVideoFilePath}" + Color.END)
"""
Function that goes through the to_split folder and splits each video into chunks
If no mp4 files in the folder, prompts user to add mp4 files to the folder, or exit editing
Only splits mp4 files
@param folder_path - the path to the folder with the video
@param output_directory - the path to the output folder
@param chunk_duration - the duration of each chunk in seconds
@param edited_directory - the path to the edited folder
@param gui - boolean to check if the program is running in GUI mode
@param splitOneVideo - boolean to check if the program is splitting one video
"""
def splitVideos(folder_path : str,
output_directory : str,
cfg: Config,
edited_directory: str,
gui: bool = False,
splitOneVideo: bool = False) -> None:
chunk_duration = cfg.chunkDuration
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
if(splitOneVideo):
print(Color.GREEN + "Splitting one video..." + Color.END)
else:
print(Color.GREEN + "Splitting all videos in to_Split folder..." + Color.END)
#for each video in the folder, split the video into 60 second chunks
for video_file in os.listdir(folder_path):
print(Color.BLUE + "Video file: " + video_file + Color.END )
if video_file.endswith(".mp4"):
input_video_path = os.path.join(folder_path, video_file)
print("Input video path: " + input_video_path)
base_name = os.path.splitext(video_file)[0] # Get the base name without .mp4
print(Color.GREEN + "Base name: " + base_name + Color.END)
#initialize the class with (video_path, output_folder, chunk_duration, vname)
video_editor = VideoEditor(input_path=input_video_path,
output_folder=output_directory,
chunk_duration=chunk_duration,
name=base_name,
useWhisper=cfg.useWhisperForTranscription,
filterProfanityInSubtitles=cfg.filterProfanityInSubtitles,
voskModelDir=cfg.voskModelDir,
tinyLlamaDir=cfg.tinyLlamaDir)
if cfg.blurTopBottomOfClip:
print (Color.RED + "Split the video into chunks using blur" + Color.END)
video_editor.split_video_into_chunks_blur()
else :
print (Color.RED + "Split the video into chunks without blur" + Color.END)
video_editor.split_video_into_chunks()
print (Color.GREEN + "Done editing the video into chunks" + Color.END)
# Move the original video to the "edited" directory
new_file_path = os.path.join(edited_directory, video_file)
print("Input video path: " + input_video_path)
print("New file path: " + new_file_path)
while(True):
try:
shutil.move(input_video_path, new_file_path)
print(Color.GREEN + f"Moved original video to: {new_file_path}" + Color.END)
break
except Exception as e:
print(Color.RED + "Error moving file: " + str(e) + Color.END)
time.sleep(5)
#if we are only splitting one video, break out of the loop
if splitOneVideo:
return
#if reach here then no mp4 files in the folder (since would have broken out of the loop if there was a mp4 file)
#prompt user to give url to download new youtube video
if(gui):
print("GUI mode")
#return ui.promptUserToGiveVideoPath(folder_path, output_directory, chunk_duration, edited_directory)
return
else:
print("CLI mode")
return promptUserForURLCLI(folder_path, output_directory, cfg, edited_directory)
def promptUserForURLCLI(folder_path : str,
output_directory : str,
cfg: Config,
edited_directory: str) -> None:
print(Color.RED + "No mp4 files in the folder. Please add mp4 files to the folder." + Color.END)
#wait for user input
waiting : bool = True
while (waiting):
answer: str = input(Color.YELLOW + "Enter a url for a youtube video to download to edit \n or type 'exit' to stop editing \n or type 'yes' if you added a file to the 'to_split' folder \n" + Color.END)
if(answer == "exit"):
print(Color.GREEN + "exiting video editing..." + Color.END)
return
if (answer == "yes"):
waiting = False
print(Color.GREEN + "Continuing program..." + Color.END)
return splitVideos(folder_path, output_directory, cfg, edited_directory) #call the function again to split the video into chunks
#check if answer is url
else :
try:
print(Color.GREEN + "Downloading video..." + Color.END)
#print url and folder path we are dolownloading to
print(Color.BLUE + "URL: " + answer + Color.END)
print(Color.BLUE + "Save Path: " + folder_path + Color.END)
print(Color.GREEN + "\n--------------------------------------------" + Color.END)
#call the download_vid function to download the video
#downloads the highest quality audio and video
#saves it to the to_split folder
download_vid(answer, folder_path)
#call the function again to split the video, checks if there are mp4 files in the folder, should be since we just downloaded a video
return splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory)
except Exception as e:
print(Color.RED + "Error downloading video: " + str(e) + Color.END)
waiting = True
#default to none if no arguments provided
def main(editBool = None, uploadBool = None, gui: bool = False) -> None:
# Get the current working directory (where your Python script is located)
project_root = os.getcwd()
#read title.txt file to print title
with open(os.path.join('assets', 'title.txt'), 'r') as file:
title = file.read().strip()
print(Color.RED + title + Color.END)
time.sleep(2)
# Define relative paths from the project root
folder_path = os.path.join(project_root, "to_split")
output_directory = os.path.join(project_root, "done_split")
edited_directory = os.path.join(folder_path, "edited")
print(Color.GREEN + "Folder path: " + folder_path + Color.END)
print(Color.GREEN + "Output directory: " + output_directory + Color.END)
print(Color.GREEN + "Edited directory: " + edited_directory + Color.END)
# Load the config file
cfg = Config.load()
MinsBefore = cfg.sleepXMinsBeforeStartingUploader
print(Color.GREEN + "Config: " + str(cfg.model_dump()) + Color.END)
#if a flag is provided, run the program only for that flag
if(editBool or uploadBool):
# Split one of the videos into chunks, move into edited folder
if editBool:
splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui)
return
# Upload the chunks to YouTube within the done_split folder
# do this until there are no more videos to upload, then go back to editing
if uploadBool:
print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END)
time.sleep(MinsBefore * 60)
uploadVideos(output_directory, cfg)
return
#else run the program for both flags, edit one vid -> upload -> edit another vid -> upload -> etc
i : int = 0
while(True):
# Split one of the videos into chunks, move into edited folder
splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui, splitOneVideo=True)
#Upload the chunks to YouTube within the done_split folder
#if we are on the first video, sleep for MinsBefore minutes in order to delay the first upload
if(i == 0):
print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END)
#print when next upload will be
next_upload = datetime.now() + timedelta(seconds=MinsBefore * 60)
print(Color.BLUE+ f" => next upload at {next_upload}" + Color.END)
time.sleep(MinsBefore * 60)
uploadVideos(output_directory, cfg)
i += 1
# Run the main function if the name of the module is __main__
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Process videos for YouTube.')
parser.add_argument('--edit', '-e', action='store_true', help='Split videos into chunks. \n \
Edits all mp4 files within the to_split folder. \n \
Without uploading the videos after editing.')
parser.add_argument('--upload', '-u', action='store_true', help='Upload video chunks to YouTube. \n \
Uploads all mp4 files within the done_split folder.')
args = parser.parse_args() #parse the arguments, so we can use them in the program, shows up in help message for functions
if not args.edit and not args.upload:
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
print(Color.YELLOW + "\n No arguments provided. \n \
Defualt functionality is to edit one video into chunks, then upload the chunks to YouTube, and continue edit-upload as a loop. \n \
Please use --edit to only edit videos from to_split folder, or --upload to only upload videos from done_split folder. \n" + Color.END)
main(editBool=args.edit, uploadBool=args.upload) #call the main function with the arguments provided
+427
View File
@@ -0,0 +1,427 @@
<a id="readme-top"></a>
<!-- PROJECT SHIELDS -->
<!--
*** I'm using markdown "reference style" links for readability.
*** Reference links are enclosed in brackets [ ] instead of parentheses ( ).
*** See the bottom of this document for the declaration of the reference variables
*** for contributors-url, forks-url, etc. This is an optional, concise syntax you may use.
*** https://www.markdownguide.org/basic-syntax/#reference-style-links
-->
[![LinkedIn][linkedin-shield]][linkedin-url]
<!-- PROJECT LOGO -->
<br />
<div align="center">
<img src="demo_and_images/logo.png" alt="Logo" width="400" height="80">
</div>
<div>
<h3 align="center">Brainrotinator</h3>
### Podcast Clip Automation Using AI
- Edits long form content into clips with subtitles using FFmpeg (libass for burn-in)
- Web UI built with Gradio for editing — CLI still works for headless / cron use
- Transcribes audio using Vosk or Whisper Models (your choice)
- Mutes audio where profanity is detected using FFmpeg's `volume` filter driven by SRT timestamps
- Uses TinyLlamma LLM to generate titles based on transcription for YouTube and Instagram.
- Automatically uploads to YouTube, Instagram, and Tiktok based on schedule given in config file using Selenium Firefox.
- Downloads videos from youtube using given URL using Pytube
- Thank you Timofei for the inspiration and name of the project.
</div>
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li><a href="#about-the-project">About The Project</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#install-without-docker">Install without Docker</a></li>
<li><a href="#install-with-docker">Install with Docker</a></li>
</ul>
</li>
<li><a href="#running-the-program">Running the Program</a>
<ul>
<li><a href="#gradio-ui">Gradio UI</a></li>
<li><a href="#cli">CLI</a></li>
<li><a href="#config">Config</a></li>
</ul>
</li>
<li><a href="#things-to-note">Things to note</a></li>
<li><a href="#vosk-or-whisper">Vosk or Whisper</a></li>
<li><a href="#license">License</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT -->
## About The Project
### Video Created and Uploaded Using Brainrotinator
https://github.com/user-attachments/assets/36b5d927-ecde-4099-b9f5-687d2b0108e5
[Watch the demo on youtube](https://www.youtube.com/shorts/p__GGpKI9-w)
[Original Video](https://www.youtube.com/watch?v=Ue_jnmeBO_I)
I initially made this as a joke.
I have been editing youtube videos myself for around 10 years now. I wanted to see if I could automate the horrible podcast clips I see on youtube shorts using python.
It was really fun working with AI models to make some cool features for this project
## New Gradio Layout:
<img width="3085" height="1710" alt="image" src="https://github.com/user-attachments/assets/bdd28f0d-db84-4953-8147-0bc8afaaf1e9" />
<!-- ARCHITECTURE -->
## Architecture
The editor is **FFmpeg-only** as of the v2 rewrite. moviepy, ImageMagick, and cleanvid have all been removed. Setup is dramatically simpler — `pip install -r requirements.txt` and a working `ffmpeg` binary is enough to edit videos.
### Application Flow
```mermaid
flowchart TD
subgraph Input
A1[Upload MP4]
A2[YouTube URL\nyt-dlp download]
A3[Existing file\nin to_split/]
end
subgraph UI["Entry Points"]
B1[app.py\nGradio Web UI]
B2[main.py\nCLI]
end
subgraph Editor["brainrotinator/ — VideoEditor"]
C1[Split into chunks\nvideo_editor.py]
C2{Blur mode?}
C3[Blur letterbox\nffmpeg_ops.py]
C4[Center crop 9:16\nffmpeg_ops.py]
C5[Transcribe audio\ntranscribe.py]
C6{Whisper\nor Vosk?}
C7[Whisper model]
C8[Vosk model]
C9[Generate SRT\nsubtitles.py]
C10[Detect profanity\nprofanity.py / swears.txt]
C11[Burn subtitles\nlibass / ffmpeg_ops.py]
C12[Mute profanity\nFFmpeg volume filter]
C13[Generate title\nTinyLlama LLM]
end
subgraph Output["done_split/"]
D1[Final MP4 clips\nwith subtitles]
end
subgraph Uploaders
E1[YouTube\nyoutube_uploader_selenium]
E2[Instagram\nInstagram_Uploader]
E3[TikTok\nupload_tiktok.py]
end
A1 & A2 & A3 --> B1
A1 & A2 & A3 --> B2
B1 & B2 --> C1
C1 --> C2
C2 -->|Yes| C3
C2 -->|No| C4
C3 & C4 --> C5
C5 --> C6
C6 -->|Whisper| C7
C6 -->|Vosk| C8
C7 & C8 --> C9
C9 --> C10
C10 --> C11
C10 --> C12
C11 & C12 --> C13
C13 --> D1
D1 --> E1 & E2 & E3
```
### Repo Layout
```text
brainrotinator/ # Editor package — pure FFmpeg
ffmpeg_ops.py # Cut, crop, blur, burn-in, mute wrappers
subtitles.py # SRT → styled ASS, profanity → mute-range list
transcribe.py # Vosk / Whisper / TinyLlama (lazy, resumable)
video_editor.py # Per-chunk orchestration
profanity.py # Text-profanity censor
uploaders/ # Selenium-based uploaders
login.py, uploader_selenium.py, upload_tiktok.py
Instagram_Uploader/, youtube_uploader_selenium/
downloader/ # yt-dlp downloader module
downloadVid.py, combineAudioVideo.py
assets/ # Static files
fonts/, swears.txt, title.txt
to_split/, done_split/, subtitles/ # Runtime media directories
models/ # Persistent AI models storage (Vosk/TinyLllama/Whisper)
app.py # Gradio Web UI entrypoint
main.py # CLI entrypoint
config.py / config.json # Pydantic configuration model
Dockerfile / docker-compose.yml # Containerization setup
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
Editing requires only Python, FFmpeg (with libass), and ~8 GB of disk for models on first run. The selenium uploaders additionally need Firefox + geckodriver and per-platform cookies.
### Install with Docker
The best way to run Brainrotinator with Docker is using **Docker Compose**. This automatically handles mounting the `to_split`, `done_split`, and `models` directories so your files and AI models are saved locally on your machine, working seamlessly across Windows, Mac, and Linux without complex path variables.
1. Ensure your `config.json` points the AI models to the synced `models` folder:
```json
"voskModelDir": "models",
"tinyLlamaDir": "models"
```
2. Build and start the container in the background. (Use `--build` the first time you run this, or after pulling in new code updates):
```sh
docker compose up -d --build
```
*Note: On subsequent runs, you can just use `docker compose up -d` to start the container instantly without docker checking for build updates.*
Then open http://localhost:7860.
To view logs or access the container shell:
* **Logs:** `docker compose logs -f`
* **Shell:** `docker compose exec brainrotinator bash`
If you'll use the uploader, run `python login.py` **on your host** first (it needs a GUI) so cookies are present in the mounted volume before the container starts.
#### Save output locally without Gradio
You can also run the CLI editor directly via Docker Compose (bypassing the web UI). Finished clips land in `done_split/` on your host machine, so no browser download is needed:
```sh
docker compose run --rm brainrotinator python main.py -e
```
Drop your source mp4s into `to_split/` before running.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
### Install without Docker
**Prerequisites**
* Python 3.10+
* FFmpeg with libass (`ffmpeg -filters | grep " ass "` should list it; most distro packages and the official Windows builds include it)
* ~10 GB VRAM if you'll use Whisper; CPU is fine for Vosk
* ~4 GB for the TinyLlama model, ~4 GB for the Vosk model (downloaded automatically on first use)
* Firefox + geckodriver — only if you'll use the uploader
**Steps**
1. Clone the repo and `cd` in.
2. Install Python deps:
```sh
pip install -r requirements.txt
```
3. Make sure `ffmpeg` is on your `PATH`. No `IMAGEMAGICK_BINARY` / `FFMPEG_BINARY` env vars are needed anymore.
4. *(Uploader only)* install geckodriver v0.32.0 and put it on your `PATH`, then run `python login.py` once on a machine with a GUI to capture cookies.
5. Launch:
* Gradio UI: `python app.py` → http://localhost:7860
* Or CLI: `python main.py` (see [CLI](#cli) below)
## Running the Program
### Gradio UI
```sh
python app.py
```
Tabs:
* **Edit** — upload an mp4 or paste a YouTube URL, set chunk length / blur / Vosk-vs-Whisper / profanity filter, watch logs stream as the splitter runs.
* **Library** — list everything in `done_split/`. **Click a filename to download it** to your computer. Delete clips you don't want.
* **Settings** — edit `config.json` in-browser, validated against the pydantic schema before save.
### CLI
```sh
python main.py # default loop: edit one video → upload from done_split → repeat
python main.py -e # edit only (consume to_split/, write to done_split/)
python main.py -u # upload only (consume done_split/ on the schedule in config.json)
```
When the editor runs out of videos in `to_split/`, the CLI prompts for a YouTube URL and downloads it via `yt-dlp`.
### Config
`config.json` is now validated by `config.Config` (`config.py`). Defaults are filled in for any missing keys.
```json
{
"tags": ["chuckle Sandwich", "jschlatt", "ted nivison", "slimecicle", "gaming", "comedy"],
"description": "#shorts",
"howManyUploads": 1,
"howManyHoursBetweenSchedule": 0,
"howManyMinsBetweenUpload": 5,
"howManyHoursLongToSleep": 23,
"sleepXMinsBeforeStartingUploader": 0,
"chunkDuration": 58,
"blurTopBottomOfClip": true,
"useWhisperForTranscription": false,
"filterProfanityInSubtitles": false,
"uploadToYoutube": true,
"uploadToInstagram": true,
"uploadToTiktok": false,
"firefoxHeadless": true,
"voskModelDir": "",
"tinyLlamaDir": ""
}
```
| Key | Meaning |
|---|---|
| `tags` | YouTube tags; also used as #hashtags appended to the IG/TikTok caption |
| `description` | YouTube description; prepended before tags for IG/TikTok |
| `howManyUploads` | Uploads per cycle before sleeping `howManyHoursLongToSleep` |
| `howManyHoursBetweenSchedule` | Hours between each scheduled upload (YouTube/TikTok only — IG ignores) |
| `howManyMinsBetweenUpload` | Base delay between uploads, plus a 05 min jitter |
| `chunkDuration` | Clip length in seconds |
| `blurTopBottomOfClip` | `true` = blurred letterbox; `false` = center-crop to 9:16 |
| `useWhisperForTranscription` | `true` = Whisper, `false` = Vosk |
| `filterProfanityInSubtitles` | Censor swears in burned-in subtitles (audio is muted regardless) |
| `firefoxHeadless` | Must be `true` inside Docker (no display) |
| `voskModelDir`, `tinyLlamaDir` | Where to cache models. Empty = current working directory |
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Things to note
* **Editor vs uploader**: the editor is FFmpeg-only and should keep working indefinitely. The selenium uploaders depend on YouTube/IG/TikTok DOM layout and **will break** when those sites change. I am not maintaining them.
* **`swears.txt`** is the source of truth for what gets muted. Add or remove words to taste. Matching is word-bounded so `ass` won't match `class`.
* **TikTok uploads** require cookies from https://github.com/wkaisertexas/tiktok-uploader — and you will hit captchas. A paid solver like sadcaptcha can fix it; this repo doesn't include one.
* **Headless selenium**: cookies must already exist or the upload will crash. Run `login.py` on a machine with a GUI first.
* **Models** download lazily on first transcription. Vosk shows a `tqdm` progress bar; TinyLlama uses `huggingface_hub.snapshot_download` (resumable).
* **libass fonts**: the burn-in filter is invoked with `fontsdir=fonts/`, so any `.ttf` you drop in `fonts/` is available. Default is `Bangers.ttf`.
## Vosk or whisper
### Whisper
**Pros:**
- Really accurate
- Better profanity filter due to accuracy
**Cons:**
- Subtitles linger/timing is bad
- Late/early profanity mute due to timing
### Vosk
**Pros:**
- Timing is really good
- Timing of muting profanity very good
**Cons:**
- Not very accurate, so words might not get filtered
- A lot of the words are not accurate
### Notes
Maybe adapt whisper to use https://github.com/m-bain/whisperX for better timing
* I used vosk for the example video in readme
* Vosk vs whisper comparison in the demo_and_images folder
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- LICENSE -->
## License
Do not Sell this program. Do not use it for your own cloud service you are selling like [this](https://www.opus.pro/).
Other than that do what you like with it.
<!-- ACKNOWLEDGMENTS -->
## Acknowledgments
Thank you to the following projects for making this possible.
* [Vosk](https://alphacephei.com/vosk/)
* [Whisper](https://github.com/openai/whisper)
* [TinyLlama](https://huggingface.co/TinyLlama)
* [Text Profanity Filter](https://github.com/ben174/profanity)
* [CleanVid (mute profanity in audio)](https://github.com/mmguero/cleanvid)
* [FFmpeg](https://ffmpeg.org/) + [libass](https://github.com/libass/libass)
* [pysubs2](https://github.com/tkarabela/pysubs2) (SRT → ASS conversion)
* [Gradio](https://www.gradio.app/)
* [Youtube selenium uploader (also what I used to make the instagram uploader)](https://github.com/linouk23/youtube_uploader_selenium)
* [yt-dlp for downloading youtube videos](https://github.com/ytdl-org/youtube-dl)
* [TiktokUploader](https://github.com/wkaisertexas/tiktok-uploader)
* [readme Template](https://github.com/othneildrew/Best-README-Template/blob/main/README.md)
### TODO
- Add support for different models (current ones are outdated)
- Change preview of subtitles, maybe run subtitles through llm to get emojis or custom colors per line
- Color param for text
- CV to focus crop around face/person talking
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/othneildrew/Best-README-Template.svg?style=for-the-badge
[contributors-url]: https://github.com/othneildrew/Best-README-Template/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/othneildrew/Best-README-Template.svg?style=for-the-badge
[forks-url]: https://github.com/othneildrew/Best-README-Template/network/members
[stars-shield]: https://img.shields.io/github/stars/othneildrew/Best-README-Template.svg?style=for-the-badge
[stars-url]: https://github.com/othneildrew/Best-README-Template/stargazers
[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge
[issues-url]: https://github.com/othneildrew/Best-README-Template/issues
[license-shield]: https://img.shields.io/github/license/othneildrew/Best-README-Template.svg?style=for-the-badge
[license-url]: https://github.com/othneildrew/Best-README-Template/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://www.linkedin.com/in/luke-sorvik/
[product-screenshot]: images/screenshot.png
[Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
[Next-url]: https://nextjs.org/
[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB
[React-url]: https://reactjs.org/
[Vue.js]: https://img.shields.io/badge/Vue.js-35495E?style=for-the-badge&logo=vuedotjs&logoColor=4FC08D
[Vue-url]: https://vuejs.org/
[Angular.io]: https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular&logoColor=white
[Angular-url]: https://angular.io/
[Svelte.dev]: https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte&logoColor=FF3E00
[Svelte-url]: https://svelte.dev/
[Laravel.com]: https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white
[Laravel-url]: https://laravel.com
[Bootstrap.com]: https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white
[Bootstrap-url]: https://getbootstrap.com
[JQuery.com]: https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white
[JQuery-url]: https://jquery.com
+89
View File
@@ -0,0 +1,89 @@
accelerate==0.34.2
attrs==24.2.0
beautifulsoup4==4.12.3
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.3.2
click==8.1.7
filelock==3.16.1
fsspec==2024.9.0
geckodriver-autoinstaller==0.1.0
gradio>=4.44.0
pydantic>=2.0
huggingface-hub>=0.28.1
idna==3.10
Jinja2==3.1.4
jsoncodable==0.1.7
jsonpickle==3.3.0
k-selenium-cookies==0.0.4
llvmlite==0.43.0
MarkupSafe==2.1.5
more-itertools==10.5.0
mpmath==1.3.0
networkx==3.3
noraise==0.0.16
numba==0.60.0
numpy==2.0.2
nvidia-cublas-cu12==12.1.3.1
nvidia-cuda-cupti-cu12==12.1.105
nvidia-cuda-nvrtc-cu12==12.1.105
nvidia-cuda-runtime-cu12==12.1.105
nvidia-cudnn-cu12==8.9.2.26
nvidia-cufft-cu12==11.0.2.54
nvidia-curand-cu12==10.3.2.106
nvidia-cusolver-cu12==11.4.5.107
nvidia-cusparse-cu12==12.1.0.106
nvidia-nccl-cu12==2.20.5
nvidia-nvjitlink-cu12==12.6.68
nvidia-nvtx-cu12==12.1.105
openai-whisper==20240930
outcome==1.3.0.post0
packaging==24.1
pillow==10.4.0
platformdirs==4.3.6
psutil==6.0.0
pycparser==2.22
pyperclip==1.9.0
PySocks==1.7.1
pysrt==1.1.2
pysubs2==1.7.3
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
yt-dlp>=2024.1.0
pytz==2024.2
PyYAML==6.0.2
regex==2024.9.11
requests==2.32.3
safetensors==0.4.5
selenium>=4.15.0
selenium-browser==0.0.15
selenium-firefox==2.0.8
setuptools==69.1.1
six==1.16.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.6
srt==3.5.3
sympy==1.13.3
termcolor==2.4.0
tiktok-uploader==1.0.16
tiktoken==0.7.0
tldextract==5.1.2
tokenizers==0.19.1
toml==0.10.2
tomli==2.0.1
torch==2.3.1
tqdm==4.66.5
transformers==4.44.2
trio==0.26.2
trio-websocket==0.11.1
triton==2.3.1
typing_extensions==4.12.2
urllib3>=2.0
vosk==0.3.45
webdriver-manager==4.0.2
websocket-client==1.8.0
websockets==13.0.1
wheel==0.43.0
wsproto==1.2.0
xpath-utils==0.0.3
+3
View File
@@ -0,0 +1,3 @@
selenium==4.9.0
selenium-browser==0.0.15
selenium-firefox==2.0.8
View File
@@ -0,0 +1,54 @@
class Constant:
"""A class for storing constants for YoutubeUploader class"""
YOUTUBE_URL = 'https://www.youtube.com'
YOUTUBE_STUDIO_URL = 'https://studio.youtube.com'
YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload'
USER_WAITING_TIME = 1
VIDEO_TITLE = 'title'
VIDEO_DESCRIPTION = 'description'
VIDEO_EDIT = 'edit'
VIDEO_TAGS = 'tags'
TEXTBOX_ID = 'textbox'
TEXT_INPUT = 'text-input'
RADIO_LABEL = 'radioLabel'
UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]'
NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK'
Click_Add = '//*[@id="mount_0_0_7N"]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a/div/div[1]/div/div/svg'
UPLOAD_DIALOG = '//ytcp-uploads-dialog'
ADVANCED_BUTTON_ID = 'toggle-button'
TAGS_CONTAINER_ID = 'tags-container'
TAGS_INPUT = 'text-input'
NEXT_BUTTON = 'next-button'
PUBLIC_BUTTON = 'PUBLIC'
VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']"
VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']"
HREF = 'href'
ERROR_CONTAINER = '//*[@id="error-message"]'
VIDEO_NOT_FOUND_ERROR = 'Could not find video_id'
DONE_BUTTON = 'done-button'
INPUT_FILE_VIDEO = "//input[@type='file']"
INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']"
# Playlist
VIDEO_PLAYLIST = 'playlist_title'
PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists'
PL_SEARCH_INPUT_ID = 'search-input'
PL_ITEMS_CONTAINER_ID = 'items'
PL_ITEM_CONTAINER = '//span[text()="{}"]'
PL_NEW_BUTTON_CLASS = 'new-playlist-button'
PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form'
PL_CREATE_BUTTON_CLASS = 'create-playlist-button'
PL_DONE_BUTTON_CLASS = 'done-button'
#Schedule
VIDEO_SCHEDULE = 'schedule'
SCHEDULE_CONTAINER_ID = 'second-container-expand-button'
SCHEDULE_DATE_ID = 'datepicker-trigger'
SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input'
SCHEDULE_TIME = '//*[@id="input-1"]/input'
@@ -0,0 +1,221 @@
"""This module implements uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc."""
from typing import DefaultDict, Optional, Tuple
from selenium_firefox.firefox import Firefox
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from collections import defaultdict
from datetime import datetime
import json
import time
from .Constant import *
from pathlib import Path
import logging
import platform
logging.basicConfig()
import random
#used to generate a random wait time to perform the actions on the browser
def random_time():
rand : int = random.randint(1, 3)
print("Random time: ", rand)
return rand
def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]:
if metadata_json_path is None:
return defaultdict(str)
with open(metadata_json_path, encoding='utf-8') as metadata_json_file:
return defaultdict(str, json.load(metadata_json_file))
class InstagramUploader:
"""A class for uploading videos on Instagram via Selenium using metadata JSON file
to extract its title, description etc"""
def __init__(self, video_path: str, metadata_json_path: Optional[str] = None,
thumbnail_path: Optional[str] = None,
profile_path: Optional[str] = str(Path.cwd()) + "/profile",
headless : bool = True) -> None:
self.video_path = video_path
self.thumbnail_path = thumbnail_path
self.metadata_dict = load_metadata(metadata_json_path)
self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless)
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.DEBUG)
self.__validate_inputs()
print("headless for instagram =" +str(headless))
self.is_mac = False
if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]):
self.is_mac = True
self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path))
def __validate_inputs(self):
if not self.metadata_dict[Constant.VIDEO_TITLE]:
self.logger.warning(
"The video title was not found in a metadata file")
self.metadata_dict[Constant.VIDEO_TITLE] = Path(
self.video_path).stem
self.logger.warning("The video title was set to {}".format(
Path(self.video_path).stem))
if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]:
self.logger.warning(
"The video description was not found in a metadata file")
def upload(self):
try:
self.login()
return self.__upload()
except Exception as e:
print(e)
self.__quit()
raise
def login(self):
self.browser.get("https://www.instagram.com/")
time.sleep(1)
if self.browser.has_cookies_for_current_website():
self.browser.load_cookies()
self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path))
time.sleep(1)
self.browser.refresh()
else:
self.logger.info('Please sign in and then press enter')
input()
self.browser.get("https://www.instagram.com/")
time.sleep(3)
self.browser.save_cookies()
self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path))
def __clear_field(self, field):
field.click()
time.sleep(3)
if self.is_mac:
field.send_keys(Keys.COMMAND + 'a')
else:
field.send_keys(Keys.CONTROL + 'a')
time.sleep(2)
field.send_keys(Keys.BACKSPACE)
def __write_in_field(self, field, string, select_all=False):
if select_all:
self.__clear_field(field)
else:
field.click()
time.sleep(random_time())
field.send_keys(string)
#find element, if not found, wait 3 seconds and try again
#by - the type of element to find
#value - the value of the element to find
#name - the name of the element to find
#return - the element found
def find_element(self, by, value: str, name: str):
element = None
startTime = time.time()
while element is None:
element = self.browser.find(by, value)
time.sleep(3)
print(f"{name} not found")
#if 1 minute has passed, break the loop
if time.time() - startTime > 60:
break
print(f"{name} found")
return element
def __upload(self) -> bool:
self.browser.get("https://www.instagram.com/?next=%2F")
uploading_status_container = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'button._a9--:nth-child(2)', "uploading status container")
uploading_status_container.click()
print("clicked no updates")
time.sleep(2)
upload_button = None
upload_button = InstagramUploader.find_element(self,By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a', "upload button")
upload_button.click()
print("clicked create")
time.sleep(3)
self.browser.find(By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/div/div/div[1]/a[1]').click()
print("clicked post")
time.sleep(3)
absolute_video_path = str(Path.cwd() / self.video_path)
videoUpload = InstagramUploader.find_element(self,By.CSS_SELECTOR,'._ac69', "video upload")
videoUpload.send_keys(absolute_video_path)
print("gave video path: ", absolute_video_path)
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '._acap', "next")
next.click()
print("clicked next")
time.sleep(3)
size = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > button:nth-child(1)', "size")
size.click()
print("clicked size")
time.sleep(3)
phone_resolution = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x1i10hfl:nth-child(5)', "phone resolution")
phone_resolution.click()
print("clicked phone resolution")
clickOff = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(3)', "click off")
clickOff.click()
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "next")
next.click()
print("clicked next")
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.xyamay9 > div:nth-child(1)', "next")
next.click()
print("clicked next")
time.sleep(3)
description_field = self.browser.find(By.XPATH, '/html/body/div[6]/div[1]/div/div[3]/div/div/div/div/div/div/div/div[2]/div[2]/div/div/div/div[1]/div[2]/div/div[1]/div[1]')
video_description :str = self.metadata_dict[Constant.VIDEO_DESCRIPTION]
video_description = video_description.replace("\n", Keys.ENTER)
if video_description:
description_field = self.browser.find(By.CSS_SELECTOR, '.x1hnll1o')
print("Video Description: ", video_description)
description_field.click()
time.sleep(2)
[description_field.send_keys(c) for c in video_description] #send_keys(video_description)
print("Video description added")
time.sleep(2)
share = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "share")
share.click()
print("clicked share")
#uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
uploading_status_container_done = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x5yr21d:nth-child(1) > div:nth-child(1) > div:nth-child(2)', "uploading status container done")
self.__quit()
return True
def __get_video_id(self) -> Optional[str]:
video_id = None
try:
video_url_container = self.browser.find(
By.XPATH, Constant.VIDEO_URL_CONTAINER)
video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container)
video_id = video_url_element.get_attribute(
Constant.HREF).split('/')[-1]
except:
self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR)
pass
return video_id
def __quit(self):
self.browser.driver.quit()
+23
View File
@@ -0,0 +1,23 @@
import os
from .youtube_uploader_selenium import YouTubeUploader
from .Instagram_Uploader.instagramUploader import InstagramUploader
from termcolor import colored
def login_youtube(metadata:str) -> None:
print(colored("Logging in to Youtube", "blue"))
uploader = YouTubeUploader("dummy", metadata, headless= False)
uploader.login()
def login_instagram(metadata:str) -> None:
print(colored("Logging in to Instagram", "magenta"))
uploader = InstagramUploader("dummy",metadata, headless= False)
uploader.login()
if __name__ == "__main__":
print(colored("Logging in to Youtube and Instagram to save cookies for future use", "green"))
print(colored("Firefox browser required", "yellow"))
outputDirectory :str = os.path.join(os.getcwd(), "done_split")
metadata_path :str = os.path.join(outputDirectory, "upload.json")
login_youtube(metadata_path)
login_instagram(metadata_path)
print(colored("Cookies saved successfully. Now you can upload videos without logging in again", "cyan"))
+16
View File
@@ -0,0 +1,16 @@
from tiktok_uploader.upload import upload_video, upload_videos
from tiktok_uploader.auth import AuthBackend
#https://github.com/wkaisertexas/tiktok-uploader?tab=readme-ov-file
def upload_video_Tiktok(video_path :str, description:str, cookies:str) -> None:
username = 'your username here'
password = 'password here'
upload_video(filename=video_path, description=description, cookies= cookies, username=username, password=password)
@@ -0,0 +1,31 @@
from .youtube_uploader_selenium import YouTubeUploader
#from https://github.com/linouk23/youtube_uploader_selenium
from .Instagram_Uploader.instagramUploader import InstagramUploader
def upload_video_youtube(video_path: str, metadata_path: str, headless: bool, i : int = 0) -> None:
uploader = YouTubeUploader(video_path, metadata_path, headless=headless)
try:
uploader.upload()
except Exception as e:
print(f"Error uploading video: {e}")
print("Retrying upload...")
if i < 5:
print(f"Retry number {i}")
i += 1
upload_video_youtube(video_path, metadata_path, headless, i) # retry the upload recursively
def upload_video_Instagram(video_path: str, metadata_path: str, headless: bool, i: int = 0) -> None:
uploader = InstagramUploader(video_path, metadata_path, headless=headless)
try:
uploader.upload()
except Exception as e:
print(f"Error uploading video: {e}")
print("Retrying upload...")
if(i < 5):
print(f"Retry number {i}")
i += 1
upload_video_Instagram(video_path, metadata_path,headless, i) # retry the upload recursively
@@ -0,0 +1,50 @@
class Constant:
"""A class for storing constants for YoutubeUploader class"""
YOUTUBE_URL = 'https://www.youtube.com'
YOUTUBE_STUDIO_URL = 'https://studio.youtube.com'
YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload'
USER_WAITING_TIME = 1
VIDEO_TITLE = 'title'
VIDEO_DESCRIPTION = 'description'
VIDEO_EDIT = 'edit'
VIDEO_TAGS = 'tags'
TEXTBOX_ID = 'textbox'
TEXT_INPUT = 'text-input'
RADIO_LABEL = 'radioLabel'
UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]'
NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK'
UPLOAD_DIALOG = '//ytcp-uploads-dialog'
ADVANCED_BUTTON_ID = 'toggle-button'
TAGS_CONTAINER_ID = 'tags-container'
TAGS_INPUT = 'text-input'
NEXT_BUTTON = 'next-button'
PUBLIC_BUTTON = 'PUBLIC'
VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']"
VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']"
HREF = 'href'
ERROR_CONTAINER = '//*[@id="error-message"]'
VIDEO_NOT_FOUND_ERROR = 'Could not find video_id'
DONE_BUTTON = 'done-button'
INPUT_FILE_VIDEO = "//input[@type='file']"
INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']"
# Playlist
VIDEO_PLAYLIST = 'playlist_title'
PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists'
PL_SEARCH_INPUT_ID = 'search-input'
PL_ITEMS_CONTAINER_ID = 'items'
PL_ITEM_CONTAINER = '//span[text()="{}"]'
PL_NEW_BUTTON_CLASS = 'new-playlist-button'
PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form'
PL_CREATE_BUTTON_CLASS = 'create-playlist-button'
PL_DONE_BUTTON_CLASS = 'done-button'
#Schedule
VIDEO_SCHEDULE = 'schedule'
SCHEDULE_CONTAINER_ID = 'second-container-expand-button'
SCHEDULE_DATE_ID = 'datepicker-trigger'
SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input'
SCHEDULE_TIME = '//*[@id="input-1"]/input'
@@ -0,0 +1,296 @@
"""This module implements uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc."""
from typing import DefaultDict, Optional, Tuple
from selenium_firefox.firefox import Firefox
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from collections import defaultdict
from datetime import datetime
import json
import time
from .Constant import *
from pathlib import Path
import logging
import platform
logging.basicConfig()
import random
#used to generate a random wait time to perform the actions on the browser
def random_time():
rand : int = random.randint(1, 3)
print("Random time: ", rand)
return rand
def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]:
if metadata_json_path is None:
return defaultdict(str)
with open(metadata_json_path, encoding='utf-8') as metadata_json_file:
return defaultdict(str, json.load(metadata_json_file))
class YouTubeUploader:
"""A class for uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc"""
def __init__(self, video_path: str, metadata_json_path: Optional[str] = None,
thumbnail_path: Optional[str] = None,
profile_path: Optional[str] = str(Path.cwd()) + "/profile",
headless : bool = True) -> None:
self.video_path = video_path
self.thumbnail_path = thumbnail_path
self.metadata_dict = load_metadata(metadata_json_path)
self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless)
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.DEBUG)
self.__validate_inputs()
print("headless for youtube =" + str(headless))
self.is_mac = False
if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]):
self.is_mac = True
self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path))
def __validate_inputs(self):
if not self.metadata_dict[Constant.VIDEO_TITLE]:
self.logger.warning(
"The video title was not found in a metadata file")
self.metadata_dict[Constant.VIDEO_TITLE] = Path(
self.video_path).stem
self.logger.warning("The video title was set to {}".format(
Path(self.video_path).stem))
if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]:
self.logger.warning(
"The video description was not found in a metadata file")
def upload(self):
try:
self.login()
return self.__upload()
except Exception as e:
print(e)
self.__quit()
raise
def login(self):
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
if self.browser.has_cookies_for_current_website():
self.browser.load_cookies()
self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path))
time.sleep(random_time())
self.browser.refresh()
else:
self.logger.info('Please sign in and then press enter')
input()
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
self.browser.save_cookies()
self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path))
def __clear_field(self, field):
field.click()
time.sleep(random_time())
if self.is_mac:
field.send_keys(Keys.COMMAND + 'a')
else:
field.send_keys(Keys.CONTROL + 'a')
time.sleep(random_time())
field.send_keys(Keys.BACKSPACE)
def __write_in_field(self, field, string, select_all=False):
if select_all:
self.__clear_field(field)
else:
field.click()
time.sleep(random_time())
field.send_keys(string)
def __upload(self) -> Tuple[bool, Optional[str]]:
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
self.browser.get(Constant.YOUTUBE_UPLOAD_URL)
time.sleep(random_time())
absolute_video_path = str(Path.cwd() / self.video_path)
self.browser.find(By.XPATH, Constant.INPUT_FILE_VIDEO).send_keys(
absolute_video_path)
self.logger.debug('Attached video {}'.format(self.video_path))
# Find status container
uploading_status_container = None
while uploading_status_container is None:
time.sleep(0.5) #bug where slept too long, missed finding the element got soft locked
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
if self.thumbnail_path is not None:
absolute_thumbnail_path = str(Path.cwd() / self.thumbnail_path)
self.browser.find(By.XPATH, Constant.INPUT_FILE_THUMBNAIL).send_keys(
absolute_thumbnail_path)
change_display = "document.getElementById('file-loader').style = 'display: block! important'"
self.browser.driver.execute_script(change_display)
self.logger.debug(
'Attached thumbnail {}'.format(self.thumbnail_path))
title_field, description_field = self.browser.find_all(By.ID, Constant.TEXTBOX_ID, timeout=15)
self.__write_in_field(
title_field, self.metadata_dict[Constant.VIDEO_TITLE], select_all=True)
self.logger.debug('The video title was set to \"{}\"'.format(
self.metadata_dict[Constant.VIDEO_TITLE]))
video_description = self.metadata_dict[Constant.VIDEO_DESCRIPTION]
video_description = video_description.replace("\n", Keys.ENTER)
if video_description:
self.__write_in_field(description_field, video_description, select_all=True)
self.logger.debug('Description filled.')
kids_section = self.browser.find(By.NAME, Constant.NOT_MADE_FOR_KIDS_LABEL)
kids_section.location_once_scrolled_into_view
time.sleep(random_time())
self.browser.find(By.ID, Constant.RADIO_LABEL, kids_section).click()
self.logger.debug('Selected \"{}\"'.format(Constant.NOT_MADE_FOR_KIDS_LABEL))
# Playlist
playlist = self.metadata_dict[Constant.VIDEO_PLAYLIST]
if playlist:
self.browser.find(By.CLASS_NAME, Constant.PL_DROPDOWN_CLASS).click()
time.sleep(random_time())
search_field = self.browser.find(By.ID, Constant.PL_SEARCH_INPUT_ID)
self.__write_in_field(search_field, playlist)
time.sleep(random_time() * 2)
playlist_items_container = self.browser.find(By.ID, Constant.PL_ITEMS_CONTAINER_ID)
# Try to find playlist
self.logger.debug('Playlist xpath: "{}".'.format(Constant.PL_ITEM_CONTAINER.format(playlist)))
playlist_item = self.browser.find(By.XPATH, Constant.PL_ITEM_CONTAINER.format(playlist), playlist_items_container)
if playlist_item:
self.logger.debug('Playlist found.')
playlist_item.click()
time.sleep(random_time())
else:
self.logger.debug('Playlist not found. Creating')
self.__clear_field(search_field)
time.sleep(random_time())
new_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_NEW_BUTTON_CLASS)
new_playlist_button.click()
create_playlist_container = self.browser.find(By.ID, Constant.PL_CREATE_PLAYLIST_CONTAINER_ID)
playlist_title_textbox = self.browser.find(By.XPATH, "//textarea", create_playlist_container)
self.__write_in_field(playlist_title_textbox, playlist)
time.sleep(random_time())
create_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_CREATE_BUTTON_CLASS)
create_playlist_button.click()
time.sleep(random_time())
done_button = self.browser.find(By.CLASS_NAME, Constant.PL_DONE_BUTTON_CLASS)
done_button.click()
# Advanced options
self.browser.find(By.ID, Constant.ADVANCED_BUTTON_ID).click()
self.logger.debug('Clicked MORE OPTIONS')
time.sleep(random_time())
#click not ai (added myself by inspecting element and right clicking copy by xpath)
not_ai = self.browser.find(By.XPATH, "/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[1]/ytcp-ve/ytcp-video-metadata-editor/div/ytcp-video-metadata-editor-advanced/div[2]/ytkp-altered-content-select/div[2]/tp-yt-paper-radio-group/tp-yt-paper-radio-button[2]/div[1]/div[1]")
not_ai.click()
time.sleep(random_time())
# Tags
tags = self.metadata_dict[Constant.VIDEO_TAGS]
if tags:
tags_container = self.browser.find(By.ID, Constant.TAGS_CONTAINER_ID)
tags_field = self.browser.find(By.ID, Constant.TAGS_INPUT, tags_container)
self.__write_in_field(tags_field, ','.join(tags))
self.logger.debug('The tags were set to \"{}\"'.format(tags))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} one'.format(Constant.NEXT_BUTTON))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} two'.format(Constant.NEXT_BUTTON))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} three'.format(Constant.NEXT_BUTTON))
schedule = self.metadata_dict[Constant.VIDEO_SCHEDULE]
#Schedule
if schedule:
upload_time_object = datetime.strptime(schedule, "%m/%d/%Y, %H:%M")
self.browser.find(By.ID, Constant.SCHEDULE_CONTAINER_ID).click() #click the schedule dropdown
time.sleep(1) #make sure there is time for the date picker to load
schedule2 = self.browser.find(By.ID, Constant.SCHEDULE_DATE_ID) #find the date picker
time.sleep(1) #wait for schedule2 to load
schedule2.click() #click to open calendar
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).clear() #clear the date text box
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys(
datetime.strftime(upload_time_object, "%b %e, %Y"))
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys(Keys.ENTER)
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).click()
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).clear()
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys(
datetime.strftime(upload_time_object, "%H:%M"))
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys(Keys.ENTER)
self.logger.debug(f"Scheduled the video for {schedule}")
else:
public_main_button = self.browser.find(By.NAME, Constant.PUBLIC_BUTTON)
self.browser.find(By.ID, Constant.RADIO_LABEL, public_main_button).click()
self.logger.debug('Made the video {}'.format(Constant.PUBLIC_BUTTON))
video_id = self.__get_video_id()
# Check status container and upload progress
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
while uploading_status_container is not None:
uploading_progress = uploading_status_container.get_attribute('value')
self.logger.debug('Upload video progress: {}%'.format(uploading_progress))
time.sleep(random_time() * 5)
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
self.logger.debug('Upload container gone.')
done_button = self.browser.find(By.ID, Constant.DONE_BUTTON)
# Catch such error as
# "File is a duplicate of a video you have already uploaded"
if done_button.get_attribute('aria-disabled') == 'true':
error_message = self.browser.find(By.XPATH, Constant.ERROR_CONTAINER).text
self.logger.error(error_message)
return False, None
done_button.click()
self.logger.debug(
"Published the video with video_id = {}".format(video_id))
time.sleep(random_time())
self.browser.get(Constant.YOUTUBE_URL)
self.__quit()
return True, video_id
def __get_video_id(self) -> Optional[str]:
video_id = None
try:
video_url_container = self.browser.find(
By.XPATH, Constant.VIDEO_URL_CONTAINER)
video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container)
video_id = video_url_element.get_attribute(
Constant.HREF).split('/')[-1]
except:
self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR)
pass
return video_id
def __quit(self):
self.browser.driver.quit()
Submodule openshorts deleted from fe87af6dd5
+55
View File
@@ -0,0 +1,55 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv/
ENV/
.eggs/
*.egg-info/
.pytest_cache/
# Node
node_modules/
remotion/node_modules/
render-service/node_modules/
dashboard/node_modules/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Video files (will be mounted via volume)
*.mp4
*.avi
*.mov
*.mkv
*.gif
videos/
# Model files (downloaded at build time)
*.pt
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Docs
README.md
*.md
+9
View File
@@ -0,0 +1,9 @@
# AWS S3 (optional — for clip backup/gallery)
AWS_ACCESS_KEY_ID=your_aws_access_key_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
AWS_REGION=eu-west-3
AWS_S3_BUCKET=your-bucket-name
AWS_S3_PUBLIC_BUCKET=your-public-bucket-name
# YouTube cookies (optional — paste Netscape-format cookies to bypass bot detection)
# YOUTUBE_COOKIES=...
+42
View File
@@ -0,0 +1,42 @@
# Video files
*.mp4
*.webm
*.mov
*.mkv
# YOLO model
*.pt
# Python virtual environment
.venv/
__pycache__/
*.pyc
# Temporary files / runtime dirs
temp_*
uploads/
downloads/
videos/
output/
# OS / IDE
.DS_Store
.idea/
.vscode/
# Secrets
.env
# Generated metadata
*_metadata.json
# Cache dirs
.cache/
.config/
# Multi-agent Skills
.agents/
.agent/
.claude/
skills/
skills-lock.json
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OpenShorts is an AI-powered vertical video generator that transforms long YouTube videos or local uploads into viral-ready short clips (9:16 format) for TikTok, Instagram Reels, and YouTube Shorts. Uses Google Gemini 2.0 Flash for viral moment detection and title generation.
## Development Commands
### Local Development (Docker)
```bash
docker compose up --build # Build and run full stack
```
- Backend: http://localhost:8000 (FastAPI/Uvicorn)
- Frontend: http://localhost:5175 (Vite proxies API calls to backend)
### Frontend Only (Dashboard)
```bash
cd dashboard
npm install
npm run dev # Dev server with HMR (port 5173)
npm run build # Production build
npm run lint # ESLint (strict, --max-warnings 0)
```
### Backend Only
```bash
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000
```
## Architecture
### Core Processing Pipeline
1. **Ingest** - YouTube download (yt-dlp) or local upload
2. **Transcription** - faster-whisper with word-level timestamps
3. **Scene Detection** - PySceneDetect for segment boundaries
4. **AI Analysis** - Gemini identifies 3-15 viral moments (15-60 sec each)
5. **FFmpeg Extraction** - Precise clip cutting
6. **AI Cropping** - Vertical reframing with subject tracking
7. **Effects/Subtitles** - Optional AI-generated FFmpeg filters
8. **Hook Overlay** - Text overlays with styled fonts
9. **Voice Dubbing** - Optional ElevenLabs AI translation (30+ languages)
10. **S3 Backup** - Silent background upload
11. **Social Distribution** - Upload-Post API (async upload)
### Key Files
| File | Purpose |
|------|---------|
| `main.py` | Core video processing: transcription, scene detection, clip extraction, vertical reframing |
| `app.py` | FastAPI server with async job queue and REST endpoints |
| `editor.py` | Gemini AI integration for dynamic video effects (FFmpeg filter generation) |
| `hooks.py` | Hook text overlay generation with font rendering |
| `s3_uploader.py` | AWS S3 upload with caching |
| `subtitles.py` | SRT generation, FFmpeg subtitle burning, and dubbed video transcription |
| `translate.py` | ElevenLabs dubbing API for AI voice translation |
| `dashboard/src/App.jsx` | Main React component with state management |
| `dashboard/src/components/TranslateModal.jsx` | Voice dubbing UI with language selection |
### Dual-Mode Video Reframing
- **TRACK Mode** (single subject): MediaPipe face detection + YOLOv8 fallback with "Heavy Tripod" stabilization
- **GENERAL Mode** (groups/landscapes): Blurred background layout preserving full width
### Key Classes
- `SmoothedCameraman` - Stabilized camera movement with safe zone logic (prevents jitter)
- `SpeakerTracker` - Prevents rapid speaker switching, handles temporary occlusions
### API Endpoints
| Method | Route | Purpose |
|--------|-------|---------|
| POST | `/api/process` | Submit video for processing |
| GET | `/api/status/{job_id}` | Poll job status and logs |
| POST | `/api/edit` | Apply AI video effects |
| POST | `/api/subtitle` | Generate and apply subtitles (auto-transcribes dubbed videos) |
| POST | `/api/hook` | Add text hook overlays |
| POST | `/api/translate` | AI voice dubbing via ElevenLabs |
| GET | `/api/translate/languages` | List supported dubbing languages |
| POST | `/api/social/post` | Post to social media (async upload) |
### Concurrency Model
Async job queue with semaphore-based concurrency control. Configure via `MAX_CONCURRENT_JOBS` env var (default: 5). Jobs auto-cleanup after 1 hour.
## Environment Variables
**Server-side (.env):**
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_S3_BUCKET` - For S3 backup
- `MAX_CONCURRENT_JOBS` - Concurrent processing limit (default: 5)
- `VITE_API_URL` - Production API URL override
**Client-side (localStorage, encrypted):**
- `GEMINI_API_KEY` - Google Gemini API key (required)
- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice dubbing (optional)
- `UPLOAD_POST_API_KEY` - Upload-Post API key for social posting (optional)
> API keys are stored encrypted in the browser and sent via headers only when needed. Never stored server-side.
## Tech Stack
- **Backend:** Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx
- **Frontend:** React 18, Vite 4, Tailwind CSS 3.4
- **External APIs:** Google Gemini, ElevenLabs Dubbing, Upload-Post
- **Infrastructure:** Docker + Docker Compose, AWS S3
+64
View File
@@ -0,0 +1,64 @@
# Multi-stage build for smaller final image
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
# Copy and install Python dependencies
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
# Final stage
FROM python:3.11-slim
WORKDIR /app
# Install FFmpeg, OpenCV dependencies, and Node.js (for yt-dlp JS challenges)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual env from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Always upgrade yt-dlp to latest (YouTube bot-detection changes frequently)
RUN pip install --upgrade --no-cache-dir yt-dlp
# Copy application code
COPY . .
# Create a non-root user (Moved up)
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
# Create directories including Ultralytics cache config
RUN mkdir -p /app/uploads /app/output /tmp/Ultralytics
# Fix permissions: /app for code/uploads, /tmp/Ultralytics for AI cache
RUN chown -R appuser:appuser /app /tmp/Ultralytics
# Switch to non-root user
USER appuser
# Pre-download YOLO model on build (now running as appuser)
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"
# Expose FastAPI port
EXPOSE 8000
# Run FastAPI app
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 OpenShorts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+297
View File
@@ -0,0 +1,297 @@
# OpenShorts.app
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Open Source](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://opensource.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://docs.docker.com/compose/)
[![GitHub stars](https://img.shields.io/github/stars/mutonby/openshorts?style=social)](https://github.com/mutonby/openshorts)
[![Last Commit](https://img.shields.io/github/last-commit/mutonby/openshorts)](https://github.com/mutonby/openshorts/commits/main)
**Free & open source AI video platform** with 3 tools in one: **Clip Generator**, **AI Shorts (UGC videos with AI actors)**, and **YouTube Studio**. Self-hosted with Docker. No watermarks, no limits.
https://github.com/user-attachments/assets/b45fa983-16b4-48b5-ac5b-a267836b9ad9
### Video Tutorial: How it works
[![OpenShorts Tutorial](https://img.youtube.com/vi/xlyjD1qCaX0/maxresdefault.jpg)](https://www.youtube.com/watch?v=xlyjD1qCaX0 "Click to watch the video on YouTube")
*Click the image above to watch the full walkthrough.*
---
## 3 Tools in 1 Platform
### 1. Clip Generator
Turn your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready 9:16 shorts for TikTok, Instagram Reels, and YouTube Shorts.
![Clip Results](screenshots/clip-results.png)
### 2. AI Shorts (UGC Video Creator)
Generate marketing videos with AI actors for **any product or business**. No camera, no studio, no influencer budget. Just describe your product or paste a URL.
![AI Shorts Setup](screenshots/ai-shorts.png)
- **Two cost modes**: Low Cost (~$0.65/video) and Premium (~$2/video)
- Works for any business: SaaS, restaurants, e-commerce, coaching, local businesses
- AI-generated actors with lip-sync, voiceover, b-roll, and TikTok-style subtitles
- Choose from a shared avatar gallery or upload your own photo
- Publish directly to TikTok, Instagram, and YouTube
### 3. YouTube Studio
Complete free AI YouTube toolkit: thumbnails, titles, descriptions, and direct publishing.
![YouTube Studio](screenshots/youtube-studio.png)
- AI thumbnail generator with face overlay
- 10 viral title suggestions with refinement chat
- Auto-generated descriptions with chapter timestamps
- One-click publish to YouTube
### UGC Video Gallery
All generated videos and avatars are saved to a public gallery with SEO pages for each video.
![UGC Gallery](screenshots/ugc-gallery.png)
- Public gallery page with hover-to-play (`/gallery`)
- Individual SEO video pages with og:video meta tags (`/video/{id}`)
- JSON-LD structured data for search engines
- Avatar gallery with prompt history
---
## Key Features
### Clip Generator
- **Viral Moment Detection**: Google Gemini 3.0 Flash analyzes transcripts and scene boundaries to detect 3-15 high-potential moments
- **Smart 9:16 Cropping**: Dual-mode AI reframing — TRACK mode (MediaPipe + YOLOv8 face tracking) and GENERAL mode (blurred background)
- **Auto Subtitles**: faster-whisper with word-level timestamps, styled and burned into clips
- **AI Voice Dubbing**: ElevenLabs integration for 30+ languages with voice cloning
- **Hook Text Overlays**: AI-generated attention-grabbing text overlays
- **AI Video Effects**: Gemini-generated FFmpeg filters for professional effects
### AI Shorts Pipeline
1. **Analyze**: Scrape website URL + web research, or generate from manual description
2. **Script**: AI writes viral scripts (hook - problem - solution - CTA format)
3. **Actor**: Generate AI actors with Flux 2 Pro or select from shared gallery
4. **Voice**: ElevenLabs TTS voiceover (English/Spanish, male/female)
5. **Video**: Talking head generation (Hailuo 2.3 Fast img2video + VEED Lipsync)
6. **B-roll**: AI-generated visuals with Ken Burns effect
7. **Composite**: FFmpeg final assembly with subtitles and hook overlays
8. **Publish**: Direct posting to TikTok, Instagram Reels, YouTube Shorts via Upload-Post
### YouTube Studio
- AI-powered title generation with 10 viral options
- Interactive refinement chat for titles
- AI thumbnail generation with custom face + background
- Auto descriptions with chapter timestamps from Whisper transcript
- Direct YouTube publishing via Upload-Post
### Social Auto-Publishing
- **One-click posting** to TikTok, Instagram Reels, and YouTube Shorts simultaneously
- **Schedule uploads** for any date and time — plan your content calendar and let OpenShorts publish automatically
- **Multi-platform distribution** — publish to all your social networks at once from a single interface
- Upload-Post integration with async uploads
### Infrastructure
- S3 cloud backup (private bucket for clips, public bucket for gallery/avatars)
- SEO gallery pages served by FastAPI with JSON-LD structured data
- Shared avatar gallery across all users
- Async job queue with configurable concurrency
---
## Who Is This For?
- **Content creators** — Turn long videos into shorts automatically, publish to all platforms at once
- **Marketing agencies** — Generate UGC videos for clients at scale, no actors or studios needed
- **SaaS founders** — Create product demos and marketing shorts from just a URL
- **E-commerce brands** — Product videos with AI actors for TikTok Shop, Instagram, YouTube
- **Local businesses** — Restaurants, gyms, real estate, coaching — affordable video marketing
- **Developers** — Self-host, customize the pipeline, integrate via API
---
## AI Shorts Showcase
Videos generated with OpenShorts AI Shorts — no camera, no studio, no actors:
| | | |
|:---:|:---:|:---:|
| [![Biohacking for Investors](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/cdceec1b/actor.png)](https://openshorts.app/video/cdceec1b) | [![Secret Weapon for Devs](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/d3a80b6b/actor.png)](https://openshorts.app/video/d3a80b6b) | [![El Secreto de los Agentes de IA](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/8ab7de92/actor.png)](https://openshorts.app/video/8ab7de92) |
| **Biohacking for Investors** · LOW COST | **Secret Weapon for Devs** · LOW COST | **El Secreto de los Agentes de IA** · PREMIUM |
> Browse all videos at [openshorts.app/gallery](https://openshorts.app/gallery)
---
## OpenShorts vs Competitors
| Feature | OpenShorts | Opus Clip | CapCut | Vizard | Klap | Descript |
|---------|:---:|:---:|:---:|:---:|:---:|:---:|
| **Price** | **Free** | $15-29/mo | $8/mo | $15-20/mo | $23-63/mo | $24-65/mo |
| **Self-hosted** | **Yes** | No | No | No | No | No |
| **Open source** | **Yes** | No | No | No | No | No |
| **Watermark** | **Never** | Free tier | Some | Free tier | Free tier | Free tier |
| **Upload limits** | **None** | 10-30GB | Credit-based | 60min-10hr | 10-100 vids/mo | 60min-40hr |
| **AI clip detection** | Yes | Yes | Yes | Yes | Yes | Yes |
| **Smart 9:16 reframing** | Yes | Yes | Yes | Yes | Yes | No |
| **Auto subtitles** | Yes | Yes | Yes | Yes | Yes | Yes |
| **Voice dubbing (30+ langs)** | Yes | No | Pro only | No | Pro only | Business only |
| **AI UGC actors** | **Yes** | No | No | No | No | No |
| **AI video effects** | Yes | No | Yes | No | No | No |
| **Hook text overlays** | Yes | No | No | No | No | No |
| **YouTube Studio (titles, thumbnails)** | **Yes** | No | No | No | No | No |
| **Social auto-publishing** | Yes | Pro only | TikTok only | Paid only | Paid only | No |
| **Schedule uploads** | Yes | Pro only | No | Paid only | Paid only | No |
| **Data privacy** | **Your server** | Their cloud | Their cloud | Their cloud | Their cloud | Their cloud |
---
## How Much Does It Cost?
OpenShorts is free. You only pay for the AI APIs you use — and most have generous free tiers:
| Service | Free Tier | Paid Cost | Used For |
|---------|-----------|-----------|----------|
| **Google Gemini** | Free trial with generous limits | < $0.01 per 10-min video | Viral moment detection, script generation, web research |
| **fal.ai** | Pay-per-use | ~$0.50-1.50 per AI Short | Actor generation, talking head video, lip-sync |
| **ElevenLabs** | Free tier available | Pay-per-use | Voiceover, voice dubbing |
| **Upload-Post** | **10 free uploads/month** to all networks (no credit card) | Pay-per-use | Auto-publishing to TikTok, Instagram, YouTube |
| **AWS S3** | Optional | ~$0.023/GB | Cloud backup for clips and gallery |
**Bottom line:** You can clip videos for practically free with Gemini, and publish 10 videos/month to all social networks at zero cost with Upload-Post.
---
## Requirements
- **Docker & Docker Compose**
- **Google Gemini API Key** ([Free — get it here](https://aistudio.google.com/app/apikey)) — required for all AI features
- **fal.ai API Key** ([Pay-per-use](https://fal.ai)) — required for AI Shorts (actor generation, video, lip-sync)
- **ElevenLabs API Key** ([Free tier](https://elevenlabs.io)) — required for voiceover/dubbing
- **Upload-Post API Key** ([free tier](https://upload-post.com)) — required for direct social posting
---
## Getting Started
### 1. Clone
```bash
git clone https://github.com/your-username/OpenShorts.git
cd OpenShorts
```
### 2. Configure (optional)
```bash
cp .env.example .env
# Edit .env with your AWS keys for S3 backup
```
### 3. Launch
```bash
docker compose up --build
```
### 4. Open Dashboard
Navigate to **`http://localhost:5175`**
1. Go to **Settings** and enter your API keys (Gemini, fal.ai, ElevenLabs, Upload-Post)
2. **Clip Generator**: Upload a long-form video to generate viral shorts
3. **AI Shorts**: Describe your product or paste a URL to generate UGC marketing videos
4. **YouTube Studio**: Generate thumbnails, titles, and descriptions for YouTube
5. **UGC Gallery**: Browse all generated videos and avatars
---
## Technical Pipeline
### Clip Generator
1. **Ingest** — Local video upload (or self-hosted URL ingest via yt-dlp)
2. **Transcribe** — faster-whisper with word-level timestamps
3. **Detect** — PySceneDetect for scene boundaries
4. **Analyze** — Gemini identifies 3-15 viral moments (15-60s each)
5. **Extract** — FFmpeg precise clip cutting
6. **Reframe** — AI vertical cropping with subject tracking
7. **Effects** — Subtitles, hooks, AI video effects
8. **Publish** — S3 backup + Upload-Post social distribution
### AI Shorts
1. **Analyze** — Website scraping + Gemini web research (or manual description)
2. **Script** — Gemini generates viral scripts with segments
3. **Actor** — Flux 2 Pro portrait generation (or gallery/upload)
4. **Voice** — ElevenLabs TTS voiceover
5. **Video** — Hailuo 2.3 Fast img2video + VEED Lipsync (Low Cost) or Kling Avatar v2 (Premium)
6. **B-roll** — Flux 2 Pro image generation + Ken Burns effect
7. **Composite** — FFmpeg assembly with ASS subtitles and hook overlays
8. **Gallery** — Upload to public S3 with metadata for SEO pages
9. **Publish** — Upload-Post to TikTok, Instagram, YouTube
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx |
| Frontend | React 18, Vite 4, Tailwind CSS 3.4 |
| AI APIs | Google Gemini, fal.ai (Flux, Hailuo, VEED, Kling), ElevenLabs |
| Infrastructure | Docker + Docker Compose, AWS S3 |
| Publishing | Upload-Post API (TikTok, Instagram, YouTube) |
---
## Environment Variables
**Server-side (.env):**
| Variable | Description |
|----------|------------|
| `AWS_ACCESS_KEY_ID` | AWS access key for S3 |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key |
| `AWS_REGION` | AWS region (default: us-east-1) |
| `AWS_S3_BUCKET` | Private bucket for clip backup |
| `AWS_S3_PUBLIC_BUCKET` | Public bucket for gallery/avatars |
| `MAX_CONCURRENT_JOBS` | Concurrent processing limit (default: 5) |
**Client-side (encrypted in localStorage):**
| Key | Description |
|-----|------------|
| `GEMINI_API_KEY` | Google Gemini — required |
| `FAL_KEY` | fal.ai — required for AI Shorts |
| `ELEVENLABS_API_KEY` | ElevenLabs — required for voiceover/dubbing |
| `UPLOAD_POST_API_KEY` | Upload-Post — required, for social posting |
---
## Security & Performance
- **Non-Root Execution**: Containers run as dedicated `appuser`
- **Concurrency Control**: Semaphore-based job queue (`MAX_CONCURRENT_JOBS`)
- **Auto-Cleanup**: Automatic purging of old jobs (1h retention)
- **Encrypted Keys**: API keys encrypted client-side, never stored server-side
- **Upload Validation**: Image uploads validated for format and minimum size
- **File Limits**: 2GB upload limit protection
---
## Social Media Setup (Upload-Post)
1. **Register**: [app.upload-post.com/login](https://app.upload-post.com/login)
2. **Create Profile**: Go to [Manage Users](https://app.upload-post.com/manage-users)
3. **Connect Accounts**: Link TikTok, Instagram, and/or YouTube
4. **Get API Key**: Navigate to [API Keys](https://app.upload-post.com/api-keys)
5. **Use in OpenShorts**: Paste the key in Settings
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=mutonby/openshorts&type=Date)](https://star-history.com/#mutonby/openshorts&Date)
## Contributions
Contributions are welcome! Whether it's adding new AI models, improving the lip-sync pipeline, or building new features — feel free to open a PR.
## License
MIT License. OpenShorts is yours to use, modify, and scale.
+2256
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.config
.config/*
+13
View File
@@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+194
View File
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo-openshorts.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary SEO Meta Tags -->
<title>OpenShorts - Free Open Source Clip Generator & AI UGC Video Creator | TikTok, Reels & Shorts</title>
<meta name="description" content="Free open source clip generator & AI UGC video creator. Turn your long-form videos into viral shorts or generate marketing videos with AI actors for any business. From $0.65/video." />
<meta name="keywords"
content="free clip generator, open source clip generator, AI clip generator, free video clip generator, AI video clipping tool, AI short video generator, vertical video maker, open source video editor, YouTube Shorts maker, Instagram Reels creator, video repurposing tool, AI viral moment detection, auto subtitle generator, AI voice dubbing, Opus Clip alternative free, free YouTube thumbnail generator, AI YouTube title generator, free YouTube description generator" />
<meta name="author" content="OpenShorts" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<link rel="canonical" href="https://openshorts.app" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://openshorts.app" />
<meta property="og:title"
content="OpenShorts - Free Clip Generator & AI UGC Video Creator" />
<meta property="og:description"
content="3 free tools in 1: clip generator, AI UGC video creator with AI actors, and YouTube Studio. Turn videos into viral shorts or generate marketing videos for any business. Open source, self-hosted." />
<meta property="og:image" content="https://openshorts.app/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:site_name" content="OpenShorts" />
<meta property="og:locale" content="en_US" />
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://openshorts.app" />
<meta name="twitter:title"
content="OpenShorts - Free Clip Generator & AI UGC Video Creator" />
<meta name="twitter:description"
content="3 free tools in 1: clip generator, AI UGC video creator with AI actors, and YouTube Studio. Open source, self-hosted, no watermarks." />
<meta name="twitter:image" content="https://openshorts.app/og-image.png" />
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "OpenShorts - Free Open Source Clip Generator",
"description": "Free open source clip generator that turns your long-form videos into viral TikTok, Instagram Reels & YouTube Shorts automatically with AI-powered viral moment detection, smart cropping, and a free AI YouTube thumbnail studio.",
"url": "https://openshorts.app",
"datePublished": "2024-06-01",
"dateModified": "2026-03-20",
"inLanguage": "en-US",
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": ["h1", ".hero-description", ".faq-answer"]
}
},
{
"@type": "SoftwareApplication",
"name": "OpenShorts",
"alternateName": "OpenShorts Free Clip Generator",
"description": "Free open source AI clip generator. Transforms your long-form videos into viral-ready short clips for TikTok, Instagram Reels, and YouTube Shorts using Google Gemini AI. Includes free AI YouTube thumbnail generator, title suggestions, description writer, and direct YouTube publishing.",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Cross-platform (Docker)",
"url": "https://openshorts.app",
"downloadUrl": "https://github.com/mutonby/openshorts",
"softwareVersion": "2.0",
"screenshot": "https://openshorts.app/og-image.png",
"featureList": [
"Free AI clip generator with viral moment detection",
"Open source clip generator — self-hosted with Docker",
"AI viral moment detection with Google Gemini 3.0 Flash",
"Smart 9:16 vertical cropping with face tracking",
"Automatic subtitle generation with word-level timestamps",
"AI voice dubbing in 30+ languages via ElevenLabs",
"Local video file upload support",
"Hook text overlays with styled fonts",
"AI-generated video effects via FFmpeg",
"Direct social media posting",
"Free AI YouTube thumbnail generator",
"Free AI YouTube title generator with 10 viral suggestions",
"Free AI YouTube description generator with chapter timestamps",
"YouTube Studio: thumbnails, titles, descriptions & direct publishing — all free",
"AI UGC video creator with AI actors and lip-sync",
"Generate marketing videos for any business from $0.65",
"Shared AI avatar gallery",
"Public video gallery with SEO pages",
"S3 cloud backup",
"Self-hosted Docker deployment"
],
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
},
"author": {
"@type": "Organization",
"name": "OpenShorts",
"url": "https://openshorts.app"
}
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is OpenShorts?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OpenShorts is a free, open source AI clip generator that automatically transforms your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready short clips (9:16 vertical format) for TikTok, Instagram Reels, and YouTube Shorts. It uses Google Gemini 3.0 Flash AI to detect the most engaging viral moments, then applies smart cropping with MediaPipe face tracking, automatic subtitles via faster-whisper, and optional AI voice dubbing in over 30 languages via ElevenLabs. According to HubSpot's 2025 State of Marketing report, short-form video delivers the highest ROI of any content format."
}
},
{
"@type": "Question",
"name": "How does OpenShorts detect viral moments in videos?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OpenShorts uses a multi-step AI pipeline: first, it transcribes the video with word-level timestamps using faster-whisper. Then, PySceneDetect identifies scene boundaries. Finally, Google Gemini 3.0 Flash AI analyzes the transcript and scene data to identify the 3 to 15 most engaging moments (15-60 seconds each), scoring them for viral potential based on emotional impact, hook strength, and shareability."
}
},
{
"@type": "Question",
"name": "Is OpenShorts really free? What's the catch?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OpenShorts is 100% free and open source. You self-host it using Docker on your own machine or server. It uses three external APIs — all with free tiers. Google Gemini API (required) powers AI analysis, viral moment detection, and thumbnail generation — free tier includes 1,500 requests per day. ElevenLabs API (optional) enables AI voice dubbing in 30+ languages — free tier included. Upload-Post API (optional) allows direct publishing to YouTube, TikTok, and Instagram — also free tier. There are no watermarks, no usage limits, and no subscription fees."
}
},
{
"@type": "Question",
"name": "How does the AI vertical cropping work?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OpenShorts offers two intelligent cropping modes. TRACK mode uses MediaPipe face detection with YOLOv8 fallback to follow a single subject with stabilized 'Heavy Tripod' camera movement, preventing jitter. GENERAL mode handles group shots and landscapes by creating a blurred background layout that preserves the full width. A SpeakerTracker prevents rapid switching between subjects for smooth, professional results."
}
},
{
"@type": "Question",
"name": "Is there a free alternative to Opus Clip?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. OpenShorts is a free, open source alternative to Opus Clip that you can self-host. Unlike Opus Clip's $15-228/month pricing, OpenShorts is completely free with no watermarks or usage limits. It offers comparable AI viral moment detection powered by Google Gemini, smart vertical cropping with face tracking, automatic subtitles, and AI voice dubbing — all running on your own infrastructure with full data privacy."
}
},
{
"@type": "Question",
"name": "Can OpenShorts add subtitles and translate videos?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. OpenShorts automatically generates subtitles using faster-whisper with word-level timestamps and burns them into the video using FFmpeg. For translation, it integrates with ElevenLabs AI dubbing to translate and dub video audio into over 30 languages, preserving the original speaker's voice characteristics. The dubbed video is automatically re-transcribed and subtitled in the new language."
}
},
{
"@type": "Question",
"name": "Can OpenShorts generate YouTube thumbnails and titles for free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. OpenShorts includes a free AI YouTube thumbnail generator, a free AI YouTube title generator, and a free AI YouTube description generator — all powered by Google Gemini 3.0 Flash. Upload your video and the AI suggests 10 viral title options with an interactive refinement chat. Then it generates multiple thumbnail designs using AI image generation — upload a face photo and background for personalized results. The studio also auto-generates YouTube descriptions with chapter timestamps and lets you publish directly to YouTube. Everything is 100% free with the Gemini free tier."
}
},
{
"@type": "Question",
"name": "Is there a free open source clip generator?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes — OpenShorts is a 100% free, open source clip generator. Unlike paid clip generators like Opus Clip ($15-228/month) or Kapwing ($24-79/month), OpenShorts lets you generate unlimited clips with no watermarks, no usage limits, and no subscription fees. It also includes a free AI YouTube thumbnail generator, free AI YouTube title generator, and free AI YouTube description generator — features that other clip generators charge extra for. You self-host it with Docker on your own machine for full privacy and control."
}
}
]
},
{
"@type": "Organization",
"name": "OpenShorts",
"url": "https://openshorts.app",
"logo": "https://openshorts.app/logo-openshorts.png",
"sameAs": [
"https://github.com/mutonby/openshorts"
]
}
]
}
</script>
<!-- Analytics -->
<script defer data-domain="openshorts.app" src="https://analytics.fotoexamen.com/js/script.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "openshorts-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@remotion/media": "^4.0.447",
"@remotion/media-utils": "^4.0.447",
"@remotion/player": "^4.0.447",
"@remotion/web-renderer": "^4.0.447",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.447",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.23",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^4.5.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+33
View File
@@ -0,0 +1,33 @@
# robots.txt for OpenShorts
# Allow all search engines and AI bots to crawl the site
User-agent: *
Allow: /
# Explicitly allow AI search engine bots
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: Google-Extended
Allow: /
# Sitemap
Sitemap: https://openshorts.app/sitemap.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://openshorts.app/</loc>
<lastmod>2026-03-07</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
import React from 'react';
import { Sparkles, Zap, Globe, FileVideo, Subtitles, Youtube, Instagram, Shield, Github, ArrowRight, Play, Check, ChevronDown, Monitor, Cpu, Languages, Type, Upload, Scissors } from 'lucide-react';
const TikTokIcon = ({ size = 16, className = "" }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M19.589 6.686a4.793 4.793 0 0 1-3.77-4.245V2h-3.445v13.672a2.896 2.896 0 0 1-5.201 1.743l-.002-.001.002.001a2.895 2.895 0 0 1 3.183-4.51v-3.5a6.329 6.329 0 0 0-5.394 10.692 6.33 6.33 0 0 0 10.857-4.424V8.687a8.182 8.182 0 0 0 4.773 1.526V6.79a4.831 4.831 0 0 1-1.003-.104z" />
</svg>
);
const FeatureCard = ({ icon: Icon, title, description }) => (
<div className="group bg-surface/50 backdrop-blur-xl border border-white/10 rounded-2xl p-6 hover:border-primary/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<Icon size={24} className="text-primary" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">{description}</p>
</div>
);
const StepCard = ({ number, title, description }) => (
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 border border-primary/30 flex items-center justify-center text-primary font-bold text-sm">
{number}
</div>
<div>
<h3 className="text-white font-semibold mb-1">{title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">{description}</p>
</div>
</div>
);
const ComparisonRow = ({ feature, openshorts, opusclip, kapwing }) => (
<tr className="border-b border-white/5">
<td className="py-3 px-4 text-sm text-zinc-300">{feature}</td>
<td className="py-3 px-4 text-center">{openshorts}</td>
<td className="py-3 px-4 text-center">{opusclip}</td>
<td className="py-3 px-4 text-center">{kapwing}</td>
</tr>
);
const FAQItem = ({ question, answer, isOpen, onClick }) => (
<div className="border border-white/10 rounded-xl overflow-hidden">
<button
onClick={onClick}
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-white/5 transition-colors"
>
<span className="text-white font-medium pr-4">{question}</span>
<ChevronDown size={18} className={`text-zinc-400 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="px-6 pb-5">
<p className="faq-answer text-zinc-400 text-sm leading-relaxed">{answer}</p>
</div>
)}
</div>
);
export default function Landing({ onLaunchApp }) {
const [openFaq, setOpenFaq] = React.useState(null);
const features = [
{
icon: Sparkles,
title: "AI Viral Moment Detection",
description: "Google Gemini 3.0 Flash analyzes your video transcript and scene boundaries to detect the 3-15 most engaging moments. Each clip is scored for viral potential based on emotional impact, hook strength, and shareability — similar to how TikTok's algorithm ranks content for the For You page."
},
{
icon: Scissors,
title: "Smart 9:16 Vertical Cropping",
description: "Dual-mode AI reframing: TRACK mode follows subjects with MediaPipe face detection + YOLOv8 fallback. GENERAL mode creates blurred backgrounds for group shots and landscapes."
},
{
icon: Subtitles,
title: "Automatic Subtitle Generation",
description: "Powered by faster-whisper with word-level timestamps. According to Verizon Media research, 80% of viewers are more likely to watch a video to completion when captions are available. Subtitles are auto-generated, styled, and burned into your clips."
},
{
icon: Languages,
title: "AI Voice Dubbing in 30+ Languages",
description: "ElevenLabs AI integration translates and dubs your video audio while preserving the original speaker's voice characteristics. According to CSA Research, 76% of consumers prefer content in their native language — dubbing unlocks global audiences."
},
{
icon: Type,
title: "Hook Text Overlays",
description: "Add attention-grabbing text overlays with styled fonts. AI-generated hook titles capture viewers in the first 3 seconds — critical for TikTok and Reels engagement."
},
{
icon: Zap,
title: "AI Video Effects",
description: "Google Gemini generates dynamic FFmpeg filters for professional video effects — color grading, transitions, and visual enhancements applied automatically."
},
{
icon: Upload,
title: "Local Video Upload",
description: "Upload your long-form videos — podcasts, webinars, livestreams, vlogs — at full original resolution and audio quality. Process content you own or have rights to."
},
{
icon: Shield,
title: "100% Self-Hosted & Private",
description: "Deploy with Docker on your own machine. Your videos never leave your infrastructure. API keys are encrypted client-side and never stored on the server."
},
{
icon: Monitor,
title: "Free AI YouTube Studio",
description: "Free AI YouTube thumbnail generator, AI title suggestions (10 viral options with refinement chat), and auto-generated descriptions with chapter timestamps — all free. Upload a face photo for personalized thumbnails. Publish directly to YouTube from one workflow."
},
{
icon: Globe,
title: "Direct Social Publishing",
description: "Post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard. Async uploads with progress tracking and S3 cloud backup."
},
{
icon: Sparkles,
title: "AI UGC Video Generator",
description: "Generate marketing videos with AI actors for any product or business. Paste a URL or describe your product — AI writes the script, generates a realistic avatar with lip-sync, adds b-roll, subtitles, and hook overlays. From $0.65/video."
},
{
icon: FileVideo,
title: "AI Actors & Lip-Sync",
description: "Choose from a gallery of AI-generated actors or upload your own photo. The pipeline generates a talking head video with natural movement and lip-synced voiceover in English or Spanish. Two modes: Low Cost ($0.65) and Premium ($2.00)."
}
];
const steps = [
{ title: "Upload a Long-Form Video", description: "Drop any video file you own — podcasts, webinars, livestreams, interviews. OpenShorts supports all common formats and resolutions." },
{ title: "AI Detects the Best Viral Moments", description: "Google Gemini 3.0 Flash transcribes, analyzes scene boundaries, and identifies 3-15 high-potential clips of 15-60 seconds each." },
{ title: "Smart Cropping to Vertical 9:16", description: "AI reframes each clip to vertical format with face tracking. Subjects stay centered with stabilized camera movement — no manual positioning." },
{ title: "Add Subtitles, Hooks & Effects", description: "Auto-generate styled subtitles, add hook text overlays, and apply AI video effects. Optionally dub into 30+ languages." },
{ title: "Download or Post to Social Media", description: "Export your viral-ready clips or post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard." }
];
const faqs = [
{
question: "What is OpenShorts and how does it work?",
answer: "OpenShorts is a free, open source AI clip generator that transforms your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready short clips in 9:16 vertical format. It uses a multi-step AI pipeline: faster-whisper for transcription with word-level timestamps, PySceneDetect for scene boundary detection, and Google Gemini 3.0 Flash AI for identifying the most engaging viral moments. According to HubSpot's 2025 State of Marketing report, short-form video delivers the highest ROI of any content format, and repurposing long-form content into shorts increases total reach by up to 300%."
},
{
question: "Is OpenShorts really free? What's the catch?",
answer: "OpenShorts is 100% free and open source. You self-host it using Docker on your own machine or server. It uses three external APIs — all with free tiers. Google Gemini API (required) powers the AI analysis, viral moment detection, and thumbnail generation — its free tier includes 1,500 requests per day. ElevenLabs API (optional) enables AI voice dubbing in 30+ languages — free tier included. Upload-Post API (optional) is a social media API that allows direct publishing to YouTube, TikTok, and Instagram — 10 free uploads/month, no credit card required. There are no watermarks, no usage limits, no monthly subscriptions, and no per-video fees — unlike Opus Clip ($15-228/month) or Kapwing ($24-79/month)."
},
{
question: "How does OpenShorts compare to Opus Clip?",
answer: "OpenShorts is a free, self-hosted alternative to Opus Clip. Both offer AI viral moment detection and smart vertical cropping. Key differences: OpenShorts is completely free vs Opus Clip's $15-228/month pricing. OpenShorts runs on your infrastructure (full data privacy) vs cloud-only. OpenShorts uses Google Gemini 3.0 Flash for AI analysis vs Opus Clip's proprietary model. OpenShorts adds AI voice dubbing in 30+ languages, AI-generated video effects, and hook text overlays. The trade-off is that OpenShorts requires Docker self-hosting, while Opus Clip is a ready-to-use cloud service."
},
{
question: "How do I turn a long-form video into TikTok or Reels clips?",
answer: "Upload your long-form video into OpenShorts, enter your free Gemini API key, and click Process. The AI transcribes it with faster-whisper, detects the best viral moments using Google Gemini 3.0 Flash, and crops them to 9:16 vertical format with MediaPipe face tracking. According to Wyzowl's 2025 Video Marketing Statistics report, 91% of businesses use video as a marketing tool, and repurposed short-form clips drive 2.5x more engagement than original content."
},
{
question: "What AI does OpenShorts use for viral moment detection?",
answer: "OpenShorts uses Google Gemini 3.0 Flash, Google's latest multimodal AI model, for viral moment detection and title generation. The AI receives the full video transcript with timestamps, scene boundary data from PySceneDetect, and analyzes engagement patterns to identify the 3-15 most shareable moments. Each clip is scored based on emotional impact, hook strength, and viral potential — similar to how platforms like TikTok and YouTube rank content."
},
{
question: "Can OpenShorts translate and dub videos into other languages?",
answer: "Yes. OpenShorts integrates with ElevenLabs AI dubbing to translate your video audio into over 30 languages while preserving the original speaker's voice characteristics. After dubbing, the system automatically re-transcribes the new audio and generates subtitles in the target language. This makes it easy to repurpose content for global audiences — studies show that dubbed content receives 2-3x more engagement in non-English markets."
},
{
question: "How does the smart vertical cropping work?",
answer: "OpenShorts offers two intelligent cropping modes for converting 16:9 horizontal video to 9:16 vertical format. TRACK mode uses MediaPipe face detection with YOLOv8 as fallback to follow a single subject with 'Heavy Tripod' stabilization — the camera moves smoothly like a professional cameraman. GENERAL mode handles group shots and landscapes by creating a blurred background layout. A SpeakerTracker prevents rapid switching between subjects and handles temporary occlusions for smooth results."
},
{
question: "Can OpenShorts generate YouTube thumbnails and titles for free?",
answer: "Yes. OpenShorts includes a free AI YouTube thumbnail generator, a free AI YouTube title generator, and a free AI YouTube description generator — all powered by Google Gemini 3.0 Flash. Upload your video and the AI suggests 10 viral title options with an interactive refinement chat. Then it generates multiple thumbnail designs using AI image generation — upload a face photo and background image for personalized results. The studio also auto-generates YouTube descriptions with chapter timestamps and lets you publish directly to YouTube. Everything is 100% free with the Gemini free tier."
},
{
question: "What are the system requirements to run OpenShorts?",
answer: "OpenShorts runs on any system with Docker installed. The recommended setup is 8GB+ RAM and a modern multi-core CPU. GPU acceleration (NVIDIA CUDA) is optional but speeds up video processing significantly. The Docker Compose setup handles all dependencies automatically — Python 3.11, FFmpeg, YOLOv8, MediaPipe, faster-whisper, and the React dashboard. It works on Linux, macOS, and Windows (via WSL2/Docker Desktop)."
},
{
question: "Is there a free open source clip generator?",
answer: "Yes — OpenShorts is a 100% free, open source clip generator. Unlike paid clip generators like Opus Clip ($15-228/month) or Kapwing ($24-79/month), OpenShorts lets you generate unlimited clips with no watermarks, no usage limits, and no subscription fees. It also includes a free AI YouTube thumbnail generator, free AI YouTube title generator, and free AI YouTube description generator — features that other clip generators charge extra for. You self-host it with Docker on your own machine for full privacy and control."
},
{
question: "What is the AI UGC Video Generator?",
answer: "OpenShorts includes an AI UGC (User Generated Content) video creator that generates marketing videos with AI actors for any product or business. You describe your product or paste a website URL — the AI writes a viral script, generates a realistic AI actor with lip-synced voiceover, adds b-roll visuals, TikTok-style subtitles, and hook text overlays. The result is a ready-to-post vertical video for TikTok, Instagram Reels, or YouTube Shorts. Two cost modes: Low Cost (~$0.65/video using Hailuo + VEED Lipsync) and Premium (~$2/video using Kling Avatar v2)."
},
{
question: "How much does it cost to generate an AI UGC video?",
answer: "OpenShorts itself is free, but the AI Shorts feature uses external APIs (fal.ai for video generation, ElevenLabs for voiceover) that charge per use. Low Cost mode costs approximately $0.65 per video (Flux image $0.05 + ElevenLabs voice $0.10 + Hailuo img2video $0.19 + VEED Lipsync $0.20 + b-roll $0.10). Premium mode costs approximately $2.00 per video using Kling Avatar v2 for higher quality. Both modes are significantly cheaper than hiring UGC creators ($50-500 per video) or using platforms like HeyGen ($24-180/month)."
},
{
question: "Can I use the AI UGC Video Generator for any type of business?",
answer: "Yes. The AI Shorts generator works for any product, service, or business — not just SaaS. You can use it for restaurants, e-commerce stores, coaching services, local businesses, personal brands, apps, and more. Just describe your business in the text field (e.g. 'Artisan pizza restaurant in Madrid, wood-fired oven, home delivery') or paste your website URL, and the AI generates viral marketing scripts tailored to your business."
}
];
const checkIcon = <Check size={16} className="text-green-400 mx-auto" />;
const xIcon = <span className="text-zinc-500 text-sm">Paid</span>;
return (
<div className="min-h-screen bg-background text-white">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-background/80 backdrop-blur-xl border-b border-white/5">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<img src="/logo-openshorts.png" alt="OpenShorts logo" className="w-8 h-8" />
<span className="text-lg font-bold">OpenShorts</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm text-zinc-400">
<a href="#features" className="hover:text-white transition-colors">Features</a>
<a href="#how-it-works" className="hover:text-white transition-colors">How It Works</a>
<a href="#comparison" className="hover:text-white transition-colors">Comparison</a>
<a href="#faq" className="hover:text-white transition-colors">FAQ</a>
</div>
<div className="flex items-center gap-3">
<a
href="https://github.com/mutonby/openshorts"
target="_blank"
rel="noopener noreferrer"
className="hidden sm:flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
<Github size={18} />
<span>GitHub</span>
</a>
<button
onClick={onLaunchApp}
className="bg-primary hover:bg-blue-600 text-white px-5 py-2 rounded-xl text-sm font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20"
>
Launch App
</button>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="pt-32 pb-20 px-6">
<div className="max-w-5xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-full px-4 py-1.5 text-sm text-primary mb-8">
<Sparkles size={14} />
<span>Free & Open Source AI Clip Generator + UGC Video Creator</span>
</div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold leading-tight mb-6 tracking-tight">
Free Open Source
<span className="bg-gradient-to-r from-primary via-purple-400 to-pink-500 bg-clip-text text-transparent"> Clip Generator </span>
& AI UGC Video Creator
</h1>
<p className="hero-description text-lg md:text-xl text-zinc-400 max-w-3xl mx-auto mb-10 leading-relaxed">
Three tools in one. <strong className="text-white">Clip Generator:</strong> turn your long-form videos into viral shorts with AI moment detection, smart 9:16 crop, and auto subtitles. <strong className="text-white">AI Shorts:</strong> generate UGC marketing videos with AI actors and lip-sync for any business. <strong className="text-white">YouTube Studio:</strong> free AI thumbnail generator, 10 viral title suggestions with refinement chat, and auto descriptions with chapters. Self-hosted, open source, no limits.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
<button
onClick={onLaunchApp}
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-8 py-3.5 rounded-xl font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20 text-lg"
>
Get Started Free
<ArrowRight size={20} />
</button>
<a
href="https://github.com/mutonby/openshorts"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-white/5 border border-white/10 text-white px-8 py-3.5 rounded-xl font-medium transition-all hover:bg-white/10 text-lg"
>
<Github size={20} />
View on GitHub
</a>
</div>
{/* Platform Icons */}
<div className="flex items-center justify-center gap-6 text-zinc-500">
<span className="text-sm">Export to:</span>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-zinc-400">
<TikTokIcon size={18} />
<span className="text-sm">TikTok</span>
</div>
<div className="flex items-center gap-1.5 text-zinc-400">
<Instagram size={18} />
<span className="text-sm">Reels</span>
</div>
<div className="flex items-center gap-1.5 text-zinc-400">
<Youtube size={18} />
<span className="text-sm">Shorts</span>
</div>
</div>
</div>
</div>
</section>
{/* Stats Bar */}
<section className="border-y border-white/5 bg-surface/30">
<div className="max-w-5xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div>
<div className="text-3xl font-bold text-white">100%</div>
<div className="text-sm text-zinc-400 mt-1">Free & Open Source</div>
</div>
<div>
<div className="text-3xl font-bold text-white">3</div>
<div className="text-sm text-zinc-400 mt-1">Tools in One</div>
</div>
<div>
<div className="text-3xl font-bold text-white">30+</div>
<div className="text-sm text-zinc-400 mt-1">Dubbing Languages</div>
</div>
<div>
<div className="text-3xl font-bold text-white">$0</div>
<div className="text-sm text-zinc-400 mt-1">No Watermarks</div>
</div>
</div>
</section>
{/* 3 Tools in 1 Section */}
<section className="py-20 px-6">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">3 Free Tools in 1 Platform</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">Everything you need to create, optimize, and publish short-form video content all free and open source.</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-surface/50 border border-primary/20 rounded-2xl p-8 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<Scissors size={28} className="text-primary mb-4" />
<h3 className="text-xl font-bold text-white mb-2">Clip Generator</h3>
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Turn your long-form videos into viral-ready 9:16 shorts. AI detects the best moments, crops to vertical with face tracking, and adds subtitles automatically.</p>
<ul className="space-y-1.5">
{['AI viral moment detection', 'Smart face-tracking crop', 'Auto subtitles + hook overlays', 'AI dubbing in 30+ languages'].map((f, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
))}
</ul>
</div>
<div className="bg-surface/50 border border-violet-500/20 rounded-2xl p-8 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-violet-500/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<Sparkles size={28} className="text-violet-400 mb-4" />
<h3 className="text-xl font-bold text-white mb-2">AI Shorts</h3>
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Generate UGC marketing videos with AI actors for any product or business. No camera, no studio. Just describe your product and get a viral-ready video.</p>
<ul className="space-y-1.5">
{['AI actor generation + lip-sync', 'Script writing from URL or description', 'B-roll + TikTok-style subtitles', 'From $0.65 per video'].map((f, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
))}
</ul>
</div>
<div className="bg-surface/50 border border-pink-500/20 rounded-2xl p-8 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-pink-500/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<Monitor size={28} className="text-pink-400 mb-4" />
<h3 className="text-xl font-bold text-white mb-2">YouTube Studio</h3>
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Complete free AI YouTube toolkit. Generate thumbnails with your face, get 10 viral title suggestions with refinement chat, and auto-generate descriptions with timestamps.</p>
<ul className="space-y-1.5">
{['AI thumbnail generator (with face upload)', '10 viral title suggestions + chat', 'Auto descriptions with chapters', 'Direct publish to YouTube'].map((f, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
))}
</ul>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20 px-6">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Free AI Clip Generator + UGC Video Creator</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">Three tools in one: clip long videos into viral shorts, generate UGC marketing videos with AI actors, and a complete YouTube Studio for thumbnails, titles, and descriptions.</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
{features.map((feature, i) => (
<FeatureCard key={i} {...feature} />
))}
</div>
</div>
</section>
{/* API Keys Section */}
<section className="py-20 px-6 bg-surface/20">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">All APIs Have Free Tiers</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">OpenShorts uses three external APIs all with generous free tiers. Only Gemini is required. Your API keys are encrypted client-side and never stored on the server.</p>
</div>
<div className="grid md:grid-cols-3 gap-5">
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
<div className="absolute top-4 right-4 bg-primary/20 text-primary text-[10px] font-bold px-2 py-0.5 rounded-full border border-primary/30">REQUIRED</div>
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<Cpu size={24} className="text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">Google Gemini API</h3>
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier: 1,500 req/day</span>
<p className="text-zinc-400 text-sm leading-relaxed">Powers all AI features: viral moment detection, title generation, video effects, YouTube thumbnail creation, and description writing. The core engine of OpenShorts.</p>
</div>
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
<div className="absolute top-4 right-4 bg-zinc-700/50 text-zinc-400 text-[10px] font-bold px-2 py-0.5 rounded-full border border-zinc-600/30">OPTIONAL</div>
<div className="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-4">
<Languages size={24} className="text-purple-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">ElevenLabs API</h3>
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
<p className="text-zinc-400 text-sm leading-relaxed">Enables AI voice dubbing and translation in 30+ languages. Preserves the original speaker's voice while translating audio. Dubbed clips are auto-subtitled.</p>
</div>
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
<div className="absolute top-4 right-4 bg-zinc-700/50 text-zinc-400 text-[10px] font-bold px-2 py-0.5 rounded-full border border-zinc-600/30">OPTIONAL</div>
<div className="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-4">
<Globe size={24} className="text-pink-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">Upload-Post API</h3>
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
<p className="text-zinc-400 text-sm leading-relaxed">Enables direct publishing to YouTube, TikTok, and Instagram Reels from the dashboard. <a href="https://www.upload-post.com" target="_blank" rel="noopener noreferrer" className="text-pink-400 hover:text-pink-300 underline">Social media API</a> that lets you post your clips and thumbnails without leaving OpenShorts.</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-5 mt-5">
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
<div className="absolute top-4 right-4 bg-violet-700/50 text-violet-300 text-[10px] font-bold px-2 py-0.5 rounded-full border border-violet-500/30">AI SHORTS</div>
<div className="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-4">
<Zap size={24} className="text-violet-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">fal.ai API</h3>
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Pay-per-use from $0.04</span>
<p className="text-zinc-400 text-sm leading-relaxed">Powers AI Shorts: generates AI actor images (Flux), talking head videos (Hailuo/Kling), and lip-sync (VEED). Required only for the AI UGC video generator.</p>
</div>
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
<div className="absolute top-4 right-4 bg-violet-700/50 text-violet-300 text-[10px] font-bold px-2 py-0.5 rounded-full border border-violet-500/30">AI SHORTS</div>
<div className="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-4">
<Languages size={24} className="text-violet-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">ElevenLabs TTS</h3>
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
<p className="text-zinc-400 text-sm leading-relaxed">Generates natural voiceovers for AI Shorts from the script. Multiple voice options for male and female actors in English and Spanish.</p>
</div>
</div>
</div>
</section>
{/* How It Works Section */}
<section id="how-it-works" className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">How It Works</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">From a long-form video to viral-ready clips in 5 automated steps. The entire pipeline runs on your machine with AI doing the heavy lifting.</p>
</div>
<div className="space-y-8">
{steps.map((step, i) => (
<StepCard key={i} number={i + 1} {...step} />
))}
</div>
</div>
</section>
{/* Tech Stack */}
<section className="py-20 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Built with Proven Technology</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">OpenShorts combines industry-leading AI models and open source tools into a production-ready video processing pipeline.</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ name: "Google Gemini 3.0", desc: "AI Analysis" },
{ name: "faster-whisper", desc: "Transcription" },
{ name: "YOLOv8", desc: "Object Detection" },
{ name: "MediaPipe", desc: "Face Tracking" },
{ name: "FFmpeg", desc: "Video Processing" },
{ name: "ElevenLabs", desc: "Voice & TTS" },
{ name: "fal.ai", desc: "AI Video Gen" },
{ name: "React + Vite", desc: "Dashboard" },
{ name: "Docker", desc: "Deployment" }
].map((tech, i) => (
<div key={i} className="bg-surface/50 border border-white/10 rounded-xl p-4 text-center">
<div className="text-white font-medium text-sm">{tech.name}</div>
<div className="text-zinc-500 text-xs mt-1">{tech.desc}</div>
</div>
))}
</div>
</div>
</section>
{/* Comparison Table */}
<section id="comparison" className="py-20 px-6 bg-surface/20">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Free Clip Generator vs Paid Alternatives</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">Why pay $15-228/month for an AI clip generator when you can self-host the same capabilities for free? OpenShorts includes a free YouTube thumbnail generator, AI title suggestions, and auto descriptions features that paid tools charge extra for.</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="py-3 px-4 text-left text-sm text-zinc-400 font-medium">Feature</th>
<th className="py-3 px-4 text-center text-sm font-medium">
<span className="text-primary">OpenShorts</span>
</th>
<th className="py-3 px-4 text-center text-sm text-zinc-400 font-medium">Opus Clip</th>
<th className="py-3 px-4 text-center text-sm text-zinc-400 font-medium">Kapwing</th>
</tr>
</thead>
<tbody>
<ComparisonRow feature="Price" openshorts={<span className="text-green-400 font-semibold">$0 Free</span>} opusclip={xIcon} kapwing={xIcon} />
<ComparisonRow feature="AI Viral Moment Detection" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
<ComparisonRow feature="Smart Vertical Cropping" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
<ComparisonRow feature="Auto Subtitles" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
<ComparisonRow feature="AI Voice Dubbing (30+ langs)" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Limited</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
<ComparisonRow feature="AI Video Effects" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={checkIcon} />
<ComparisonRow feature="Hook Text Overlays" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
<ComparisonRow feature="Self-Hosted / Privacy" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Cloud only</span>} kapwing={<span className="text-zinc-500 text-sm">Cloud only</span>} />
<ComparisonRow feature="No Watermark" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Free tier only</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
<ComparisonRow feature="Open Source" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
<ComparisonRow feature="AI YouTube Thumbnail Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
<ComparisonRow feature="AI Title & Description Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Limited</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
<ComparisonRow feature="AI UGC Video Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
<ComparisonRow feature="AI Actors with Lip-Sync" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
<ComparisonRow feature="Usage Limits" openshorts={<span className="text-green-400 text-sm">Unlimited</span>} opusclip={<span className="text-zinc-500 text-sm">Per plan</span>} kapwing={<span className="text-zinc-500 text-sm">Per plan</span>} />
</tbody>
</table>
</div>
</div>
</section>
{/* Use Cases */}
<section className="py-20 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Who Uses OpenShorts?</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">Content creators, marketers, and agencies use OpenShorts to scale their short-form video production. According to HubSpot's 2025 report, short-form video is the #1 content format with the highest ROI.</p>
</div>
<div className="grid md:grid-cols-3 gap-5">
{[
{
title: "Content Creators",
description: "Repurpose your long-form videos into TikTok and Reels clips automatically. According to YouTube's Creator Insider data, channels that post Shorts alongside long-form videos see 20-30% more subscriber growth.",
icon: Youtube
},
{
title: "Social Media Managers",
description: "Scale short-form content production for multiple clients. According to Sprout Social's 2025 Index, 66% of consumers find short-form video the most engaging content type. Process videos in batch and publish directly from one dashboard.",
icon: Instagram
},
{
title: "Podcasters & Educators",
description: "Extract the most engaging moments from podcast episodes and educational content. Research by Headliner shows that podcast clips on social media increase episode downloads by 72% on average.",
icon: FileVideo
},
{
title: "Businesses & Brands",
description: "Generate UGC-style marketing videos for any product or business with AI actors. No camera, no studio, no influencer budget. Just describe your product and get a viral-ready video with lip-synced AI avatar, voiceover, b-roll, and subtitles — from $0.65 per video.",
icon: Sparkles
}
].map((useCase, i) => (
<div key={i} className="bg-surface/50 border border-white/10 rounded-2xl p-6">
<useCase.icon size={24} className="text-primary mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">{useCase.title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">{useCase.description}</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ Section */}
<section id="faq" className="py-20 px-6 bg-surface/20">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Frequently Asked Questions</h2>
<p className="text-zinc-400">Everything you need to know about OpenShorts, from setup to features.</p>
</div>
<div className="space-y-3">
{faqs.map((faq, i) => (
<FAQItem
key={i}
question={faq.question}
answer={faq.answer}
isOpen={openFaq === i}
onClick={() => setOpenFaq(openFaq === i ? null : i)}
/>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-6">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Start Creating Viral Videos for Free</h2>
<p className="text-zinc-400 mb-8 max-w-xl mx-auto">No sign-up, no credit card, no watermarks. Generate viral clips from long videos or create AI UGC marketing videos with AI actors for any business. Self-host with Docker.</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={onLaunchApp}
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-8 py-3.5 rounded-xl font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20 text-lg"
>
Launch OpenShorts
<ArrowRight size={20} />
</button>
<a
href="https://github.com/mutonby/openshorts"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm"
>
<Github size={18} />
Star on GitHub
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-white/5 py-10 px-6">
<div className="max-w-5xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<img src="/logo-openshorts.png" alt="OpenShorts" className="w-6 h-6" />
<span className="text-sm text-zinc-400">OpenShorts Free Open Source Clip Generator & AI UGC Video Creator</span>
</div>
<div className="flex items-center gap-6 text-sm text-zinc-500">
<a href="https://github.com/mutonby/openshorts" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">GitHub</a>
<a href="#features" className="hover:text-white transition-colors">Features</a>
<a href="#faq" className="hover:text-white transition-colors">FAQ</a>
<a href="#legal" className="hover:text-white transition-colors">Terms & Privacy</a>
</div>
</div>
</footer>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
const LAST_UPDATED = '2026-05-06';
const ISSUES_URL = 'https://github.com/mutonby/openshorts/issues';
function Section({ title, children }) {
return (
<section className="mb-7">
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
<div className="text-zinc-300 leading-relaxed space-y-2 text-sm">{children}</div>
</section>
);
}
export default function Legal() {
const handleBack = () => {
window.location.hash = '';
};
return (
<div className="min-h-screen bg-bg text-white">
<header className="border-b border-white/5 sticky top-0 bg-bg/95 backdrop-blur z-10">
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center">
<button
onClick={handleBack}
className="text-zinc-400 hover:text-white flex items-center gap-2 text-sm"
>
<ArrowLeft size={16} /> Back
</button>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-3xl md:text-4xl font-bold mb-2">Terms & Privacy</h1>
<p className="text-zinc-500 text-sm mb-10">Last updated: {LAST_UPDATED}</p>
<Section title="The short version">
<p>
OpenShorts is a free, open-source AI clip generator. There are no accounts, no payments, and we
do not persistently store the videos you upload or the clips we generate. By using the Service
you agree to the points below.
</p>
</Section>
<Section title="Service is provided as-is">
<p>
The Service is offered for free, on a best-effort basis, with no warranties of any kind and no
guarantee of uptime, accuracy, or fitness for any particular purpose. To the maximum extent
permitted by law, we are not liable for any damages arising from your use of the Service.
</p>
</Section>
<Section title="You are responsible for what you upload">
<p>
Before processing a video, you must affirmatively confirm via the checkbox in the upload
interface that you own the content or have the rights to process it. By doing so you
represent and warrant that:
</p>
<ul className="list-disc pl-6 space-y-1">
<li>You own all rights to the content, or have a valid license or permission to process it;</li>
<li>The content does not infringe any third-party copyright, trademark, privacy, or other right;</li>
<li>The content is not unlawful, defamatory, or otherwise prohibited.</li>
</ul>
<p>
If you submit content you do not have rights to, that is your responsibility, not ours. You
agree to indemnify OpenShorts and its contributors against any third-party claim arising from
content you submitted.
</p>
</Section>
<Section title="What we keep, and for how long">
<ul className="list-disc pl-6 space-y-1">
<li>
<strong className="text-white">Uploaded videos and generated clips:</strong> deleted with
their job, typically within 1 hour. Not backed up off-server in our hosted deployment.
</li>
<li>
<strong className="text-white">Attestation record (IP, user-agent, timestamp, source):</strong>{' '}
kept in memory with the job and discarded when the job is purged (1 hour). Used only to
evidence the ownership confirmation in case of a takedown or dispute.
</li>
<li>
<strong className="text-white">Standard server access logs:</strong> retained up to 30 days
for debugging and abuse prevention.
</li>
<li>
<strong className="text-white">API keys (Gemini, ElevenLabs, Upload-Post):</strong> stored
encrypted in your browser's <code className="text-zinc-200">localStorage</code>. They are
sent as request headers when a feature needs them, used to call the relevant third party,
and never written to our database or disk.
</li>
</ul>
<p>We do not sell, rent, or share your data with third parties for advertising or any unrelated purpose.</p>
</Section>
<Section title="Third-party APIs">
<p>
When you use a feature that requires it, OpenShorts forwards relevant data to the third-party
API for which you provided a key Google Gemini (AI analysis), ElevenLabs (optional dubbing),
Upload-Post (optional social posting). Those services have their own terms and privacy policies
which apply in addition to this notice.
</p>
</Section>
<Section title="Your rights (EU / EEA / UK)">
<p>
Under the GDPR / UK GDPR you have the right to access, rectify, erase, restrict, object to, or
port your personal data. Because we do not hold accounts and job data is purged within an hour,
most requests are auto-satisfied by the retention schedule. For anything else, file a request
via{' '}
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
GitHub Issues
</a>
. You may also lodge a complaint with your local supervisory authority (in Spain: AEPD,{' '}
<a className="text-primary underline" href="https://www.aepd.es" target="_blank" rel="noopener noreferrer">
aepd.es
</a>
).
</p>
</Section>
<Section title="Copyright takedowns">
<p>
If you believe content processed through the Service infringes your copyright, open an issue at{' '}
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
{ISSUES_URL}
</a>{' '}
with: identification of the work, identification of the allegedly infringing material (job ID,
URL, or sufficient detail to locate it), your contact information, and a statement that you are
authorized to act on behalf of the rights holder. Note that uploaded content is typically
deleted within 1 hour, so most takedowns are auto-resolved by retention.
</p>
</Section>
<Section title="Self-hosted instances">
<p>
OpenShorts is open source and may be self-hosted. This notice applies only to the hosted
version we operate. Self-hosted instances are operated by their respective administrators, and
their data handling, retention, and policies are their responsibility, not ours.
</p>
</Section>
<Section title="Changes & contact">
<p>
We may update this notice from time to time; the "Last updated" date above reflects the most
recent revision. Continued use after a change constitutes acceptance. For any other question,
please use{' '}
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
GitHub Issues
</a>
.
</p>
<p>This notice is governed by the laws of Spain.</p>
</Section>
</main>
</div>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,147 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { LayoutGrid, AlertCircle, Loader2 } from 'lucide-react';
import { getApiUrl } from '../config';
import GalleryCard from './GalleryCard';
const CLIPS_PER_PAGE = 20;
export default function Gallery() {
const [clips, setClips] = useState([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const loaderRef = useRef(null);
const fetchClips = useCallback(async (currentOffset = 0, append = false) => {
try {
if (currentOffset === 0) setLoading(true);
else setLoadingMore(true);
const res = await fetch(
getApiUrl(`/api/gallery/clips?limit=${CLIPS_PER_PAGE}&offset=${currentOffset}`)
);
if (!res.ok) throw new Error('Failed to fetch clips');
const data = await res.json();
const newClips = data.clips || [];
if (append) {
setClips(prev => [...prev, ...newClips]);
} else {
setClips(newClips);
}
setHasMore(data.has_more ?? newClips.length === CLIPS_PER_PAGE);
setOffset(currentOffset + newClips.length);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
// Initial load
useEffect(() => {
fetchClips(0, false);
}, [fetchClips]);
// Infinite scroll observer
useEffect(() => {
if (!hasMore || loadingMore || loading) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
fetchClips(offset, true);
}
},
{ rootMargin: '200px', threshold: 0.1 }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => {
if (loaderRef.current) {
observer.unobserve(loaderRef.current);
}
};
}, [hasMore, loadingMore, loading, offset, fetchClips]);
if (loading) {
return (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 animate-[fadeIn_0.5s_ease-out]">
<Loader2 size={32} className="animate-spin mb-4 text-primary" />
<p>Loading your viral history...</p>
</div>
);
}
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center text-red-400 p-6">
<AlertCircle size={32} className="mb-4" />
<p>Error loading gallery: {error}</p>
<button
onClick={() => {
setError(null);
setOffset(0);
fetchClips(0, false);
}}
className="mt-4 px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white transition-colors"
>
Retry
</button>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6 md:p-8 animate-[fadeIn_0.3s_ease-out]">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold flex items-center gap-3">
<LayoutGrid className="text-primary" /> Clip Gallery
</h1>
<span className="text-xs bg-white/10 text-white px-3 py-1 rounded-full border border-white/5">
{clips.length} {clips.length === 1 ? 'Clip' : 'Clips'}{hasMore ? '+' : ''}
</span>
</div>
{clips.length === 0 ? (
<div className="text-center py-20 text-zinc-500">
<p className="text-lg mb-2">No clips found yet.</p>
<p className="text-sm">Process some videos to populate your gallery!</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6 pb-10">
{clips.map((clip, i) => (
<GalleryCard key={`${clip.job_id}-${clip.index}`} clip={clip} />
))}
</div>
{/* Infinite scroll loader trigger */}
{hasMore && (
<div
ref={loaderRef}
className="flex justify-center py-8"
>
{loadingMore && (
<div className="flex items-center gap-2 text-zinc-500">
<Loader2 size={20} className="animate-spin" />
<span className="text-sm">Loading more clips...</span>
</div>
)}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,156 @@
import React, { useRef, useState, useEffect } from 'react';
import { Download, Youtube, Instagram, Video, Copy, Check, Play } from 'lucide-react';
export default function GalleryCard({ clip }) {
const [copied, setCopied] = useState(null);
const [isVisible, setIsVisible] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const cardRef = useRef(null);
const videoRef = useRef(null);
// Lazy loading with IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
// Once loaded, we don't need to observe anymore
observer.unobserve(entry.target);
}
});
},
{
rootMargin: '200px', // Start loading 200px before entering viewport
threshold: 0.1
}
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => {
if (cardRef.current) {
observer.unobserve(cardRef.current);
}
};
}, []);
const handleCopy = (text, field) => {
navigator.clipboard.writeText(text);
setCopied(field);
setTimeout(() => setCopied(null), 2000);
};
const handleDownload = async (e) => {
e.preventDefault();
try {
const response = await fetch(clip.url);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `clip_${clip.job_id}_${clip.index + 1}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download error:', err);
window.open(clip.url, '_blank');
}
};
return (
<div
ref={cardRef}
className="bg-surface border border-white/5 rounded-xl overflow-hidden flex flex-col hover:border-white/10 transition-all group animate-[fadeIn_0.5s_ease-out]"
>
{/* Video Player - Lazy loaded */}
<div className="aspect-[9/16] bg-black relative group/video">
{isVisible ? (
<video
ref={videoRef}
src={clip.url}
controls
className="w-full h-full object-cover"
playsInline
preload="metadata"
onLoadedData={() => setHasLoaded(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center">
<Play size={24} className="text-white/50 ml-1" />
</div>
</div>
)}
<div className="absolute top-2 left-2">
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 tracking-wide">
{new Date(clip.created_at).toLocaleDateString()}
</span>
</div>
</div>
{/* Content & Details */}
<div className="flex-1 p-4 flex flex-col bg-[#121214] min-w-0">
<div className="mb-3">
<h3 className="text-sm font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.title}>
{clip.title}
</h3>
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5">{clip.duration.toFixed(1)}s</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 truncate max-w-[150px]" title={clip.job_id}>ID: {clip.job_id.substring(0, 8)}</span>
</div>
</div>
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar max-h-[150px] pr-1 mb-3">
{/* YouTube Title */}
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-red-400 mb-1 uppercase tracking-wider">
<Youtube size={10} className="shrink-0" /> YouTube Title
</div>
<p className="text-xs text-zinc-300 select-all line-clamp-2 hover:line-clamp-none transition-all">{clip.title}</p>
<button
onClick={() => handleCopy(clip.title, 'yt')}
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
title="Copy Title"
>
{copied === 'yt' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
{/* TikTok / IG Caption */}
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 mb-1 uppercase tracking-wider">
<Video size={10} className="text-cyan-400 shrink-0" />
<span className="text-zinc-600">/</span>
<Instagram size={10} className="text-pink-400 shrink-0" /> Caption
</div>
<p className="text-xs text-zinc-300 select-all line-clamp-3 hover:line-clamp-none transition-all cursor-pointer">
{clip.tiktok_desc || clip.insta_desc}
</p>
<button
onClick={() => handleCopy(clip.tiktok_desc || clip.insta_desc, 'caption')}
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
title="Copy Caption"
>
{copied === 'caption' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
{/* Footer Action */}
<button
onClick={handleDownload}
className="w-full py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5"
>
<Download size={14} className="shrink-0" /> Download Clip
</button>
</div>
</div>
);
}
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { X, Sparkles, Loader2, Maximize, MoveVertical, Zap } from 'lucide-react';
import RemotionPreview from './RemotionPreview';
const ENTRANCE_OPTIONS = [
{ value: 'spring', label: 'Bounce' },
{ value: 'fade', label: 'Fade' },
{ value: 'slide-up', label: 'Slide Up' },
{ value: 'none', label: 'None' },
];
export default function HookModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, initialText, durationInSeconds, existingSubtitles }) {
const [text, setText] = useState(initialText || 'POV: You are using the viral hook feature');
const [position, setPosition] = useState('top');
const [size, setSize] = useState('M');
const [entranceAnimation, setEntranceAnimation] = useState('spring');
const [displayDuration, setDisplayDuration] = useState(5);
if (!isOpen) return null;
// Build hook config for Remotion preview
const hookConfig = {
text: text || 'Enter your text...',
position,
size,
entranceAnimation,
displayDurationSec: displayDuration,
};
const useRemotionPreview = !!videoUrl;
// Fallback preview logic (same as original)
const getPositionClass = () => {
switch (position) {
case 'center': return 'items-center justify-center';
case 'bottom': return 'items-center justify-end pb-[20%]';
case 'top': default: return 'items-center justify-start pt-[20%]';
}
};
const getSizeStyle = () => {
switch (size) {
case 'S': return { fontSize: '14px', maxWidth: '80%' };
case 'L': return { fontSize: '24px', maxWidth: '95%' };
case 'M': default: return { fontSize: '18px', maxWidth: '90%' };
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-4xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
<button
onClick={onClose}
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
>
<X size={20} />
</button>
{/* Left: Preview */}
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
{useRemotionPreview ? (
<RemotionPreview
videoUrl={videoUrl}
durationInSeconds={durationInSeconds || 30}
hook={hookConfig}
subtitles={existingSubtitles || null}
/>
) : (
<>
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col h-full ${getPositionClass()}`}>
<div
className="text-black font-bold px-3 py-2 rounded-xl shadow-2xl text-center whitespace-pre-wrap transition-all duration-200"
style={{
...getSizeStyle(),
backgroundColor: 'rgba(255, 255, 255, 0.82)',
fontFamily: 'Noto Serif, serif',
boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
paddingTop: '10px',
paddingBottom: '10px',
paddingLeft: '12px',
paddingRight: '12px'
}}
>
{text || "Enter your text..."}
</div>
</div>
</>
)}
</div>
{/* Right: Controls */}
<div className="w-full md:w-80 flex flex-col">
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Sparkles className="text-yellow-400" /> Viral Hook
</h3>
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
{/* Text Input */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 block">Text</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white placeholder-zinc-600 focus:outline-none focus:border-yellow-500/50 resize-none font-serif"
placeholder="Enter text that will stop the scroll..."
/>
</div>
{/* Position Control */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<MoveVertical size={12} /> Position
</label>
<div className="grid grid-cols-3 gap-2">
{['top', 'center', 'bottom'].map((pos) => (
<button
key={pos}
onClick={() => setPosition(pos)}
className={`py-2 px-1 rounded-lg text-xs font-bold capitalize transition-all border ${position === pos
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{pos}
</button>
))}
</div>
</div>
{/* Size Control */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Maximize size={12} /> Size
</label>
<div className="grid grid-cols-3 gap-2">
{['S', 'M', 'L'].map((sz) => (
<button
key={sz}
onClick={() => setSize(sz)}
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${size === sz
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{sz === 'S' ? 'Small' : sz === 'M' ? 'Medium' : 'Large'}
</button>
))}
</div>
</div>
{/* Entrance Animation (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Zap size={12} /> Entrance
</label>
<div className="grid grid-cols-2 gap-2">
{ENTRANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setEntranceAnimation(opt.value)}
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${entranceAnimation === opt.value
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Display Duration (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Duration: {displayDuration}s</label>
<input
type="range"
min="2"
max="15"
value={displayDuration}
onChange={(e) => setDisplayDuration(parseInt(e.target.value))}
className="w-full accent-yellow-500"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>2s</span>
<span>15s</span>
</div>
</div>
<div className="p-3 bg-white/5 rounded-lg border border-white/5 text-[11px] text-zinc-400">
<strong>Tip:</strong> Keep it short and punchy. Using "POV:" or specific questions works best for retention.
</div>
</div>
<button
onClick={() => onGenerate({
text, position, size,
// Remotion data
remotion: hookConfig,
})}
disabled={isProcessing || !text.trim()}
className="w-full py-4 mt-4 bg-gradient-to-r from-yellow-500 to-amber-600 hover:from-yellow-400 hover:to-amber-500 text-black font-bold rounded-xl shadow-lg shadow-amber-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Sparkles size={20} />}
{isProcessing ? 'Generating...' : 'Add Hook'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react';
import { Key, Eye, EyeOff, Check } from 'lucide-react';
export default function KeyInput({ onKeySet, savedKey }) {
const [key, setKey] = useState(savedKey || '');
const [isVisible, setIsVisible] = useState(false);
const [isSaved, setIsSaved] = useState(!!savedKey);
useEffect(() => {
if (savedKey) setKey(savedKey);
}, [savedKey]);
const handleSave = () => {
if (key.trim().length > 0) {
onKeySet(key);
setIsSaved(true);
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl p-6 mb-8 animate-[fadeIn_0.5s_ease-out]">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-accent/20 rounded-lg text-accent">
<Key size={20} />
</div>
<h2 className="text-lg font-semibold">Gemini API Key</h2>
</div>
<div className="flex gap-3">
<div className="relative flex-1">
<input
type={isVisible ? "text" : "password"}
value={key}
onChange={(e) => {
setKey(e.target.value);
setIsSaved(false);
}}
placeholder="AIzaSy..."
className="input-field pr-12 font-mono"
/>
<button
onClick={() => setIsVisible(!isVisible)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
>
{isVisible ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<button
onClick={handleSave}
disabled={!key || isSaved}
className={`px-6 rounded-xl font-medium transition-all flex items-center gap-2 ${isSaved
? 'bg-green-500/20 text-green-400 cursor-default'
: 'bg-primary hover:bg-blue-600 text-white shadow-lg shadow-primary/20'
}`}
>
{isSaved ? <><Check size={18} /> Ready</> : 'Set Key'}
</button>
</div>
<p className="mt-3 text-xs text-zinc-500">
Your key is stored locally in your browser for convenience.
<br />
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mt-1 inline-block"
>
Get your free Gemini API Key here
</a>
</p>
</div>
);
}
@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { Youtube, Upload, FileVideo, X } from 'lucide-react';
import { getApiUrl } from '../config';
export default function MediaInput({ onProcess, isProcessing }) {
const [youtubeUrlEnabled, setYoutubeUrlEnabled] = useState(true);
const [mode, setMode] = useState('url'); // 'url' | 'file'
const [url, setUrl] = useState('');
const [file, setFile] = useState(null);
const [acknowledged, setAcknowledged] = useState(false);
useEffect(() => {
fetch(getApiUrl('/api/config'))
.then((r) => r.ok ? r.json() : null)
.then((cfg) => {
if (cfg && cfg.youtubeUrlEnabled === false) {
setYoutubeUrlEnabled(false);
setMode('file');
}
})
.catch(() => {});
}, []);
const handleSubmit = (e) => {
e.preventDefault();
if (!acknowledged) return;
if (mode === 'url' && url) {
onProcess({ type: 'url', payload: url, acknowledged: true });
} else if (mode === 'file' && file) {
onProcess({ type: 'file', payload: file, acknowledged: true });
}
};
const handleDrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFile(e.dataTransfer.files[0]);
setMode('file');
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl p-6 animate-[fadeIn_0.6s_ease-out]">
<div className="flex gap-4 mb-6 border-b border-white/5 pb-4">
{youtubeUrlEnabled && (
<button
onClick={() => setMode('url')}
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'url'
? 'text-primary border-b-2 border-primary -mb-[17px]'
: 'text-zinc-400 hover:text-white'
}`}
>
<Youtube size={18} />
YouTube URL
</button>
)}
<button
onClick={() => setMode('file')}
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'file'
? 'text-primary border-b-2 border-primary -mb-[17px]'
: 'text-zinc-400 hover:text-white'
}`}
>
<Upload size={18} />
Upload File
</button>
</div>
<form onSubmit={handleSubmit}>
{mode === 'url' ? (
<div className="space-y-4">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="input-field"
required
/>
</div>
) : (
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all ${file ? 'border-primary/50 bg-primary/5' : 'border-zinc-700 hover:border-zinc-500 bg-white/5'
}`}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{file ? (
<div className="flex items-center justify-center gap-3 text-white">
<FileVideo className="text-primary" />
<span className="font-medium">{file.name}</span>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 hover:bg-white/10 rounded-full"
>
<X size={16} />
</button>
</div>
) : (
<label className="cursor-pointer block">
<input
type="file"
accept="video/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
/>
<Upload className="mx-auto mb-3 text-zinc-500" size={24} />
<p className="text-zinc-400">Click to upload or drag and drop</p>
<p className="text-xs text-zinc-600 mt-1">MP4, MOV up to 500MB</p>
</label>
)}
</div>
)}
<label className="flex items-start gap-2 mt-5 text-xs text-zinc-400 cursor-pointer select-none">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5 accent-primary cursor-pointer"
/>
<span>
I confirm I own this content or have the rights to process it. I am responsible for any content I submit. See our <a href="/#legal" target="_blank" rel="noopener noreferrer" className="text-primary underline" onClick={(e) => e.stopPropagation()}>Terms & Privacy</a>.
</span>
</label>
<button
type="submit"
disabled={isProcessing || !acknowledged || (mode === 'url' && !url) || (mode === 'file' && !file)}
className="w-full btn-primary mt-4 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Processing Video...
</>
) : (
<>
Generate Clips
</>
)}
</button>
</form>
</div>
);
}
@@ -0,0 +1,244 @@
import React, { useEffect, useState, useRef } from 'react';
import { Scan, Scissors, Activity, Radio, CheckCircle, Play } from 'lucide-react';
const ProcessingAnimation = ({ media, isComplete, syncedTime, isSyncedPlaying, syncTrigger }) => {
const [videoSrc, setVideoSrc] = useState(null);
const [isYouTube, setIsYouTube] = useState(false);
const videoRef = useRef(null);
const iframeRef = useRef(null);
useEffect(() => {
if (!media) return;
if (media.type === 'file') {
const url = URL.createObjectURL(media.payload);
setVideoSrc(url);
return () => URL.revokeObjectURL(url);
} else if (media.type === 'url') {
setIsYouTube(true);
const videoId = getYouTubeId(media.payload);
setVideoSrc(videoId);
}
}, [media]);
// Handle Sync Playback for Local Video
useEffect(() => {
if (!isYouTube && videoRef.current) {
if (isSyncedPlaying) {
// Sync Mode: Seek to time and Play
videoRef.current.currentTime = syncedTime;
videoRef.current.play().catch(e => console.log("Auto-play prevented", e));
videoRef.current.loop = false;
videoRef.current.muted = true; // Keep muted to avoid double audio with clip
} else {
// Stop Sync: Pause
videoRef.current.pause();
// If Analysis Complete and we just stopped syncing (paused clip), we might want to return to ambient loop?
// User issue: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica no se está reproduciendo el video en bucle como antes"
// This means when NOT synced, it should loop.
// HOWEVER, previously user asked: "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
// This implies:
// 1. If I PAUSE the clip -> Left video should PAUSE (static image) so it can resume.
// 2. If I STOP (or it finishes? or just idle state?) -> It should LOOP.
// The problem is we only have onPause from the clip.
// Maybe we need to distinguish "Pause" vs "Idle/Stop".
// But currently we just get `isSyncedPlaying = false`.
// If the user wants "resume from where left off", it MUST be static (paused).
// If the user wants "loop when not playing", it MUST play.
// These are contradictory for the "Paused" state.
// BUT, maybe the "loop original" refers to the initial state BEFORE any clip is played?
// OR when the clip finishes?
// Let's look at the logic:
// isSyncedPlaying is true ONLY when a clip is playing.
// When clip pauses, isSyncedPlaying becomes false.
// If we want it to loop "como antes", we should set it to loop.
// But then we lose the "resume" position because it starts looping.
// Unless... we only loop if we haven't started syncing yet? Or if explicitly reset?
// Wait, the user said: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica"
// This likely refers to the state AFTER analysis is complete but BEFORE (or after) playing a clip.
// If I haven't touched a clip yet, `isSyncedPlaying` is false.
// In that case, it SHOULD be looping.
// My previous change removed the "else { play loop }" block entirely.
// I need to restore the loop for the IDLE state, but keep the PAUSE for the "paused clip" state?
// That requires knowing WHY `isSyncedPlaying` is false.
// Actually, if `isSyncedPlaying` is false, it means no clip is controlling it.
// If I want it to loop in the background, I can just let it loop.
// BUT if I play a clip later, it will jump to the sync time anyway (handled by the `if (isSyncedPlaying)` block).
// The only issue is if I PAUSE the clip, `isSyncedPlaying` becomes false, and if I immediately start looping,
// visually it might jump or start moving when it should be "paused".
// Let's try this:
// If `syncedTime` is 0 (or we track if we ever started syncing?), we loop.
// But `syncedTime` updates on play.
// Alternative interpretation: The user sees it static because I removed `videoRef.current.play()` in the else block.
// If I put it back, it fixes the "loop" issue.
// Does it break the "resume" issue?
// "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
// If I pause the clip -> `isSyncedPlaying` = false.
// If logic says -> Loop from 0.
// Then I resume -> `isSyncedPlaying` = true -> Jump to `syncedTime`.
// This actually SHOULD work fine for "resume", because `syncedTime` comes from the clip's current time.
// The only visual glitch is that while paused, the left video is looping instead of frozen on the frame.
// If the user accepts that "Paused Clip" = "Background Loop", then we are good.
// If the user wants "Paused Clip" = "Frozen Frame" AND "Idle" = "Background Loop", we need more state.
// But typically "Idle" implies we aren't focusing on a clip.
// Let's restore the loop behavior because "video loop original... es una imagen estatica" sounds like a bug to them.
if (isComplete) {
videoRef.current.loop = true;
videoRef.current.play().catch(e => console.log("Ambient play prevented", e));
}
}
}
}, [syncedTime, isSyncedPlaying, isYouTube, isComplete, syncTrigger]);
// Handle Sync Playback for YouTube (Basic Iframe Control via PostMessage)
useEffect(() => {
if (isYouTube && iframeRef.current && videoSrc) {
const iframeWindow = iframeRef.current.contentWindow;
if (isSyncedPlaying) {
// Seek and Play
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'seekTo', args: [syncedTime, true] }), '*');
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'playVideo', args: [] }), '*');
} else {
// Pause
// iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); // Removed pause to allow loop if needed, but YT embeds are tricky with custom loops via API.
// For now, let's just pause YouTube as complex looping is harder without state.
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*');
}
}
}, [syncedTime, isSyncedPlaying, isYouTube, videoSrc, syncTrigger]);
const getYouTubeId = (url) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const containerClasses = `relative w-full aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-2xl mb-8 group animate-[fadeIn_0.5s_ease-out] transition-all duration-500
${isComplete && !isSyncedPlaying ? 'grayscale brightness-50' : ''}
${isSyncedPlaying ? 'ring-2 ring-primary ring-offset-2 ring-offset-black shadow-primary/20' : ''}`;
const getVideoOpacityClass = () => {
if (isSyncedPlaying) return 'opacity-100'; // Playing: Full visibility
if (isComplete) return 'opacity-30'; // Idle Result: Darker
return 'opacity-40 grayscale group-hover:grayscale-0'; // Processing: Dark + Grayscale effect
};
return (
<div className={containerClasses}>
{/* Video Layer */}
<div className={`absolute inset-0 transition-all duration-700 ${getVideoOpacityClass()}`}>
{isYouTube && videoSrc ? (
<iframe
ref={iframeRef}
className={`w-full h-full ${isSyncedPlaying ? '' : 'pointer-events-none scale-110'}`}
// Add enablejsapi=1 for postMessage control
src={`https://www.youtube.com/embed/${videoSrc}?autoplay=1&mute=1&controls=0&loop=1&playlist=${videoSrc}&modestbranding=1&showinfo=0&rel=0&enablejsapi=1`}
title="Processing Video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
) : videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
className="w-full h-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
<div className="w-16 h-16 border-4 border-zinc-700 border-t-zinc-500 rounded-full animate-spin"></div>
</div>
)}
</div>
{/* Overlays - Hide when synced playing so user sees clean video */}
{!isSyncedPlaying && !isComplete && (
<>
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:40px_40px] z-10 pointer-events-none"></div>
<div className="absolute left-0 w-full h-[2px] bg-primary shadow-[0_0_15px_2px_rgba(59,130,246,0.5)] animate-[scan_2.5s_linear_infinite] z-20 pointer-events-none"></div>
<div className="absolute left-0 w-full h-[15%] bg-gradient-to-b from-primary/0 via-primary/5 to-primary/0 animate-[scan-overlay_2.5s_linear_infinite] z-10 pointer-events-none"></div>
</>
)}
{/* HUD Elements - Hide when synced playing */}
{!isSyncedPlaying && (
<div className={`absolute top-4 left-4 z-30 flex items-center gap-2 px-3 py-1.5 backdrop-blur-md rounded-lg border text-xs font-mono font-bold uppercase transition-all duration-500 ${isComplete ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-black/60 border-primary/30 text-primary animate-pulse'}`}>
{isComplete ? (
<>
<CheckCircle size={14} /> Analysis Complete
</>
) : (
<>
<Scan size={14} /> Scanning Content...
</>
)}
</div>
)}
{!isSyncedPlaying && !isComplete && (
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 text-white/50 text-[10px] font-mono">
AI_MODEL: GEMINI-2.5-PRO
</div>
)}
{/* Visual Flair */}
{!isSyncedPlaying && !isComplete && (
<div className="absolute inset-0 pointer-events-none z-20 overflow-hidden">
<div className="absolute top-0 bottom-0 left-[35%] w-[1px] bg-yellow-500/20 border-r border-dashed border-yellow-500/40"></div>
<div className="absolute top-0 bottom-0 right-[35%] w-[1px] bg-yellow-500/20 border-l border-dashed border-yellow-500/40"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 border border-white/20 rounded-full flex items-center justify-center">
<div className="w-1 h-1 bg-red-500 rounded-full animate-ping"></div>
</div>
<div className="absolute bottom-1/3 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-2 opacity-60">
<Scissors size={24} className="text-white/20" />
</div>
</div>
)}
{/* Synced Playing Indicator */}
{isSyncedPlaying && (
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-red-600/90 backdrop-blur text-white rounded-lg shadow-lg animate-pulse font-bold text-[10px] uppercase tracking-wider border border-white/20">
<Activity size={12} /> Live Sync
</div>
)}
{/* Bottom Info Bar */}
{!isSyncedPlaying && !isComplete && (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent z-30 flex justify-between items-end border-t border-white/5">
<div className="font-mono text-[10px] text-primary/80 space-y-1">
<div className="flex items-center gap-2"><Activity size={10} className="animate-bounce" /> > ANALYSIS_THREAD_01: ACTIVE</div>
<div className="flex items-center gap-2"><Radio size={10} /> > AUDIO_TRANSCRIPT: PROCESSING</div>
</div>
<div className="flex gap-1">
<div className="w-1 h-3 bg-primary/40 animate-[pulse_0.5s_infinite]"></div>
<div className="w-1 h-5 bg-primary/60 animate-[pulse_0.7s_infinite]"></div>
<div className="w-1 h-2 bg-primary/30 animate-[pulse_0.4s_infinite]"></div>
<div className="w-1 h-4 bg-primary/80 animate-[pulse_0.6s_infinite]"></div>
<div className="w-1 h-3 bg-primary/50 animate-[pulse_0.5s_infinite]"></div>
</div>
</div>
)}
</div>
);
};
export default ProcessingAnimation;
@@ -0,0 +1,61 @@
import React, { useMemo } from 'react';
import { Player } from '@remotion/player';
import { ShortVideo } from '../remotion/compositions/ShortVideo';
/**
* Wraps Remotion's Player component for real-time preview in modals.
* Accepts the same ShortVideoProps interface as the Remotion composition.
*
* @param {object} props
* @param {string} props.videoUrl - URL to the base clip video
* @param {number} props.durationInSeconds - Video duration in seconds
* @param {object|null} props.subtitles - SubtitleConfig or null
* @param {object|null} props.hook - HookConfig or null
* @param {object|null} props.effects - EffectsConfig or null
* @param {string} [props.className] - Additional CSS classes
*/
export default function RemotionPreview({
videoUrl,
durationInSeconds = 30,
subtitles = null,
hook = null,
effects = null,
className = '',
}) {
const fps = 30;
const durationInFrames = Math.max(1, Math.round(durationInSeconds * fps));
const inputProps = useMemo(
() => ({
videoUrl,
durationInFrames,
fps,
width: 1080,
height: 1920,
subtitles,
hook,
effects,
}),
[videoUrl, durationInFrames, subtitles, hook, effects]
);
return (
<div className={`w-full h-full ${className}`}>
<Player
component={ShortVideo}
inputProps={inputProps}
durationInFrames={durationInFrames}
fps={fps}
compositionWidth={1080}
compositionHeight={1920}
style={{
width: '100%',
height: '100%',
}}
controls
autoPlay
loop
/>
</div>
);
}
@@ -0,0 +1,685 @@
import React, { useState, useEffect } from 'react';
import { Download, Share2, Instagram, Youtube, Video, CheckCircle, AlertCircle, X, Loader2, Copy, Wand2, Type, Calendar, Clock, Languages } from 'lucide-react';
import { getApiUrl } from '../config';
import SubtitleModal from './SubtitleModal';
import HookModal from './HookModal';
import TranslateModal from './TranslateModal';
import { renderInBrowser } from '../lib/renderInBrowser';
export default function ResultCard({ clip, index, jobId, uploadPostKey, uploadUserId, geminiApiKey, elevenLabsKey, onPlay, onPause }) {
const [showModal, setShowModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const videoRef = React.useRef(null);
const originalVideoUrl = getApiUrl(clip.video_url); // Never changes used for Remotion previews
const [currentVideoUrl, setCurrentVideoUrl] = useState(originalVideoUrl);
const [platforms, setPlatforms] = useState({
tiktok: true,
instagram: true,
youtube: true
});
const [postTitle, setPostTitle] = useState("");
const [postDescription, setPostDescription] = useState("");
const [isScheduling, setIsScheduling] = useState(false);
const [scheduleDate, setScheduleDate] = useState("");
const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [isSubtitling, setIsSubtitling] = useState(false);
const [isHooking, setIsHooking] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
const [showHookModal, setShowHookModal] = useState(false);
const [showTranslateModal, setShowTranslateModal] = useState(false);
const [editError, setEditError] = useState(null);
const [clipDuration, setClipDuration] = useState(clip.end && clip.start ? clip.end - clip.start : 30);
// Accumulate Remotion layers across operations
const [activeLayers, setActiveLayers] = useState({ subtitles: null, hook: null, effects: null });
// Fetch clip duration from transcript endpoint
useEffect(() => {
if (!jobId || index === undefined) return;
fetch(getApiUrl(`/api/clip/${jobId}/${index}/transcript`))
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.durationSec) setClipDuration(data.durationSec);
})
.catch(() => {});
}, [jobId, index]);
// Initialize/Reset form when modal opens
useEffect(() => {
if (showModal) {
setPostTitle(clip.video_title_for_youtube_short || "Viral Short");
setPostDescription(clip.video_description_for_instagram || clip.video_description_for_tiktok || "");
setIsScheduling(false);
setScheduleDate("");
setPostResult(null);
}
}, [showModal, clip]);
const handleAutoEdit = async () => {
setIsEditing(true);
setEditError(null);
try {
const apiKey = geminiApiKey || localStorage.getItem('gemini_key');
if (!apiKey) {
throw new Error("Gemini API Key is missing. Please set it in Settings.");
}
// Try Remotion effects endpoint first
const effectsRes = await fetch(getApiUrl('/api/effects/generate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (effectsRes.ok) {
const data = await effectsRes.json();
if (data.effects && data.effects.segments) {
const newLayers = { ...activeLayers, effects: data.effects };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
return;
}
}
// Fallback: legacy FFmpeg edit endpoint
const res = await fetch(getApiUrl('/api/edit'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsEditing(false);
}
};
const handleSubtitle = async (options) => {
setIsSubtitling(true);
setEditError(null);
try {
if (options.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, subtitles: options.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
return;
}
// Fallback: legacy FFmpeg
const res = await fetch(getApiUrl('/api/subtitle'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
position: options.position,
font_size: options.fontSize,
font_name: options.fontName,
font_color: options.fontColor,
border_color: options.borderColor,
border_width: options.borderWidth,
bg_color: options.bgColor,
bg_opacity: options.bgOpacity,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsSubtitling(false);
}
};
const handleHook = async (hookData) => {
setIsHooking(true);
setEditError(null);
try {
if (hookData.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, hook: hookData.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
return;
}
// Fallback: legacy FFmpeg
const payload = typeof hookData === 'string'
? { text: hookData, position: 'top', size: 'M' }
: hookData;
const res = await fetch(getApiUrl('/api/hook'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
text: payload.text,
position: payload.position,
size: payload.size,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsHooking(false);
}
};
const handleTranslate = async (options) => {
console.log('[Translate] Starting translation with options:', options);
setIsTranslating(true);
setEditError(null);
try {
const apiKey = elevenLabsKey;
console.log('[Translate] API Key available:', !!apiKey);
if (!apiKey) {
throw new Error("ElevenLabs API Key is missing. Please set it in Settings.");
}
const requestBody = {
job_id: jobId,
clip_index: index,
target_language: options.targetLanguage,
input_filename: currentVideoUrl.split('/').pop()
};
console.log('[Translate] Request body:', requestBody);
console.log('[Translate] Sending request to /api/translate');
const res = await fetch(getApiUrl('/api/translate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ElevenLabs-Key': apiKey
},
body: JSON.stringify(requestBody)
});
console.log('[Translate] Response status:', res.status);
if (!res.ok) {
const errText = await res.text();
console.error('[Translate] Error response:', errText);
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
if (e.message !== errText) throw e;
throw new Error(errText);
}
}
const data = await res.json();
console.log('[Translate] Success response:', data);
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
setShowTranslateModal(false);
}
} catch (e) {
console.error('[Translate] Exception:', e);
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsTranslating(false);
}
};
const handlePost = async () => {
if (!uploadPostKey || !uploadUserId) {
setPostResult({ success: false, msg: "Missing API Key or User ID." });
return;
}
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
if (selectedPlatforms.length === 0) {
setPostResult({ success: false, msg: "Select at least one platform." });
return;
}
if (isScheduling && !scheduleDate) {
setPostResult({ success: false, msg: "Please select a date and time." });
return;
}
setPosting(true);
setPostResult(null);
try {
const payload = {
job_id: jobId,
clip_index: index,
api_key: uploadPostKey,
user_id: uploadUserId,
platforms: selectedPlatforms,
title: postTitle,
description: postDescription
};
if (isScheduling && scheduleDate) {
// Convert to ISO-8601
payload.scheduled_date = new Date(scheduleDate).toISOString();
// Optional: pass timezone if needed, backend defaults to UTC or we can send user's timezone
payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
const res = await fetch(getApiUrl('/api/social/post'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
setPostResult({ success: true, msg: isScheduling ? "Scheduled successfully!" : "Posted successfully!" });
setTimeout(() => {
setShowModal(false);
setPostResult(null);
}, 3000);
} catch (e) {
setPostResult({ success: false, msg: `Failed: ${e.message}` });
} finally {
setPosting(false);
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl overflow-hidden flex flex-col md:flex-row group hover:border-white/10 transition-all animate-[fadeIn_0.5s_ease-out] min-h-[300px] h-auto" style={{ animationDelay: `${index * 0.1}s` }}>
{/* Left: Video Preview (Responsive Width) */}
<div className="w-full md:w-[180px] lg:w-[200px] bg-black relative shrink-0 aspect-[9/16] md:aspect-auto group/video">
<video
ref={videoRef}
src={currentVideoUrl}
controls
className="w-full h-full object-cover"
playsInline
onPlay={() => {
const currentTime = videoRef.current ? videoRef.current.currentTime : 0;
onPlay && onPlay(clip.start + currentTime);
}}
onPause={() => onPause && onPause()}
onEnded={() => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
videoRef.current.play();
}
}}
/>
<div className="absolute top-3 left-3 flex gap-2">
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 uppercase tracking-wide">
Clip {index + 1}
</span>
</div>
{/* Auto Edit Overlay if Processing */}
{isEditing && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center z-10 p-4 text-center">
<Loader2 size={32} className="text-primary animate-spin mb-3" />
<span className="text-xs font-bold text-white uppercase tracking-wider">AI Magic in Progress...</span>
<span className="text-[10px] text-zinc-400 mt-1">Applying viral edits & zooms</span>
</div>
)}
</div>
{/* Right: Content & Details */}
<div className="flex-1 p-4 md:p-5 flex flex-col bg-[#121214] overflow-hidden min-w-0">
<div className="mb-4">
<h3 className="text-base font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.video_title_for_youtube_short}>
{clip.video_title_for_youtube_short || "Viral Clip Generated"}
</h3>
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">{Math.floor(clip.end - clip.start)}s</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#shorts</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#viral</span>
</div>
</div>
{/* Scrollable Descriptions Area */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2 mb-4">
{/* YouTube */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-red-400 mb-1.5 uppercase tracking-wider">
<Youtube size={12} className="shrink-0" /> <span className="truncate">YouTube Title</span>
</div>
<p className="text-xs text-zinc-300 select-all break-words">
{clip.video_title_for_youtube_short || "Viral Short Video"}
</p>
</div>
{/* TikTok / IG */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-zinc-400 mb-1.5 uppercase tracking-wider">
<Video size={12} className="text-cyan-400 shrink-0" />
<span className="text-zinc-500">/</span>
<Instagram size={12} className="text-pink-400 shrink-0" />
<span className="truncate">Caption</span>
</div>
<p className="text-xs text-zinc-300 line-clamp-3 hover:line-clamp-none transition-all cursor-pointer select-all break-words">
{clip.video_description_for_tiktok || clip.video_description_for_instagram}
</p>
</div>
</div>
{/* Error Message */}
{editError && (
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] rounded-lg flex items-center gap-2">
<AlertCircle size={12} className="shrink-0" />
{editError}
</div>
)}
{/* Actions Footer */}
<div className="grid grid-cols-2 gap-3 mt-auto pt-4 border-t border-white/5">
<button
onClick={handleAutoEdit}
disabled={isEditing}
className="col-span-1 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-purple-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isEditing ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isEditing ? 'Editing...' : 'Auto Edit'}
</button>
<button
onClick={() => setShowSubtitleModal(true)}
disabled={isSubtitling}
className="col-span-1 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isSubtitling ? <Loader2 size={14} className="animate-spin" /> : <Type size={14} />}
{isSubtitling ? 'Adding...' : 'Subtitles'}
</button>
<button
onClick={() => setShowHookModal(true)}
disabled={isHooking}
className="col-span-1 py-2 bg-gradient-to-r from-amber-400 to-yellow-500 hover:from-amber-300 hover:to-yellow-400 text-black rounded-lg text-xs font-bold shadow-lg shadow-yellow-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isHooking ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isHooking ? 'Adding...' : 'Viral Hook'}
</button>
<button
onClick={() => setShowTranslateModal(true)}
disabled={isTranslating}
className="col-span-1 py-2 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-green-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isTranslating ? <Loader2 size={14} className="animate-spin" /> : <Languages size={14} />}
{isTranslating ? 'Translating...' : 'Dub Voice'}
</button>
<button
onClick={() => setShowModal(true)}
className="col-span-1 py-2 bg-primary hover:bg-blue-600 text-white rounded-lg text-xs font-bold shadow-lg shadow-primary/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 truncate px-2"
>
<Share2 size={14} className="shrink-0" /> Post
</button>
<button
onClick={async (e) => {
e.preventDefault();
try {
const response = await fetch(currentVideoUrl);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `clip-${index + 1}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download error:', err);
window.open(currentVideoUrl, '_blank');
}
}}
className="col-span-1 py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5 truncate px-2"
>
<Download size={14} className="shrink-0" /> Download
</button>
</div>
</div>
{/* Post Modal */}
{showModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
<button
onClick={() => setShowModal(false)}
className="absolute top-4 right-4 text-zinc-500 hover:text-white"
>
<X size={20} />
</button>
<h3 className="text-lg font-bold text-white mb-4">Post / Schedule</h3>
{!uploadPostKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configure API Key in Settings first.</div>
</div>
)}
<div className="space-y-4 mb-6">
{/* Title & Description */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Video Title</label>
<input
type="text"
value={postTitle}
onChange={(e) => setPostTitle(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600"
placeholder="Enter a catchy title..."
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Caption / Description</label>
<textarea
value={postDescription}
onChange={(e) => setPostDescription(e.target.value)}
rows={4}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600 resize-none"
placeholder="Write a caption for your post..."
/>
</div>
{/* Scheduling */}
<div className="p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-white font-medium">
<Calendar size={16} className="text-purple-400" /> Schedule Post
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={isScheduling} onChange={(e) => setIsScheduling(e.target.checked)} className="sr-only peer" />
<div className="w-9 h-5 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{isScheduling && (
<div className="mt-3 animate-[fadeIn_0.2s_ease-out]">
<label className="block text-xs text-zinc-400 mb-1">Select Date & Time</label>
<div className="relative">
<input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 pl-9 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
/>
<Clock size={14} className="absolute left-3 top-2.5 text-zinc-500" />
</div>
</div>
)}
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2">Select Platforms</label>
<div className="grid grid-cols-1 gap-2">
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.tiktok} onChange={e => setPlatforms({ ...platforms, tiktok: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Video size={16} className="text-cyan-400" /> TikTok</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.instagram} onChange={e => setPlatforms({ ...platforms, instagram: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Instagram size={16} className="text-pink-400" /> Instagram</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.youtube} onChange={e => setPlatforms({ ...platforms, youtube: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Youtube size={16} className="text-red-400" /> YouTube Shorts</div>
</label>
</div>
</div>
</div>
{postResult && (
<div className={`mb-4 p-3 rounded-lg text-xs flex items-start gap-2 ${postResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{postResult.success ? <CheckCircle size={14} className="mt-0.5 shrink-0" /> : <AlertCircle size={14} className="mt-0.5 shrink-0" />}
<div>{postResult.msg}</div>
</div>
)}
<button
onClick={handlePost}
disabled={posting || !uploadPostKey}
className="w-full py-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white font-bold transition-all flex items-center justify-center gap-2"
>
{posting ? <><Loader2 size={16} className="animate-spin" /> {isScheduling ? 'Scheduling...' : 'Publishing...'}</> : <><Share2 size={16} /> {isScheduling ? 'Schedule Post' : 'Publish Now'}</>}
</button>
</div>
</div>
)}
<SubtitleModal
isOpen={showSubtitleModal}
onClose={() => setShowSubtitleModal(false)}
onGenerate={handleSubtitle}
isProcessing={isSubtitling}
videoUrl={originalVideoUrl}
jobId={jobId}
clipIndex={index}
existingHook={activeLayers.hook}
/>
<HookModal
isOpen={showHookModal}
onClose={() => setShowHookModal(false)}
onGenerate={handleHook}
isProcessing={isHooking}
videoUrl={originalVideoUrl}
initialText={clip.viral_hook_text}
durationInSeconds={clip.end && clip.start ? clip.end - clip.start : 30}
existingSubtitles={activeLayers.subtitles}
/>
<TranslateModal
isOpen={showTranslateModal}
onClose={() => setShowTranslateModal(false)}
onTranslate={handleTranslate}
isProcessing={isTranslating}
videoUrl={currentVideoUrl}
hasApiKey={!!elevenLabsKey}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,388 @@
import React, { useState, useMemo } from 'react';
import { X, Loader2, Calendar, Clock, CheckCircle, AlertCircle, Video, Instagram, Youtube, ChevronLeft, ChevronRight, Globe, ExternalLink } from 'lucide-react';
import { getApiUrl } from '../config';
const DAYS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
const MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const TIMEZONES = [
{ value: 'Pacific/Midway', label: '(GMT-11:00) Midway' },
{ value: 'Pacific/Honolulu', label: '(GMT-10:00) Honolulu' },
{ value: 'America/Anchorage', label: '(GMT-09:00) Alaska' },
{ value: 'America/Los_Angeles', label: '(GMT-08:00) Los Ángeles' },
{ value: 'America/Denver', label: '(GMT-07:00) Denver' },
{ value: 'America/Mexico_City', label: '(GMT-06:00) Ciudad de México' },
{ value: 'America/Chicago', label: '(GMT-06:00) Chicago' },
{ value: 'America/New_York', label: '(GMT-05:00) Nueva York' },
{ value: 'America/Bogota', label: '(GMT-05:00) Bogotá' },
{ value: 'America/Caracas', label: '(GMT-04:00) Caracas' },
{ value: 'America/Santiago', label: '(GMT-04:00) Santiago' },
{ value: 'America/Argentina/Buenos_Aires', label: '(GMT-03:00) Buenos Aires' },
{ value: 'America/Sao_Paulo', label: '(GMT-03:00) São Paulo' },
{ value: 'Atlantic/Azores', label: '(GMT-01:00) Azores' },
{ value: 'UTC', label: '(GMT+00:00) UTC' },
{ value: 'Europe/London', label: '(GMT+00:00) Londres' },
{ value: 'Europe/Madrid', label: '(GMT+01:00) Madrid' },
{ value: 'Europe/Paris', label: '(GMT+01:00) París' },
{ value: 'Europe/Berlin', label: '(GMT+01:00) Berlín' },
{ value: 'Europe/Rome', label: '(GMT+01:00) Roma' },
{ value: 'Africa/Lagos', label: '(GMT+01:00) Lagos' },
{ value: 'Europe/Istanbul', label: '(GMT+03:00) Estambul' },
{ value: 'Asia/Dubai', label: '(GMT+04:00) Dubái' },
{ value: 'Asia/Kolkata', label: '(GMT+05:30) India' },
{ value: 'Asia/Bangkok', label: '(GMT+07:00) Bangkok' },
{ value: 'Asia/Shanghai', label: '(GMT+08:00) Shanghái' },
{ value: 'Asia/Tokyo', label: '(GMT+09:00) Tokio' },
{ value: 'Australia/Sydney', label: '(GMT+10:00) Sídney' },
{ value: 'Pacific/Auckland', label: '(GMT+12:00) Auckland' },
];
function getDayLabel(date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
if (target.getTime() === today.getTime()) return 'Hoy';
if (target.getTime() === tomorrow.getTime()) return 'Mañana';
return DAYS[target.getDay()];
}
function formatDate(date) {
return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
}
function detectTimezone() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (TIMEZONES.find(t => t.value === tz)) return tz;
return 'UTC';
} catch {
return 'UTC';
}
}
export default function ScheduleWeekModal({ isOpen, onClose, clips, jobId, uploadPostKey, uploadUserId }) {
const [time, setTime] = useState('12:00');
const [timezone, setTimezone] = useState(detectTimezone);
const [platforms, setPlatforms] = useState({
tiktok: true,
instagram: true,
youtube: true
});
const [startOffset, setStartOffset] = useState(1);
const schedule = useMemo(() => {
if (!clips) return [];
return clips.map((clip, i) => {
const date = new Date();
date.setDate(date.getDate() + startOffset + i);
date.setHours(0, 0, 0, 0);
return { clip, index: i, date };
});
}, [clips, startOffset]);
const [scheduling, setScheduling] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, results: [] });
const [done, setDone] = useState(false);
// Reset state when modal reopens
const prevOpen = React.useRef(false);
React.useEffect(() => {
if (isOpen && !prevOpen.current) {
setScheduling(false);
setDone(false);
setProgress({ current: 0, total: 0, results: [] });
}
prevOpen.current = isOpen;
}, [isOpen]);
if (!isOpen) return null;
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
const handleScheduleAll = async () => {
if (!uploadPostKey || !uploadUserId) return;
if (selectedPlatforms.length === 0) return;
setScheduling(true);
setDone(false);
const total = schedule.length;
setProgress({ current: 0, total, results: [] });
const results = [];
for (let i = 0; i < schedule.length; i++) {
const { clip, index, date } = schedule[i];
// Build local datetime string: "2026-04-06T12:00:00"
// Upload-Post accepts this + timezone IANA parameter
const pad = (n) => String(n).padStart(2, '0');
const scheduledDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${time}:00`;
const payload = {
job_id: jobId,
clip_index: index,
api_key: uploadPostKey,
user_id: uploadUserId,
platforms: selectedPlatforms,
title: clip.video_title_for_youtube_short || 'Viral Short',
description: clip.video_description_for_instagram || clip.video_description_for_tiktok || '',
scheduled_date: scheduledDate,
timezone
};
try {
const res = await fetch(getApiUrl('/api/social/post'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText);
}
results.push({ index: i, success: true });
} catch (e) {
results.push({ index: i, success: false, error: e.message });
}
setProgress({ current: i + 1, total, results: [...results] });
}
setDone(true);
setScheduling(false);
};
const successCount = progress.results.filter(r => r.success).length;
const failCount = progress.results.filter(r => !r.success).length;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-lg shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
<button
onClick={onClose}
disabled={scheduling}
className="absolute top-4 right-4 text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={20} />
</button>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
<Calendar size={20} className="text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Programar Semana</h3>
<p className="text-xs text-zinc-500">{clips?.length || 0} clips &middot; 1 por día</p>
</div>
</div>
{!uploadPostKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configura tu API Key de Upload-Post en Settings primero.</div>
</div>
)}
{/* Time + Timezone */}
<div className="mb-5 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
<Clock size={14} className="text-purple-400" />
Hora
</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
disabled={scheduling}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
<Globe size={14} className="text-indigo-400" />
Zona horaria
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
disabled={scheduling}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-indigo-500/50 appearance-none cursor-pointer"
>
{TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
</div>
{/* Start day offset */}
<div className="mb-5 flex items-center justify-between">
<span className="text-xs font-bold text-zinc-400">Empezar desde</span>
<div className="flex items-center gap-2">
<button
onClick={() => setStartOffset(Math.max(1, startOffset - 1))}
disabled={startOffset <= 1 || scheduling}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm text-white font-medium min-w-[90px] text-center">
{(() => {
const d = new Date();
d.setDate(d.getDate() + startOffset);
return `${getDayLabel(d)} ${formatDate(d)}`;
})()}
</span>
<button
onClick={() => setStartOffset(startOffset + 1)}
disabled={scheduling}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/* Calendar grid */}
<div className="mb-5 space-y-2">
{schedule.map(({ clip, index, date }) => (
<div key={index} className="flex items-center gap-3 p-3 bg-white/5 rounded-xl border border-white/5 hover:border-white/10 transition-colors">
<div className="w-14 shrink-0 text-center">
<div className="text-[10px] font-bold text-purple-400 uppercase">{getDayLabel(date)}</div>
<div className="text-lg font-bold text-white leading-tight">{date.getDate()}</div>
<div className="text-[10px] text-zinc-500">{MONTHS[date.getMonth()]}</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-white truncate">
Clip {index + 1}
</div>
<div className="text-[10px] text-zinc-500 truncate">
{clip.video_title_for_youtube_short || 'Viral Short'}
</div>
<div className="text-[10px] text-zinc-600 mt-0.5">
{time}h &middot; {TIMEZONES.find(t => t.value === timezone)?.label || timezone}
</div>
</div>
<div className="shrink-0">
{progress.results[index]?.success === true && (
<CheckCircle size={18} className="text-green-400" />
)}
{progress.results[index]?.success === false && (
<AlertCircle size={18} className="text-red-400" />
)}
{scheduling && progress.current === index && (
<Loader2 size={18} className="text-purple-400 animate-spin" />
)}
{!scheduling && progress.results[index] === undefined && (
<div className="w-4 h-4 rounded-full border-2 border-zinc-700" />
)}
</div>
</div>
))}
</div>
{/* Platforms */}
<div className="mb-5">
<label className="block text-xs font-bold text-zinc-400 mb-2">Plataformas</label>
<div className="flex gap-2">
<button
onClick={() => setPlatforms(p => ({ ...p, tiktok: !p.tiktok }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.tiktok ? 'bg-cyan-500/10 border-cyan-500/30 text-cyan-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Video size={14} /> TikTok
</button>
<button
onClick={() => setPlatforms(p => ({ ...p, instagram: !p.instagram }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.instagram ? 'bg-pink-500/10 border-pink-500/30 text-pink-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Instagram size={14} /> Instagram
</button>
<button
onClick={() => setPlatforms(p => ({ ...p, youtube: !p.youtube }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.youtube ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Youtube size={14} /> YouTube
</button>
</div>
</div>
{/* Progress bar */}
{(scheduling || done) && (
<div className="mb-5">
<div className="flex items-center justify-between text-xs text-zinc-400 mb-2">
<span>{scheduling ? 'Programando...' : 'Completado'}</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="w-full h-2 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${done && failCount === 0 ? 'bg-green-500' : done && failCount > 0 ? 'bg-yellow-500' : 'bg-purple-500'}`}
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
{done && (
<div className="mt-3 text-xs text-center">
{failCount === 0 ? (
<span className="text-green-400">Todos los clips programados correctamente</span>
) : (
<span className="text-yellow-400">{successCount} programados, {failCount} fallidos</span>
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={scheduling}
className="flex-1 py-3 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{done ? 'Cerrar' : 'Cancelar'}
</button>
{!done ? (
<button
onClick={handleScheduleAll}
disabled={scheduling || !uploadPostKey || selectedPlatforms.length === 0}
className="flex-1 py-3 bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-400 hover:to-indigo-500 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{scheduling ? (
<>
<Loader2 size={16} className="animate-spin" />
Programando...
</>
) : (
<>
<Calendar size={16} />
Programar {clips?.length || 0} Clips
</>
)}
</button>
) : (
<a
href="https://app.upload-post.com/calendar"
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-3 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-400 hover:to-purple-500 text-white rounded-xl font-bold transition-all flex items-center justify-center gap-2 no-underline"
>
<ExternalLink size={16} />
Ver Calendario
</a>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import { X, Type, Loader2 } from 'lucide-react';
import { getApiUrl } from '../config';
import RemotionPreview from './RemotionPreview';
const FONT_OPTIONS = [
{ value: 'Verdana', label: 'Verdana' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Impact', label: 'Impact' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Courier New', label: 'Courier New' },
];
const COLOR_PRESETS = [
{ color: '#FFFFFF', label: 'White' },
{ color: '#FFFF00', label: 'Yellow' },
{ color: '#00FFFF', label: 'Cyan' },
{ color: '#00FF00', label: 'Green' },
{ color: '#FF0000', label: 'Red' },
{ color: '#FF69B4', label: 'Pink' },
];
const ANIMATION_OPTIONS = [
{ value: 'pop', label: 'Pop' },
{ value: 'word-highlight', label: 'Glow' },
{ value: 'karaoke', label: 'Karaoke' },
{ value: 'none', label: 'None' },
];
export default function SubtitleModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, jobId, clipIndex, existingHook }) {
const [position, setPosition] = useState('bottom');
const [fontSize, setFontSize] = useState(24);
const [fontName, setFontName] = useState('Verdana');
const [fontColor, setFontColor] = useState('#FFFFFF');
const [highlightColor, setHighlightColor] = useState('#FFDD00');
const [borderColor, setBorderColor] = useState('#000000');
const [borderWidth, setBorderWidth] = useState(2);
const [bgColor, setBgColor] = useState('#000000');
const [bgOpacity, setBgOpacity] = useState(0.0);
const [animation, setAnimation] = useState('pop');
const [showTextEditor, setShowTextEditor] = useState(false);
// Remotion preview state
const [captions, setCaptions] = useState([]);
const [originalCaptions, setOriginalCaptions] = useState([]);
const [editableText, setEditableText] = useState('');
const [durationSec, setDurationSec] = useState(30);
const [captionsLoading, setCaptionsLoading] = useState(false);
const [useRemotionPreview, setUseRemotionPreview] = useState(false);
// Fetch word-level captions when modal opens
useEffect(() => {
if (!isOpen || !jobId || clipIndex === undefined) return;
setCaptionsLoading(true);
fetch(getApiUrl(`/api/clip/${jobId}/${clipIndex}/transcript`))
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data && data.captions && data.captions.length > 0) {
setCaptions(data.captions);
setOriginalCaptions(data.captions);
setEditableText(data.captions.map(c => c.text).join(' '));
setDurationSec(data.durationSec || 30);
setUseRemotionPreview(true);
} else {
setUseRemotionPreview(false);
}
})
.catch(() => setUseRemotionPreview(false))
.finally(() => setCaptionsLoading(false));
}, [isOpen, jobId, clipIndex]);
// When user edits text, redistribute words across original timestamps
const handleTextEdit = (newText) => {
setEditableText(newText);
const newWords = newText.split(/\s+/).filter(w => w.length > 0);
if (newWords.length === 0 || originalCaptions.length === 0) {
setCaptions([]);
return;
}
// Distribute new words across the time span of original captions
const totalDurationMs = originalCaptions[originalCaptions.length - 1].endMs - originalCaptions[0].startMs;
const startMs = originalCaptions[0].startMs;
const wordDurationMs = totalDurationMs / newWords.length;
const newCaptions = newWords.map((word, i) => ({
text: word,
startMs: Math.round(startMs + i * wordDurationMs),
endMs: Math.round(startMs + (i + 1) * wordDurationMs),
}));
setCaptions(newCaptions);
};
if (!isOpen) return null;
// Build subtitle config for Remotion
const subtitleConfig = {
captions,
position,
style: {
fontFamily: fontName,
fontSize: fontSize * 2.2, // Scale up for 1080p (modal fontSize is for small preview)
fontColor,
highlightColor,
borderColor,
borderWidth: borderWidth * 1.5,
bgColor,
bgOpacity,
animation,
},
};
// Fallback: static CSS preview (same as original)
const bw = Math.max(borderWidth, 0);
const bc = borderColor;
const outlineShadow = bw > 0 ? [
`-${bw}px -${bw}px 0 ${bc}`, `${bw}px -${bw}px 0 ${bc}`,
`-${bw}px ${bw}px 0 ${bc}`, `${bw}px ${bw}px 0 ${bc}`,
`0 -${bw}px 0 ${bc}`, `0 ${bw}px 0 ${bc}`,
`-${bw}px 0 0 ${bc}`, `${bw}px 0 0 ${bc}`,
].join(', ') : 'none';
const fallbackPreviewStyle = {
fontFamily: fontName,
color: fontColor,
fontSize: '20px',
fontWeight: 'bold',
maxWidth: '85%',
padding: '6px 12px',
borderRadius: '4px',
textAlign: 'center',
lineHeight: '1.3',
...(bgOpacity > 0
? {
backgroundColor: `${bgColor}${Math.round(bgOpacity * 255).toString(16).padStart(2, '0')}`,
textShadow: 'none',
}
: { textShadow: outlineShadow }
),
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-5xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
<button
onClick={onClose}
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
>
<X size={20} />
</button>
{/* Left: Preview */}
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
{captionsLoading ? (
<div className="flex items-center gap-2 text-zinc-400">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">Loading preview...</span>
</div>
) : useRemotionPreview ? (
<RemotionPreview
videoUrl={videoUrl}
durationInSeconds={durationSec}
subtitles={subtitleConfig}
hook={existingHook || null}
/>
) : (
<>
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col items-center justify-center
${position === 'top' ? 'top-20' : ''}
${position === 'middle' ? 'top-0 bottom-0' : ''}
${position === 'bottom' ? 'bottom-20' : ''}
`}>
<span style={fallbackPreviewStyle}>
This is how your subtitles<br/>will appear on the video
</span>
</div>
</>
)}
</div>
{/* Right: Controls */}
<div className="w-full md:w-80 flex flex-col">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2 shrink-0">
<Type className="text-primary" /> Auto Subtitles
</h3>
<div className="space-y-5 flex-1 overflow-y-auto custom-scrollbar pr-1">
{/* Position Selector */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Position</label>
<div className="grid grid-cols-3 gap-2">
{['top', 'middle', 'bottom'].map((pos) => (
<button
key={pos}
onClick={() => setPosition(pos)}
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${position === pos ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
>
{pos.charAt(0).toUpperCase() + pos.slice(1)}
</button>
))}
</div>
</div>
{/* Animation Style (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Animation</label>
<div className="grid grid-cols-2 gap-2">
{ANIMATION_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setAnimation(opt.value)}
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${animation === opt.value ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Editable Transcript (collapsible) */}
{useRemotionPreview && (
<div>
<button
type="button"
onClick={() => setShowTextEditor(!showTextEditor)}
className="w-full flex items-center justify-between text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"
>
<span>Edit Text ({captions.length} words)</span>
<span className={`transition-transform ${showTextEditor ? 'rotate-180' : ''}`}></span>
</button>
{showTextEditor && (
<textarea
value={editableText}
onChange={(e) => handleTextEdit(e.target.value)}
rows={5}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-primary/50 resize-none leading-relaxed animate-[fadeIn_0.15s_ease-out]"
placeholder="Edit subtitle text..."
/>
)}
</div>
)}
{/* Font Family */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Font</label>
<select
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50"
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
))}
</select>
</div>
{/* Text Color */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Text Color</label>
<div className="flex flex-wrap gap-2">
{COLOR_PRESETS.map((c) => (
<button
key={c.color}
onClick={() => setFontColor(c.color)}
className={`w-7 h-7 rounded-full border-2 transition-all ${fontColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
style={{ backgroundColor: c.color }}
title={c.label}
/>
))}
<label className="w-7 h-7 rounded-full border-2 border-dashed border-white/20 cursor-pointer flex items-center justify-center hover:border-white/50 transition-all overflow-hidden relative" title="Custom color">
<span className="text-[10px] text-zinc-400">+</span>
<input type="color" value={fontColor} onChange={(e) => setFontColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
</div>
{/* Highlight Color (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Highlight Color</label>
<div className="flex flex-wrap gap-2">
{[{ color: '#FFDD00', label: 'Gold' }, { color: '#FF4444', label: 'Red' }, { color: '#00FF88', label: 'Green' }, { color: '#00BBFF', label: 'Blue' }, { color: '#FF69B4', label: 'Pink' }].map((c) => (
<button
key={c.color}
onClick={() => setHighlightColor(c.color)}
className={`w-7 h-7 rounded-full border-2 transition-all ${highlightColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
style={{ backgroundColor: c.color }}
title={c.label}
/>
))}
</div>
</div>
{/* Border / Outline */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Border</label>
<div className="flex items-center gap-3">
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Border color">
<div className="w-full h-full" style={{ backgroundColor: borderColor }} />
<input type="color" value={borderColor} onChange={(e) => setBorderColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
<div className="flex-1">
<input
type="range"
min="0"
max="5"
value={borderWidth}
onChange={(e) => setBorderWidth(parseInt(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>None</span>
<span>Thick</span>
</div>
</div>
</div>
</div>
{/* Background Box */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Background Box</label>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={bgOpacity > 0} onChange={(e) => setBgOpacity(e.target.checked ? 0.5 : 0)} className="sr-only peer" />
<div className="w-8 h-4 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[0px] after:left-[0px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
{bgOpacity > 0 && (
<div className="space-y-3 animate-[fadeIn_0.2s_ease-out]">
<div className="flex items-center gap-3">
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Background color">
<div className="w-full h-full" style={{ backgroundColor: bgColor }} />
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
<div className="flex-1">
<input
type="range"
min="10"
max="100"
value={Math.round(bgOpacity * 100)}
onChange={(e) => setBgOpacity(parseInt(e.target.value) / 100)}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>Transparent</span>
<span>{Math.round(bgOpacity * 100)}%</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<button
onClick={() => onGenerate({
position, fontSize, fontName, fontColor, borderColor, borderWidth, bgColor, bgOpacity,
// Remotion data
remotion: useRemotionPreview ? subtitleConfig : null,
})}
disabled={isProcessing}
className="w-full py-3 mt-4 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-400 hover:to-orange-400 text-black font-bold rounded-xl shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 shrink-0"
>
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Type size={20} />}
{isProcessing ? 'Generating...' : 'Generate Subtitles'}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { X, Loader2, Globe, Languages, AlertCircle } from 'lucide-react';
import { getApiUrl } from '../config';
const LANGUAGES = {
"es": "Spanish",
"fr": "French",
"de": "German",
"it": "Italian",
"pt": "Portuguese",
"pl": "Polish",
"hi": "Hindi",
"ja": "Japanese",
"ko": "Korean",
"zh": "Chinese",
"ar": "Arabic",
"ru": "Russian",
"tr": "Turkish",
"nl": "Dutch",
"sv": "Swedish",
"id": "Indonesian",
"fil": "Filipino",
"ms": "Malay",
"vi": "Vietnamese",
"th": "Thai",
"uk": "Ukrainian",
"el": "Greek",
"cs": "Czech",
"fi": "Finnish",
"ro": "Romanian",
"da": "Danish",
"bg": "Bulgarian",
"hr": "Croatian",
"sk": "Slovak",
"ta": "Tamil",
"en": "English",
};
export default function TranslateModal({ isOpen, onClose, onTranslate, isProcessing, videoUrl, hasApiKey }) {
const [targetLanguage, setTargetLanguage] = useState('es');
if (!isOpen) return null;
const handleSubmit = () => {
console.log('[TranslateModal] handleSubmit called, targetLanguage:', targetLanguage);
onTranslate({ targetLanguage });
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative">
<button
onClick={onClose}
disabled={isProcessing}
className="absolute top-4 right-4 text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={20} />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center">
<Languages size={20} className="text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Dub Voice</h3>
<p className="text-xs text-zinc-500">AI voice translation by ElevenLabs</p>
</div>
</div>
{!hasApiKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configure ElevenLabs API Key in Settings first.</div>
</div>
)}
{/* Preview */}
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative">
<video
src={videoUrl}
className="w-full h-full object-contain"
muted
playsInline
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent pointer-events-none" />
</div>
{/* Language Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-zinc-400 mb-2">
<Globe size={14} className="inline mr-2" />
Target Language
</label>
<select
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-green-500/50 appearance-none cursor-pointer"
disabled={isProcessing}
>
{Object.entries(LANGUAGES).sort((a, b) => a[1].localeCompare(b[1])).map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
</div>
{/* Info */}
<div className="mb-6 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-xs text-green-400">
The audio will be dubbed with AI-generated voice in the selected language, matching the original speaker's characteristics.
</p>
</div>
{/* Processing State */}
{isProcessing && (
<div className="mb-4 p-4 bg-white/5 rounded-lg border border-white/10">
<div className="flex items-center gap-3">
<Loader2 size={20} className="text-green-400 animate-spin" />
<div>
<p className="text-sm text-white font-medium">Dubbing audio...</p>
<p className="text-xs text-zinc-500">This may take a few minutes</p>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isProcessing}
className="flex-1 py-3 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-xl font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isProcessing || !hasApiKey}
className="flex-1 py-3 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
Dubbing...
</>
) : (
<>
<Languages size={16} />
Dub Voice
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,237 @@
import React, { useState, useEffect, useRef } from 'react';
import { Film, Download, Copy, Check, ExternalLink, Loader2, Play, User } from 'lucide-react';
import { getApiUrl } from '../config';
export default function UGCGallery() {
const [tab, setTab] = useState('videos');
const [videos, setVideos] = useState([]);
const [avatars, setAvatars] = useState([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
fetch(getApiUrl('/api/saasshorts/gallery?limit=100')).then(r => r.ok ? r.json() : { videos: [] }),
fetch(getApiUrl('/api/saasshorts/actor-gallery')).then(r => r.ok ? r.json() : { images: [] }),
])
.then(([vData, aData]) => {
setVideos(vData.videos || []);
setAvatars(aData.images || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleCopy = (text, id) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(''), 2000);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-violet-400" />
<span className="ml-2 text-zinc-400">Loading gallery...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-zinc-200">UGC Gallery</h2>
<p className="text-xs text-zinc-500">{videos.length} videos · {avatars.length} avatars</p>
</div>
<a
href={getApiUrl('/gallery')}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-violet-400 hover:text-violet-300 flex items-center gap-1"
>
<ExternalLink size={12} /> Public Gallery
</a>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-white/5 p-1 rounded-lg w-fit">
<button
onClick={() => setTab('videos')}
className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all ${
tab === 'videos' ? 'bg-violet-500/20 text-violet-300' : 'text-zinc-400 hover:text-white'
}`}
>
<Film size={12} className="inline mr-1.5" />Videos ({videos.length})
</button>
<button
onClick={() => setTab('avatars')}
className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all ${
tab === 'avatars' ? 'bg-violet-500/20 text-violet-300' : 'text-zinc-400 hover:text-white'
}`}
>
<User size={12} className="inline mr-1.5" />Avatars ({avatars.length})
</button>
</div>
{/* Videos Tab */}
{tab === 'videos' && (
videos.length === 0 ? (
<div className="text-center py-16">
<Film size={40} className="mx-auto text-zinc-700 mb-3" />
<p className="text-sm text-zinc-500">No videos yet. Generate one from AI Shorts.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{videos.map((video) => (
<VideoCard key={video.video_id} video={video} copied={copied} onCopy={handleCopy} />
))}
</div>
)
)}
{/* Avatars Tab */}
{tab === 'avatars' && (
avatars.length === 0 ? (
<div className="text-center py-16">
<User size={40} className="mx-auto text-zinc-700 mb-3" />
<p className="text-sm text-zinc-500">No avatars yet. Generate actors from AI Shorts.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{avatars.map((avatar, i) => (
<AvatarCard key={avatar.key || i} avatar={avatar} copied={copied} onCopy={handleCopy} />
))}
</div>
)
)}
</div>
);
}
function AvatarCard({ avatar, copied, onCopy }) {
return (
<div className="group rounded-xl overflow-hidden border border-white/10 bg-white/5 hover:border-white/20 transition-all">
<div className="aspect-[3/4] bg-black">
<img src={avatar.url} alt="Avatar" className="w-full h-full object-cover" />
</div>
<div className="p-2 space-y-1">
{avatar.description ? (
<div className="relative pr-4">
<p className="text-[9px] text-zinc-400 line-clamp-2">{avatar.description}</p>
<button
onClick={() => onCopy(avatar.description, `avatar-${avatar.key}`)}
className="absolute top-0 right-0 p-0.5 text-zinc-600 hover:text-zinc-300"
title="Copy prompt"
>
{copied === `avatar-${avatar.key}` ? <Check size={9} /> : <Copy size={9} />}
</button>
</div>
) : (
<p className="text-[9px] text-zinc-600 italic">No description</p>
)}
<a
href={avatar.url}
download
className="block text-center text-[9px] bg-white/5 hover:bg-white/10 text-zinc-400 py-1 rounded-md transition-colors"
>
<Download size={9} className="inline mr-0.5" />Download
</a>
</div>
</div>
);
}
function VideoCard({ video, copied, onCopy }) {
const videoRef = useRef(null);
const [playing, setPlaying] = useState(false);
const handleMouseEnter = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
setPlaying(true);
}
};
const handleMouseLeave = () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
setPlaying(false);
}
};
const mode = video.video_mode;
const caption = video.caption || '';
const hashtags = (video.hashtags || []).join(' ');
return (
<div className="group rounded-xl overflow-hidden border border-white/10 bg-white/5 hover:border-white/20 transition-all">
<div
className="relative aspect-[9/16] bg-black cursor-pointer"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<video
ref={videoRef}
src={video.video_url}
poster={video.actor_url}
muted
playsInline
preload="metadata"
className="w-full h-full object-cover"
/>
{!playing && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Play size={20} className="text-white/70" />
</div>
)}
<div className="absolute top-1.5 right-1.5">
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded-full ${
mode === 'lowcost' ? 'bg-green-500 text-black' : 'bg-violet-500 text-white'
}`}>
{mode === 'lowcost' ? 'LOW COST' : 'PREMIUM'}
</span>
</div>
</div>
<div className="p-2 space-y-1">
<h3 className="text-[11px] font-semibold text-zinc-200 truncate">{video.title || 'Untitled'}</h3>
<p className="text-[9px] text-zinc-500">
{video.duration?.toFixed(0)}s · ${video.cost_estimate?.total?.toFixed(2) || '?'}
</p>
{caption && (
<div className="relative pr-4">
<p className="text-[9px] text-zinc-400 line-clamp-2">{caption}</p>
<button
onClick={() => onCopy(`${caption}\n${hashtags}`, `caption-${video.video_id}`)}
className="absolute top-0 right-0 p-0.5 text-zinc-600 hover:text-zinc-300"
title="Copy caption"
>
{copied === `caption-${video.video_id}` ? <Check size={9} /> : <Copy size={9} />}
</button>
</div>
)}
<div className="flex gap-1 pt-0.5">
<a
href={video.video_url}
download
className="flex-1 text-center text-[9px] bg-white/5 hover:bg-white/10 text-zinc-400 py-1 rounded-md transition-colors"
>
<Download size={9} className="inline mr-0.5" />Download
</a>
<a
href={getApiUrl(`/video/${video.video_id}`)}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-[9px] bg-violet-500/10 hover:bg-violet-500/20 text-violet-400 py-1 rounded-md transition-colors"
>
<ExternalLink size={9} className="inline mr-0.5" />View
</a>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More