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:
@@ -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
|
||||
|
||||
@@ -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 (0–100), 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` 0–100, `title`, `hook_sentence`, `virality_reason`. Aim for 30–75s 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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
output/
|
||||
*.mp4
|
||||
.DS_Store
|
||||
@@ -0,0 +1,307 @@
|
||||
# AI YouTube Shorts Generator
|
||||
|
||||
[](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 Clip–style 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
|
||||
|
||||

|
||||
|
||||
<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 Clip–style 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 0–100
|
||||
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)
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
_______ __ __ __ __
|
||||
/ \ / | / | / | / |
|
||||
$$$$$$$ | ______ ______ $$/ _______ ______ ______ _$$ |_ $$/ _______ ______ _$$ |_ ______ ______
|
||||
$$ |__$$ | / \ / \ / |/ \ / \ / \ / $$ | / |/ \ / \ / $$ | / \ / \
|
||||
$$ $$< /$$$$$$ |$$$$$$ |$$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |$$$$$$/ $$ |$$$$$$$ | $$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ |
|
||||
$$$$$$$ |$$ | $$/ / $$ |$$ |$$ | $$ |$$ | $$/ $$ | $$ | $$ | __ $$ |$$ | $$ | / $$ | $$ | __ $$ | $$ |$$ | $$/
|
||||
$$ |__$$ |$$ | /$$$$$$$ |$$ |$$ | $$ |$$ | $$ \__$$ | $$ |/ |$$ |$$ | $$ |/$$$$$$$ | $$ |/ |$$ \__$$ |$$ |
|
||||
$$ $$/ $$ | $$ $$ |$$ |$$ | $$ |$$ | $$ $$/ $$ $$/ $$ |$$ | $$ |$$ $$ | $$ $$/ $$ $$/ $$ |
|
||||
$$$$$$$/ $$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$/ $$$$/ $$/ $$/ $$/ $$$$$$$/ $$$$/ $$$$$$/ $$/
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 0–5 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
selenium==4.9.0
|
||||
selenium-browser==0.0.15
|
||||
selenium-firefox==2.0.8
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
-1
Submodule openshorts deleted from fe87af6dd5
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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=...
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,297 @@
|
||||
# OpenShorts.app
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://opensource.org/)
|
||||
[](http://makeapullrequest.com)
|
||||
[](https://docs.docker.com/compose/)
|
||||
[](https://github.com/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
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
- 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:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
| [](https://openshorts.app/video/cdceec1b) | [](https://openshorts.app/video/d3a80b6b) | [](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
|
||||
|
||||
[](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
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 |
@@ -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/*
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+8362
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 · 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 · {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
Reference in New Issue
Block a user