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

This commit is contained in:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
Submodule openshorts deleted from fe87af6dd5
+55
View File
@@ -0,0 +1,55 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv/
ENV/
.eggs/
*.egg-info/
.pytest_cache/
# Node
node_modules/
remotion/node_modules/
render-service/node_modules/
dashboard/node_modules/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Video files (will be mounted via volume)
*.mp4
*.avi
*.mov
*.mkv
*.gif
videos/
# Model files (downloaded at build time)
*.pt
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Docs
README.md
*.md
+9
View File
@@ -0,0 +1,9 @@
# AWS S3 (optional — for clip backup/gallery)
AWS_ACCESS_KEY_ID=your_aws_access_key_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
AWS_REGION=eu-west-3
AWS_S3_BUCKET=your-bucket-name
AWS_S3_PUBLIC_BUCKET=your-public-bucket-name
# YouTube cookies (optional — paste Netscape-format cookies to bypass bot detection)
# YOUTUBE_COOKIES=...
+42
View File
@@ -0,0 +1,42 @@
# Video files
*.mp4
*.webm
*.mov
*.mkv
# YOLO model
*.pt
# Python virtual environment
.venv/
__pycache__/
*.pyc
# Temporary files / runtime dirs
temp_*
uploads/
downloads/
videos/
output/
# OS / IDE
.DS_Store
.idea/
.vscode/
# Secrets
.env
# Generated metadata
*_metadata.json
# Cache dirs
.cache/
.config/
# Multi-agent Skills
.agents/
.agent/
.claude/
skills/
skills-lock.json
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OpenShorts is an AI-powered vertical video generator that transforms long YouTube videos or local uploads into viral-ready short clips (9:16 format) for TikTok, Instagram Reels, and YouTube Shorts. Uses Google Gemini 2.0 Flash for viral moment detection and title generation.
## Development Commands
### Local Development (Docker)
```bash
docker compose up --build # Build and run full stack
```
- Backend: http://localhost:8000 (FastAPI/Uvicorn)
- Frontend: http://localhost:5175 (Vite proxies API calls to backend)
### Frontend Only (Dashboard)
```bash
cd dashboard
npm install
npm run dev # Dev server with HMR (port 5173)
npm run build # Production build
npm run lint # ESLint (strict, --max-warnings 0)
```
### Backend Only
```bash
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000
```
## Architecture
### Core Processing Pipeline
1. **Ingest** - YouTube download (yt-dlp) or local upload
2. **Transcription** - faster-whisper with word-level timestamps
3. **Scene Detection** - PySceneDetect for segment boundaries
4. **AI Analysis** - Gemini identifies 3-15 viral moments (15-60 sec each)
5. **FFmpeg Extraction** - Precise clip cutting
6. **AI Cropping** - Vertical reframing with subject tracking
7. **Effects/Subtitles** - Optional AI-generated FFmpeg filters
8. **Hook Overlay** - Text overlays with styled fonts
9. **Voice Dubbing** - Optional ElevenLabs AI translation (30+ languages)
10. **S3 Backup** - Silent background upload
11. **Social Distribution** - Upload-Post API (async upload)
### Key Files
| File | Purpose |
|------|---------|
| `main.py` | Core video processing: transcription, scene detection, clip extraction, vertical reframing |
| `app.py` | FastAPI server with async job queue and REST endpoints |
| `editor.py` | Gemini AI integration for dynamic video effects (FFmpeg filter generation) |
| `hooks.py` | Hook text overlay generation with font rendering |
| `s3_uploader.py` | AWS S3 upload with caching |
| `subtitles.py` | SRT generation, FFmpeg subtitle burning, and dubbed video transcription |
| `translate.py` | ElevenLabs dubbing API for AI voice translation |
| `dashboard/src/App.jsx` | Main React component with state management |
| `dashboard/src/components/TranslateModal.jsx` | Voice dubbing UI with language selection |
### Dual-Mode Video Reframing
- **TRACK Mode** (single subject): MediaPipe face detection + YOLOv8 fallback with "Heavy Tripod" stabilization
- **GENERAL Mode** (groups/landscapes): Blurred background layout preserving full width
### Key Classes
- `SmoothedCameraman` - Stabilized camera movement with safe zone logic (prevents jitter)
- `SpeakerTracker` - Prevents rapid speaker switching, handles temporary occlusions
### API Endpoints
| Method | Route | Purpose |
|--------|-------|---------|
| POST | `/api/process` | Submit video for processing |
| GET | `/api/status/{job_id}` | Poll job status and logs |
| POST | `/api/edit` | Apply AI video effects |
| POST | `/api/subtitle` | Generate and apply subtitles (auto-transcribes dubbed videos) |
| POST | `/api/hook` | Add text hook overlays |
| POST | `/api/translate` | AI voice dubbing via ElevenLabs |
| GET | `/api/translate/languages` | List supported dubbing languages |
| POST | `/api/social/post` | Post to social media (async upload) |
### Concurrency Model
Async job queue with semaphore-based concurrency control. Configure via `MAX_CONCURRENT_JOBS` env var (default: 5). Jobs auto-cleanup after 1 hour.
## Environment Variables
**Server-side (.env):**
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_S3_BUCKET` - For S3 backup
- `MAX_CONCURRENT_JOBS` - Concurrent processing limit (default: 5)
- `VITE_API_URL` - Production API URL override
**Client-side (localStorage, encrypted):**
- `GEMINI_API_KEY` - Google Gemini API key (required)
- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice dubbing (optional)
- `UPLOAD_POST_API_KEY` - Upload-Post API key for social posting (optional)
> API keys are stored encrypted in the browser and sent via headers only when needed. Never stored server-side.
## Tech Stack
- **Backend:** Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx
- **Frontend:** React 18, Vite 4, Tailwind CSS 3.4
- **External APIs:** Google Gemini, ElevenLabs Dubbing, Upload-Post
- **Infrastructure:** Docker + Docker Compose, AWS S3
+64
View File
@@ -0,0 +1,64 @@
# Multi-stage build for smaller final image
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
# Copy and install Python dependencies
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
# Final stage
FROM python:3.11-slim
WORKDIR /app
# Install FFmpeg, OpenCV dependencies, and Node.js (for yt-dlp JS challenges)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual env from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Always upgrade yt-dlp to latest (YouTube bot-detection changes frequently)
RUN pip install --upgrade --no-cache-dir yt-dlp
# Copy application code
COPY . .
# Create a non-root user (Moved up)
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
# Create directories including Ultralytics cache config
RUN mkdir -p /app/uploads /app/output /tmp/Ultralytics
# Fix permissions: /app for code/uploads, /tmp/Ultralytics for AI cache
RUN chown -R appuser:appuser /app /tmp/Ultralytics
# Switch to non-root user
USER appuser
# Pre-download YOLO model on build (now running as appuser)
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"
# Expose FastAPI port
EXPOSE 8000
# Run FastAPI app
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 OpenShorts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+297
View File
@@ -0,0 +1,297 @@
# OpenShorts.app
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Open Source](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://opensource.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://docs.docker.com/compose/)
[![GitHub stars](https://img.shields.io/github/stars/mutonby/openshorts?style=social)](https://github.com/mutonby/openshorts)
[![Last Commit](https://img.shields.io/github/last-commit/mutonby/openshorts)](https://github.com/mutonby/openshorts/commits/main)
**Free & open source AI video platform** with 3 tools in one: **Clip Generator**, **AI Shorts (UGC videos with AI actors)**, and **YouTube Studio**. Self-hosted with Docker. No watermarks, no limits.
https://github.com/user-attachments/assets/b45fa983-16b4-48b5-ac5b-a267836b9ad9
### Video Tutorial: How it works
[![OpenShorts Tutorial](https://img.youtube.com/vi/xlyjD1qCaX0/maxresdefault.jpg)](https://www.youtube.com/watch?v=xlyjD1qCaX0 "Click to watch the video on YouTube")
*Click the image above to watch the full walkthrough.*
---
## 3 Tools in 1 Platform
### 1. Clip Generator
Turn your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready 9:16 shorts for TikTok, Instagram Reels, and YouTube Shorts.
![Clip Results](screenshots/clip-results.png)
### 2. AI Shorts (UGC Video Creator)
Generate marketing videos with AI actors for **any product or business**. No camera, no studio, no influencer budget. Just describe your product or paste a URL.
![AI Shorts Setup](screenshots/ai-shorts.png)
- **Two cost modes**: Low Cost (~$0.65/video) and Premium (~$2/video)
- Works for any business: SaaS, restaurants, e-commerce, coaching, local businesses
- AI-generated actors with lip-sync, voiceover, b-roll, and TikTok-style subtitles
- Choose from a shared avatar gallery or upload your own photo
- Publish directly to TikTok, Instagram, and YouTube
### 3. YouTube Studio
Complete free AI YouTube toolkit: thumbnails, titles, descriptions, and direct publishing.
![YouTube Studio](screenshots/youtube-studio.png)
- AI thumbnail generator with face overlay
- 10 viral title suggestions with refinement chat
- Auto-generated descriptions with chapter timestamps
- One-click publish to YouTube
### UGC Video Gallery
All generated videos and avatars are saved to a public gallery with SEO pages for each video.
![UGC Gallery](screenshots/ugc-gallery.png)
- Public gallery page with hover-to-play (`/gallery`)
- Individual SEO video pages with og:video meta tags (`/video/{id}`)
- JSON-LD structured data for search engines
- Avatar gallery with prompt history
---
## Key Features
### Clip Generator
- **Viral Moment Detection**: Google Gemini 3.0 Flash analyzes transcripts and scene boundaries to detect 3-15 high-potential moments
- **Smart 9:16 Cropping**: Dual-mode AI reframing — TRACK mode (MediaPipe + YOLOv8 face tracking) and GENERAL mode (blurred background)
- **Auto Subtitles**: faster-whisper with word-level timestamps, styled and burned into clips
- **AI Voice Dubbing**: ElevenLabs integration for 30+ languages with voice cloning
- **Hook Text Overlays**: AI-generated attention-grabbing text overlays
- **AI Video Effects**: Gemini-generated FFmpeg filters for professional effects
### AI Shorts Pipeline
1. **Analyze**: Scrape website URL + web research, or generate from manual description
2. **Script**: AI writes viral scripts (hook - problem - solution - CTA format)
3. **Actor**: Generate AI actors with Flux 2 Pro or select from shared gallery
4. **Voice**: ElevenLabs TTS voiceover (English/Spanish, male/female)
5. **Video**: Talking head generation (Hailuo 2.3 Fast img2video + VEED Lipsync)
6. **B-roll**: AI-generated visuals with Ken Burns effect
7. **Composite**: FFmpeg final assembly with subtitles and hook overlays
8. **Publish**: Direct posting to TikTok, Instagram Reels, YouTube Shorts via Upload-Post
### YouTube Studio
- AI-powered title generation with 10 viral options
- Interactive refinement chat for titles
- AI thumbnail generation with custom face + background
- Auto descriptions with chapter timestamps from Whisper transcript
- Direct YouTube publishing via Upload-Post
### Social Auto-Publishing
- **One-click posting** to TikTok, Instagram Reels, and YouTube Shorts simultaneously
- **Schedule uploads** for any date and time — plan your content calendar and let OpenShorts publish automatically
- **Multi-platform distribution** — publish to all your social networks at once from a single interface
- Upload-Post integration with async uploads
### Infrastructure
- S3 cloud backup (private bucket for clips, public bucket for gallery/avatars)
- SEO gallery pages served by FastAPI with JSON-LD structured data
- Shared avatar gallery across all users
- Async job queue with configurable concurrency
---
## Who Is This For?
- **Content creators** — Turn long videos into shorts automatically, publish to all platforms at once
- **Marketing agencies** — Generate UGC videos for clients at scale, no actors or studios needed
- **SaaS founders** — Create product demos and marketing shorts from just a URL
- **E-commerce brands** — Product videos with AI actors for TikTok Shop, Instagram, YouTube
- **Local businesses** — Restaurants, gyms, real estate, coaching — affordable video marketing
- **Developers** — Self-host, customize the pipeline, integrate via API
---
## AI Shorts Showcase
Videos generated with OpenShorts AI Shorts — no camera, no studio, no actors:
| | | |
|:---:|:---:|:---:|
| [![Biohacking for Investors](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/cdceec1b/actor.png)](https://openshorts.app/video/cdceec1b) | [![Secret Weapon for Devs](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/d3a80b6b/actor.png)](https://openshorts.app/video/d3a80b6b) | [![El Secreto de los Agentes de IA](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/8ab7de92/actor.png)](https://openshorts.app/video/8ab7de92) |
| **Biohacking for Investors** · LOW COST | **Secret Weapon for Devs** · LOW COST | **El Secreto de los Agentes de IA** · PREMIUM |
> Browse all videos at [openshorts.app/gallery](https://openshorts.app/gallery)
---
## OpenShorts vs Competitors
| Feature | OpenShorts | Opus Clip | CapCut | Vizard | Klap | Descript |
|---------|:---:|:---:|:---:|:---:|:---:|:---:|
| **Price** | **Free** | $15-29/mo | $8/mo | $15-20/mo | $23-63/mo | $24-65/mo |
| **Self-hosted** | **Yes** | No | No | No | No | No |
| **Open source** | **Yes** | No | No | No | No | No |
| **Watermark** | **Never** | Free tier | Some | Free tier | Free tier | Free tier |
| **Upload limits** | **None** | 10-30GB | Credit-based | 60min-10hr | 10-100 vids/mo | 60min-40hr |
| **AI clip detection** | Yes | Yes | Yes | Yes | Yes | Yes |
| **Smart 9:16 reframing** | Yes | Yes | Yes | Yes | Yes | No |
| **Auto subtitles** | Yes | Yes | Yes | Yes | Yes | Yes |
| **Voice dubbing (30+ langs)** | Yes | No | Pro only | No | Pro only | Business only |
| **AI UGC actors** | **Yes** | No | No | No | No | No |
| **AI video effects** | Yes | No | Yes | No | No | No |
| **Hook text overlays** | Yes | No | No | No | No | No |
| **YouTube Studio (titles, thumbnails)** | **Yes** | No | No | No | No | No |
| **Social auto-publishing** | Yes | Pro only | TikTok only | Paid only | Paid only | No |
| **Schedule uploads** | Yes | Pro only | No | Paid only | Paid only | No |
| **Data privacy** | **Your server** | Their cloud | Their cloud | Their cloud | Their cloud | Their cloud |
---
## How Much Does It Cost?
OpenShorts is free. You only pay for the AI APIs you use — and most have generous free tiers:
| Service | Free Tier | Paid Cost | Used For |
|---------|-----------|-----------|----------|
| **Google Gemini** | Free trial with generous limits | < $0.01 per 10-min video | Viral moment detection, script generation, web research |
| **fal.ai** | Pay-per-use | ~$0.50-1.50 per AI Short | Actor generation, talking head video, lip-sync |
| **ElevenLabs** | Free tier available | Pay-per-use | Voiceover, voice dubbing |
| **Upload-Post** | **10 free uploads/month** to all networks (no credit card) | Pay-per-use | Auto-publishing to TikTok, Instagram, YouTube |
| **AWS S3** | Optional | ~$0.023/GB | Cloud backup for clips and gallery |
**Bottom line:** You can clip videos for practically free with Gemini, and publish 10 videos/month to all social networks at zero cost with Upload-Post.
---
## Requirements
- **Docker & Docker Compose**
- **Google Gemini API Key** ([Free — get it here](https://aistudio.google.com/app/apikey)) — required for all AI features
- **fal.ai API Key** ([Pay-per-use](https://fal.ai)) — required for AI Shorts (actor generation, video, lip-sync)
- **ElevenLabs API Key** ([Free tier](https://elevenlabs.io)) — required for voiceover/dubbing
- **Upload-Post API Key** ([free tier](https://upload-post.com)) — required for direct social posting
---
## Getting Started
### 1. Clone
```bash
git clone https://github.com/your-username/OpenShorts.git
cd OpenShorts
```
### 2. Configure (optional)
```bash
cp .env.example .env
# Edit .env with your AWS keys for S3 backup
```
### 3. Launch
```bash
docker compose up --build
```
### 4. Open Dashboard
Navigate to **`http://localhost:5175`**
1. Go to **Settings** and enter your API keys (Gemini, fal.ai, ElevenLabs, Upload-Post)
2. **Clip Generator**: Upload a long-form video to generate viral shorts
3. **AI Shorts**: Describe your product or paste a URL to generate UGC marketing videos
4. **YouTube Studio**: Generate thumbnails, titles, and descriptions for YouTube
5. **UGC Gallery**: Browse all generated videos and avatars
---
## Technical Pipeline
### Clip Generator
1. **Ingest** — Local video upload (or self-hosted URL ingest via yt-dlp)
2. **Transcribe** — faster-whisper with word-level timestamps
3. **Detect** — PySceneDetect for scene boundaries
4. **Analyze** — Gemini identifies 3-15 viral moments (15-60s each)
5. **Extract** — FFmpeg precise clip cutting
6. **Reframe** — AI vertical cropping with subject tracking
7. **Effects** — Subtitles, hooks, AI video effects
8. **Publish** — S3 backup + Upload-Post social distribution
### AI Shorts
1. **Analyze** — Website scraping + Gemini web research (or manual description)
2. **Script** — Gemini generates viral scripts with segments
3. **Actor** — Flux 2 Pro portrait generation (or gallery/upload)
4. **Voice** — ElevenLabs TTS voiceover
5. **Video** — Hailuo 2.3 Fast img2video + VEED Lipsync (Low Cost) or Kling Avatar v2 (Premium)
6. **B-roll** — Flux 2 Pro image generation + Ken Burns effect
7. **Composite** — FFmpeg assembly with ASS subtitles and hook overlays
8. **Gallery** — Upload to public S3 with metadata for SEO pages
9. **Publish** — Upload-Post to TikTok, Instagram, YouTube
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx |
| Frontend | React 18, Vite 4, Tailwind CSS 3.4 |
| AI APIs | Google Gemini, fal.ai (Flux, Hailuo, VEED, Kling), ElevenLabs |
| Infrastructure | Docker + Docker Compose, AWS S3 |
| Publishing | Upload-Post API (TikTok, Instagram, YouTube) |
---
## Environment Variables
**Server-side (.env):**
| Variable | Description |
|----------|------------|
| `AWS_ACCESS_KEY_ID` | AWS access key for S3 |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key |
| `AWS_REGION` | AWS region (default: us-east-1) |
| `AWS_S3_BUCKET` | Private bucket for clip backup |
| `AWS_S3_PUBLIC_BUCKET` | Public bucket for gallery/avatars |
| `MAX_CONCURRENT_JOBS` | Concurrent processing limit (default: 5) |
**Client-side (encrypted in localStorage):**
| Key | Description |
|-----|------------|
| `GEMINI_API_KEY` | Google Gemini — required |
| `FAL_KEY` | fal.ai — required for AI Shorts |
| `ELEVENLABS_API_KEY` | ElevenLabs — required for voiceover/dubbing |
| `UPLOAD_POST_API_KEY` | Upload-Post — required, for social posting |
---
## Security & Performance
- **Non-Root Execution**: Containers run as dedicated `appuser`
- **Concurrency Control**: Semaphore-based job queue (`MAX_CONCURRENT_JOBS`)
- **Auto-Cleanup**: Automatic purging of old jobs (1h retention)
- **Encrypted Keys**: API keys encrypted client-side, never stored server-side
- **Upload Validation**: Image uploads validated for format and minimum size
- **File Limits**: 2GB upload limit protection
---
## Social Media Setup (Upload-Post)
1. **Register**: [app.upload-post.com/login](https://app.upload-post.com/login)
2. **Create Profile**: Go to [Manage Users](https://app.upload-post.com/manage-users)
3. **Connect Accounts**: Link TikTok, Instagram, and/or YouTube
4. **Get API Key**: Navigate to [API Keys](https://app.upload-post.com/api-keys)
5. **Use in OpenShorts**: Paste the key in Settings
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=mutonby/openshorts&type=Date)](https://star-history.com/#mutonby/openshorts&Date)
## Contributions
Contributions are welcome! Whether it's adding new AI models, improving the lip-sync pipeline, or building new features — feel free to open a PR.
## License
MIT License. OpenShorts is yours to use, modify, and scale.
+2256
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

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

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,147 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { LayoutGrid, AlertCircle, Loader2 } from 'lucide-react';
import { getApiUrl } from '../config';
import GalleryCard from './GalleryCard';
const CLIPS_PER_PAGE = 20;
export default function Gallery() {
const [clips, setClips] = useState([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const loaderRef = useRef(null);
const fetchClips = useCallback(async (currentOffset = 0, append = false) => {
try {
if (currentOffset === 0) setLoading(true);
else setLoadingMore(true);
const res = await fetch(
getApiUrl(`/api/gallery/clips?limit=${CLIPS_PER_PAGE}&offset=${currentOffset}`)
);
if (!res.ok) throw new Error('Failed to fetch clips');
const data = await res.json();
const newClips = data.clips || [];
if (append) {
setClips(prev => [...prev, ...newClips]);
} else {
setClips(newClips);
}
setHasMore(data.has_more ?? newClips.length === CLIPS_PER_PAGE);
setOffset(currentOffset + newClips.length);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
// Initial load
useEffect(() => {
fetchClips(0, false);
}, [fetchClips]);
// Infinite scroll observer
useEffect(() => {
if (!hasMore || loadingMore || loading) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
fetchClips(offset, true);
}
},
{ rootMargin: '200px', threshold: 0.1 }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => {
if (loaderRef.current) {
observer.unobserve(loaderRef.current);
}
};
}, [hasMore, loadingMore, loading, offset, fetchClips]);
if (loading) {
return (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 animate-[fadeIn_0.5s_ease-out]">
<Loader2 size={32} className="animate-spin mb-4 text-primary" />
<p>Loading your viral history...</p>
</div>
);
}
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center text-red-400 p-6">
<AlertCircle size={32} className="mb-4" />
<p>Error loading gallery: {error}</p>
<button
onClick={() => {
setError(null);
setOffset(0);
fetchClips(0, false);
}}
className="mt-4 px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white transition-colors"
>
Retry
</button>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6 md:p-8 animate-[fadeIn_0.3s_ease-out]">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold flex items-center gap-3">
<LayoutGrid className="text-primary" /> Clip Gallery
</h1>
<span className="text-xs bg-white/10 text-white px-3 py-1 rounded-full border border-white/5">
{clips.length} {clips.length === 1 ? 'Clip' : 'Clips'}{hasMore ? '+' : ''}
</span>
</div>
{clips.length === 0 ? (
<div className="text-center py-20 text-zinc-500">
<p className="text-lg mb-2">No clips found yet.</p>
<p className="text-sm">Process some videos to populate your gallery!</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6 pb-10">
{clips.map((clip, i) => (
<GalleryCard key={`${clip.job_id}-${clip.index}`} clip={clip} />
))}
</div>
{/* Infinite scroll loader trigger */}
{hasMore && (
<div
ref={loaderRef}
className="flex justify-center py-8"
>
{loadingMore && (
<div className="flex items-center gap-2 text-zinc-500">
<Loader2 size={20} className="animate-spin" />
<span className="text-sm">Loading more clips...</span>
</div>
)}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,156 @@
import React, { useRef, useState, useEffect } from 'react';
import { Download, Youtube, Instagram, Video, Copy, Check, Play } from 'lucide-react';
export default function GalleryCard({ clip }) {
const [copied, setCopied] = useState(null);
const [isVisible, setIsVisible] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const cardRef = useRef(null);
const videoRef = useRef(null);
// Lazy loading with IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
// Once loaded, we don't need to observe anymore
observer.unobserve(entry.target);
}
});
},
{
rootMargin: '200px', // Start loading 200px before entering viewport
threshold: 0.1
}
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => {
if (cardRef.current) {
observer.unobserve(cardRef.current);
}
};
}, []);
const handleCopy = (text, field) => {
navigator.clipboard.writeText(text);
setCopied(field);
setTimeout(() => setCopied(null), 2000);
};
const handleDownload = async (e) => {
e.preventDefault();
try {
const response = await fetch(clip.url);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `clip_${clip.job_id}_${clip.index + 1}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download error:', err);
window.open(clip.url, '_blank');
}
};
return (
<div
ref={cardRef}
className="bg-surface border border-white/5 rounded-xl overflow-hidden flex flex-col hover:border-white/10 transition-all group animate-[fadeIn_0.5s_ease-out]"
>
{/* Video Player - Lazy loaded */}
<div className="aspect-[9/16] bg-black relative group/video">
{isVisible ? (
<video
ref={videoRef}
src={clip.url}
controls
className="w-full h-full object-cover"
playsInline
preload="metadata"
onLoadedData={() => setHasLoaded(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center">
<Play size={24} className="text-white/50 ml-1" />
</div>
</div>
)}
<div className="absolute top-2 left-2">
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 tracking-wide">
{new Date(clip.created_at).toLocaleDateString()}
</span>
</div>
</div>
{/* Content & Details */}
<div className="flex-1 p-4 flex flex-col bg-[#121214] min-w-0">
<div className="mb-3">
<h3 className="text-sm font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.title}>
{clip.title}
</h3>
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5">{clip.duration.toFixed(1)}s</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 truncate max-w-[150px]" title={clip.job_id}>ID: {clip.job_id.substring(0, 8)}</span>
</div>
</div>
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar max-h-[150px] pr-1 mb-3">
{/* YouTube Title */}
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-red-400 mb-1 uppercase tracking-wider">
<Youtube size={10} className="shrink-0" /> YouTube Title
</div>
<p className="text-xs text-zinc-300 select-all line-clamp-2 hover:line-clamp-none transition-all">{clip.title}</p>
<button
onClick={() => handleCopy(clip.title, 'yt')}
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
title="Copy Title"
>
{copied === 'yt' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
{/* TikTok / IG Caption */}
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 mb-1 uppercase tracking-wider">
<Video size={10} className="text-cyan-400 shrink-0" />
<span className="text-zinc-600">/</span>
<Instagram size={10} className="text-pink-400 shrink-0" /> Caption
</div>
<p className="text-xs text-zinc-300 select-all line-clamp-3 hover:line-clamp-none transition-all cursor-pointer">
{clip.tiktok_desc || clip.insta_desc}
</p>
<button
onClick={() => handleCopy(clip.tiktok_desc || clip.insta_desc, 'caption')}
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
title="Copy Caption"
>
{copied === 'caption' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
{/* Footer Action */}
<button
onClick={handleDownload}
className="w-full py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5"
>
<Download size={14} className="shrink-0" /> Download Clip
</button>
</div>
</div>
);
}
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { X, Sparkles, Loader2, Maximize, MoveVertical, Zap } from 'lucide-react';
import RemotionPreview from './RemotionPreview';
const ENTRANCE_OPTIONS = [
{ value: 'spring', label: 'Bounce' },
{ value: 'fade', label: 'Fade' },
{ value: 'slide-up', label: 'Slide Up' },
{ value: 'none', label: 'None' },
];
export default function HookModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, initialText, durationInSeconds, existingSubtitles }) {
const [text, setText] = useState(initialText || 'POV: You are using the viral hook feature');
const [position, setPosition] = useState('top');
const [size, setSize] = useState('M');
const [entranceAnimation, setEntranceAnimation] = useState('spring');
const [displayDuration, setDisplayDuration] = useState(5);
if (!isOpen) return null;
// Build hook config for Remotion preview
const hookConfig = {
text: text || 'Enter your text...',
position,
size,
entranceAnimation,
displayDurationSec: displayDuration,
};
const useRemotionPreview = !!videoUrl;
// Fallback preview logic (same as original)
const getPositionClass = () => {
switch (position) {
case 'center': return 'items-center justify-center';
case 'bottom': return 'items-center justify-end pb-[20%]';
case 'top': default: return 'items-center justify-start pt-[20%]';
}
};
const getSizeStyle = () => {
switch (size) {
case 'S': return { fontSize: '14px', maxWidth: '80%' };
case 'L': return { fontSize: '24px', maxWidth: '95%' };
case 'M': default: return { fontSize: '18px', maxWidth: '90%' };
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-4xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
<button
onClick={onClose}
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
>
<X size={20} />
</button>
{/* Left: Preview */}
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
{useRemotionPreview ? (
<RemotionPreview
videoUrl={videoUrl}
durationInSeconds={durationInSeconds || 30}
hook={hookConfig}
subtitles={existingSubtitles || null}
/>
) : (
<>
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col h-full ${getPositionClass()}`}>
<div
className="text-black font-bold px-3 py-2 rounded-xl shadow-2xl text-center whitespace-pre-wrap transition-all duration-200"
style={{
...getSizeStyle(),
backgroundColor: 'rgba(255, 255, 255, 0.82)',
fontFamily: 'Noto Serif, serif',
boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
paddingTop: '10px',
paddingBottom: '10px',
paddingLeft: '12px',
paddingRight: '12px'
}}
>
{text || "Enter your text..."}
</div>
</div>
</>
)}
</div>
{/* Right: Controls */}
<div className="w-full md:w-80 flex flex-col">
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Sparkles className="text-yellow-400" /> Viral Hook
</h3>
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
{/* Text Input */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 block">Text</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white placeholder-zinc-600 focus:outline-none focus:border-yellow-500/50 resize-none font-serif"
placeholder="Enter text that will stop the scroll..."
/>
</div>
{/* Position Control */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<MoveVertical size={12} /> Position
</label>
<div className="grid grid-cols-3 gap-2">
{['top', 'center', 'bottom'].map((pos) => (
<button
key={pos}
onClick={() => setPosition(pos)}
className={`py-2 px-1 rounded-lg text-xs font-bold capitalize transition-all border ${position === pos
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{pos}
</button>
))}
</div>
</div>
{/* Size Control */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Maximize size={12} /> Size
</label>
<div className="grid grid-cols-3 gap-2">
{['S', 'M', 'L'].map((sz) => (
<button
key={sz}
onClick={() => setSize(sz)}
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${size === sz
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{sz === 'S' ? 'Small' : sz === 'M' ? 'Medium' : 'Large'}
</button>
))}
</div>
</div>
{/* Entrance Animation (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Zap size={12} /> Entrance
</label>
<div className="grid grid-cols-2 gap-2">
{ENTRANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setEntranceAnimation(opt.value)}
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${entranceAnimation === opt.value
? 'bg-white text-black border-white'
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Display Duration (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Duration: {displayDuration}s</label>
<input
type="range"
min="2"
max="15"
value={displayDuration}
onChange={(e) => setDisplayDuration(parseInt(e.target.value))}
className="w-full accent-yellow-500"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>2s</span>
<span>15s</span>
</div>
</div>
<div className="p-3 bg-white/5 rounded-lg border border-white/5 text-[11px] text-zinc-400">
<strong>Tip:</strong> Keep it short and punchy. Using "POV:" or specific questions works best for retention.
</div>
</div>
<button
onClick={() => onGenerate({
text, position, size,
// Remotion data
remotion: hookConfig,
})}
disabled={isProcessing || !text.trim()}
className="w-full py-4 mt-4 bg-gradient-to-r from-yellow-500 to-amber-600 hover:from-yellow-400 hover:to-amber-500 text-black font-bold rounded-xl shadow-lg shadow-amber-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Sparkles size={20} />}
{isProcessing ? 'Generating...' : 'Add Hook'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react';
import { Key, Eye, EyeOff, Check } from 'lucide-react';
export default function KeyInput({ onKeySet, savedKey }) {
const [key, setKey] = useState(savedKey || '');
const [isVisible, setIsVisible] = useState(false);
const [isSaved, setIsSaved] = useState(!!savedKey);
useEffect(() => {
if (savedKey) setKey(savedKey);
}, [savedKey]);
const handleSave = () => {
if (key.trim().length > 0) {
onKeySet(key);
setIsSaved(true);
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl p-6 mb-8 animate-[fadeIn_0.5s_ease-out]">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-accent/20 rounded-lg text-accent">
<Key size={20} />
</div>
<h2 className="text-lg font-semibold">Gemini API Key</h2>
</div>
<div className="flex gap-3">
<div className="relative flex-1">
<input
type={isVisible ? "text" : "password"}
value={key}
onChange={(e) => {
setKey(e.target.value);
setIsSaved(false);
}}
placeholder="AIzaSy..."
className="input-field pr-12 font-mono"
/>
<button
onClick={() => setIsVisible(!isVisible)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
>
{isVisible ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<button
onClick={handleSave}
disabled={!key || isSaved}
className={`px-6 rounded-xl font-medium transition-all flex items-center gap-2 ${isSaved
? 'bg-green-500/20 text-green-400 cursor-default'
: 'bg-primary hover:bg-blue-600 text-white shadow-lg shadow-primary/20'
}`}
>
{isSaved ? <><Check size={18} /> Ready</> : 'Set Key'}
</button>
</div>
<p className="mt-3 text-xs text-zinc-500">
Your key is stored locally in your browser for convenience.
<br />
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mt-1 inline-block"
>
Get your free Gemini API Key here
</a>
</p>
</div>
);
}
@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { Youtube, Upload, FileVideo, X } from 'lucide-react';
import { getApiUrl } from '../config';
export default function MediaInput({ onProcess, isProcessing }) {
const [youtubeUrlEnabled, setYoutubeUrlEnabled] = useState(true);
const [mode, setMode] = useState('url'); // 'url' | 'file'
const [url, setUrl] = useState('');
const [file, setFile] = useState(null);
const [acknowledged, setAcknowledged] = useState(false);
useEffect(() => {
fetch(getApiUrl('/api/config'))
.then((r) => r.ok ? r.json() : null)
.then((cfg) => {
if (cfg && cfg.youtubeUrlEnabled === false) {
setYoutubeUrlEnabled(false);
setMode('file');
}
})
.catch(() => {});
}, []);
const handleSubmit = (e) => {
e.preventDefault();
if (!acknowledged) return;
if (mode === 'url' && url) {
onProcess({ type: 'url', payload: url, acknowledged: true });
} else if (mode === 'file' && file) {
onProcess({ type: 'file', payload: file, acknowledged: true });
}
};
const handleDrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFile(e.dataTransfer.files[0]);
setMode('file');
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl p-6 animate-[fadeIn_0.6s_ease-out]">
<div className="flex gap-4 mb-6 border-b border-white/5 pb-4">
{youtubeUrlEnabled && (
<button
onClick={() => setMode('url')}
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'url'
? 'text-primary border-b-2 border-primary -mb-[17px]'
: 'text-zinc-400 hover:text-white'
}`}
>
<Youtube size={18} />
YouTube URL
</button>
)}
<button
onClick={() => setMode('file')}
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'file'
? 'text-primary border-b-2 border-primary -mb-[17px]'
: 'text-zinc-400 hover:text-white'
}`}
>
<Upload size={18} />
Upload File
</button>
</div>
<form onSubmit={handleSubmit}>
{mode === 'url' ? (
<div className="space-y-4">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="input-field"
required
/>
</div>
) : (
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all ${file ? 'border-primary/50 bg-primary/5' : 'border-zinc-700 hover:border-zinc-500 bg-white/5'
}`}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{file ? (
<div className="flex items-center justify-center gap-3 text-white">
<FileVideo className="text-primary" />
<span className="font-medium">{file.name}</span>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 hover:bg-white/10 rounded-full"
>
<X size={16} />
</button>
</div>
) : (
<label className="cursor-pointer block">
<input
type="file"
accept="video/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
/>
<Upload className="mx-auto mb-3 text-zinc-500" size={24} />
<p className="text-zinc-400">Click to upload or drag and drop</p>
<p className="text-xs text-zinc-600 mt-1">MP4, MOV up to 500MB</p>
</label>
)}
</div>
)}
<label className="flex items-start gap-2 mt-5 text-xs text-zinc-400 cursor-pointer select-none">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5 accent-primary cursor-pointer"
/>
<span>
I confirm I own this content or have the rights to process it. I am responsible for any content I submit. See our <a href="/#legal" target="_blank" rel="noopener noreferrer" className="text-primary underline" onClick={(e) => e.stopPropagation()}>Terms & Privacy</a>.
</span>
</label>
<button
type="submit"
disabled={isProcessing || !acknowledged || (mode === 'url' && !url) || (mode === 'file' && !file)}
className="w-full btn-primary mt-4 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Processing Video...
</>
) : (
<>
Generate Clips
</>
)}
</button>
</form>
</div>
);
}
@@ -0,0 +1,244 @@
import React, { useEffect, useState, useRef } from 'react';
import { Scan, Scissors, Activity, Radio, CheckCircle, Play } from 'lucide-react';
const ProcessingAnimation = ({ media, isComplete, syncedTime, isSyncedPlaying, syncTrigger }) => {
const [videoSrc, setVideoSrc] = useState(null);
const [isYouTube, setIsYouTube] = useState(false);
const videoRef = useRef(null);
const iframeRef = useRef(null);
useEffect(() => {
if (!media) return;
if (media.type === 'file') {
const url = URL.createObjectURL(media.payload);
setVideoSrc(url);
return () => URL.revokeObjectURL(url);
} else if (media.type === 'url') {
setIsYouTube(true);
const videoId = getYouTubeId(media.payload);
setVideoSrc(videoId);
}
}, [media]);
// Handle Sync Playback for Local Video
useEffect(() => {
if (!isYouTube && videoRef.current) {
if (isSyncedPlaying) {
// Sync Mode: Seek to time and Play
videoRef.current.currentTime = syncedTime;
videoRef.current.play().catch(e => console.log("Auto-play prevented", e));
videoRef.current.loop = false;
videoRef.current.muted = true; // Keep muted to avoid double audio with clip
} else {
// Stop Sync: Pause
videoRef.current.pause();
// If Analysis Complete and we just stopped syncing (paused clip), we might want to return to ambient loop?
// User issue: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica no se está reproduciendo el video en bucle como antes"
// This means when NOT synced, it should loop.
// HOWEVER, previously user asked: "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
// This implies:
// 1. If I PAUSE the clip -> Left video should PAUSE (static image) so it can resume.
// 2. If I STOP (or it finishes? or just idle state?) -> It should LOOP.
// The problem is we only have onPause from the clip.
// Maybe we need to distinguish "Pause" vs "Idle/Stop".
// But currently we just get `isSyncedPlaying = false`.
// If the user wants "resume from where left off", it MUST be static (paused).
// If the user wants "loop when not playing", it MUST play.
// These are contradictory for the "Paused" state.
// BUT, maybe the "loop original" refers to the initial state BEFORE any clip is played?
// OR when the clip finishes?
// Let's look at the logic:
// isSyncedPlaying is true ONLY when a clip is playing.
// When clip pauses, isSyncedPlaying becomes false.
// If we want it to loop "como antes", we should set it to loop.
// But then we lose the "resume" position because it starts looping.
// Unless... we only loop if we haven't started syncing yet? Or if explicitly reset?
// Wait, the user said: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica"
// This likely refers to the state AFTER analysis is complete but BEFORE (or after) playing a clip.
// If I haven't touched a clip yet, `isSyncedPlaying` is false.
// In that case, it SHOULD be looping.
// My previous change removed the "else { play loop }" block entirely.
// I need to restore the loop for the IDLE state, but keep the PAUSE for the "paused clip" state?
// That requires knowing WHY `isSyncedPlaying` is false.
// Actually, if `isSyncedPlaying` is false, it means no clip is controlling it.
// If I want it to loop in the background, I can just let it loop.
// BUT if I play a clip later, it will jump to the sync time anyway (handled by the `if (isSyncedPlaying)` block).
// The only issue is if I PAUSE the clip, `isSyncedPlaying` becomes false, and if I immediately start looping,
// visually it might jump or start moving when it should be "paused".
// Let's try this:
// If `syncedTime` is 0 (or we track if we ever started syncing?), we loop.
// But `syncedTime` updates on play.
// Alternative interpretation: The user sees it static because I removed `videoRef.current.play()` in the else block.
// If I put it back, it fixes the "loop" issue.
// Does it break the "resume" issue?
// "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
// If I pause the clip -> `isSyncedPlaying` = false.
// If logic says -> Loop from 0.
// Then I resume -> `isSyncedPlaying` = true -> Jump to `syncedTime`.
// This actually SHOULD work fine for "resume", because `syncedTime` comes from the clip's current time.
// The only visual glitch is that while paused, the left video is looping instead of frozen on the frame.
// If the user accepts that "Paused Clip" = "Background Loop", then we are good.
// If the user wants "Paused Clip" = "Frozen Frame" AND "Idle" = "Background Loop", we need more state.
// But typically "Idle" implies we aren't focusing on a clip.
// Let's restore the loop behavior because "video loop original... es una imagen estatica" sounds like a bug to them.
if (isComplete) {
videoRef.current.loop = true;
videoRef.current.play().catch(e => console.log("Ambient play prevented", e));
}
}
}
}, [syncedTime, isSyncedPlaying, isYouTube, isComplete, syncTrigger]);
// Handle Sync Playback for YouTube (Basic Iframe Control via PostMessage)
useEffect(() => {
if (isYouTube && iframeRef.current && videoSrc) {
const iframeWindow = iframeRef.current.contentWindow;
if (isSyncedPlaying) {
// Seek and Play
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'seekTo', args: [syncedTime, true] }), '*');
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'playVideo', args: [] }), '*');
} else {
// Pause
// iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); // Removed pause to allow loop if needed, but YT embeds are tricky with custom loops via API.
// For now, let's just pause YouTube as complex looping is harder without state.
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*');
}
}
}, [syncedTime, isSyncedPlaying, isYouTube, videoSrc, syncTrigger]);
const getYouTubeId = (url) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const containerClasses = `relative w-full aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-2xl mb-8 group animate-[fadeIn_0.5s_ease-out] transition-all duration-500
${isComplete && !isSyncedPlaying ? 'grayscale brightness-50' : ''}
${isSyncedPlaying ? 'ring-2 ring-primary ring-offset-2 ring-offset-black shadow-primary/20' : ''}`;
const getVideoOpacityClass = () => {
if (isSyncedPlaying) return 'opacity-100'; // Playing: Full visibility
if (isComplete) return 'opacity-30'; // Idle Result: Darker
return 'opacity-40 grayscale group-hover:grayscale-0'; // Processing: Dark + Grayscale effect
};
return (
<div className={containerClasses}>
{/* Video Layer */}
<div className={`absolute inset-0 transition-all duration-700 ${getVideoOpacityClass()}`}>
{isYouTube && videoSrc ? (
<iframe
ref={iframeRef}
className={`w-full h-full ${isSyncedPlaying ? '' : 'pointer-events-none scale-110'}`}
// Add enablejsapi=1 for postMessage control
src={`https://www.youtube.com/embed/${videoSrc}?autoplay=1&mute=1&controls=0&loop=1&playlist=${videoSrc}&modestbranding=1&showinfo=0&rel=0&enablejsapi=1`}
title="Processing Video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
) : videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
className="w-full h-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
<div className="w-16 h-16 border-4 border-zinc-700 border-t-zinc-500 rounded-full animate-spin"></div>
</div>
)}
</div>
{/* Overlays - Hide when synced playing so user sees clean video */}
{!isSyncedPlaying && !isComplete && (
<>
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:40px_40px] z-10 pointer-events-none"></div>
<div className="absolute left-0 w-full h-[2px] bg-primary shadow-[0_0_15px_2px_rgba(59,130,246,0.5)] animate-[scan_2.5s_linear_infinite] z-20 pointer-events-none"></div>
<div className="absolute left-0 w-full h-[15%] bg-gradient-to-b from-primary/0 via-primary/5 to-primary/0 animate-[scan-overlay_2.5s_linear_infinite] z-10 pointer-events-none"></div>
</>
)}
{/* HUD Elements - Hide when synced playing */}
{!isSyncedPlaying && (
<div className={`absolute top-4 left-4 z-30 flex items-center gap-2 px-3 py-1.5 backdrop-blur-md rounded-lg border text-xs font-mono font-bold uppercase transition-all duration-500 ${isComplete ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-black/60 border-primary/30 text-primary animate-pulse'}`}>
{isComplete ? (
<>
<CheckCircle size={14} /> Analysis Complete
</>
) : (
<>
<Scan size={14} /> Scanning Content...
</>
)}
</div>
)}
{!isSyncedPlaying && !isComplete && (
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 text-white/50 text-[10px] font-mono">
AI_MODEL: GEMINI-2.5-PRO
</div>
)}
{/* Visual Flair */}
{!isSyncedPlaying && !isComplete && (
<div className="absolute inset-0 pointer-events-none z-20 overflow-hidden">
<div className="absolute top-0 bottom-0 left-[35%] w-[1px] bg-yellow-500/20 border-r border-dashed border-yellow-500/40"></div>
<div className="absolute top-0 bottom-0 right-[35%] w-[1px] bg-yellow-500/20 border-l border-dashed border-yellow-500/40"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 border border-white/20 rounded-full flex items-center justify-center">
<div className="w-1 h-1 bg-red-500 rounded-full animate-ping"></div>
</div>
<div className="absolute bottom-1/3 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-2 opacity-60">
<Scissors size={24} className="text-white/20" />
</div>
</div>
)}
{/* Synced Playing Indicator */}
{isSyncedPlaying && (
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-red-600/90 backdrop-blur text-white rounded-lg shadow-lg animate-pulse font-bold text-[10px] uppercase tracking-wider border border-white/20">
<Activity size={12} /> Live Sync
</div>
)}
{/* Bottom Info Bar */}
{!isSyncedPlaying && !isComplete && (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent z-30 flex justify-between items-end border-t border-white/5">
<div className="font-mono text-[10px] text-primary/80 space-y-1">
<div className="flex items-center gap-2"><Activity size={10} className="animate-bounce" /> > ANALYSIS_THREAD_01: ACTIVE</div>
<div className="flex items-center gap-2"><Radio size={10} /> > AUDIO_TRANSCRIPT: PROCESSING</div>
</div>
<div className="flex gap-1">
<div className="w-1 h-3 bg-primary/40 animate-[pulse_0.5s_infinite]"></div>
<div className="w-1 h-5 bg-primary/60 animate-[pulse_0.7s_infinite]"></div>
<div className="w-1 h-2 bg-primary/30 animate-[pulse_0.4s_infinite]"></div>
<div className="w-1 h-4 bg-primary/80 animate-[pulse_0.6s_infinite]"></div>
<div className="w-1 h-3 bg-primary/50 animate-[pulse_0.5s_infinite]"></div>
</div>
</div>
)}
</div>
);
};
export default ProcessingAnimation;
@@ -0,0 +1,61 @@
import React, { useMemo } from 'react';
import { Player } from '@remotion/player';
import { ShortVideo } from '../remotion/compositions/ShortVideo';
/**
* Wraps Remotion's Player component for real-time preview in modals.
* Accepts the same ShortVideoProps interface as the Remotion composition.
*
* @param {object} props
* @param {string} props.videoUrl - URL to the base clip video
* @param {number} props.durationInSeconds - Video duration in seconds
* @param {object|null} props.subtitles - SubtitleConfig or null
* @param {object|null} props.hook - HookConfig or null
* @param {object|null} props.effects - EffectsConfig or null
* @param {string} [props.className] - Additional CSS classes
*/
export default function RemotionPreview({
videoUrl,
durationInSeconds = 30,
subtitles = null,
hook = null,
effects = null,
className = '',
}) {
const fps = 30;
const durationInFrames = Math.max(1, Math.round(durationInSeconds * fps));
const inputProps = useMemo(
() => ({
videoUrl,
durationInFrames,
fps,
width: 1080,
height: 1920,
subtitles,
hook,
effects,
}),
[videoUrl, durationInFrames, subtitles, hook, effects]
);
return (
<div className={`w-full h-full ${className}`}>
<Player
component={ShortVideo}
inputProps={inputProps}
durationInFrames={durationInFrames}
fps={fps}
compositionWidth={1080}
compositionHeight={1920}
style={{
width: '100%',
height: '100%',
}}
controls
autoPlay
loop
/>
</div>
);
}
@@ -0,0 +1,685 @@
import React, { useState, useEffect } from 'react';
import { Download, Share2, Instagram, Youtube, Video, CheckCircle, AlertCircle, X, Loader2, Copy, Wand2, Type, Calendar, Clock, Languages } from 'lucide-react';
import { getApiUrl } from '../config';
import SubtitleModal from './SubtitleModal';
import HookModal from './HookModal';
import TranslateModal from './TranslateModal';
import { renderInBrowser } from '../lib/renderInBrowser';
export default function ResultCard({ clip, index, jobId, uploadPostKey, uploadUserId, geminiApiKey, elevenLabsKey, onPlay, onPause }) {
const [showModal, setShowModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const videoRef = React.useRef(null);
const originalVideoUrl = getApiUrl(clip.video_url); // Never changes — used for Remotion previews
const [currentVideoUrl, setCurrentVideoUrl] = useState(originalVideoUrl);
const [platforms, setPlatforms] = useState({
tiktok: true,
instagram: true,
youtube: true
});
const [postTitle, setPostTitle] = useState("");
const [postDescription, setPostDescription] = useState("");
const [isScheduling, setIsScheduling] = useState(false);
const [scheduleDate, setScheduleDate] = useState("");
const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [isSubtitling, setIsSubtitling] = useState(false);
const [isHooking, setIsHooking] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
const [showHookModal, setShowHookModal] = useState(false);
const [showTranslateModal, setShowTranslateModal] = useState(false);
const [editError, setEditError] = useState(null);
const [clipDuration, setClipDuration] = useState(clip.end && clip.start ? clip.end - clip.start : 30);
// Accumulate Remotion layers across operations
const [activeLayers, setActiveLayers] = useState({ subtitles: null, hook: null, effects: null });
// Fetch clip duration from transcript endpoint
useEffect(() => {
if (!jobId || index === undefined) return;
fetch(getApiUrl(`/api/clip/${jobId}/${index}/transcript`))
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.durationSec) setClipDuration(data.durationSec);
})
.catch(() => {});
}, [jobId, index]);
// Initialize/Reset form when modal opens
useEffect(() => {
if (showModal) {
setPostTitle(clip.video_title_for_youtube_short || "Viral Short");
setPostDescription(clip.video_description_for_instagram || clip.video_description_for_tiktok || "");
setIsScheduling(false);
setScheduleDate("");
setPostResult(null);
}
}, [showModal, clip]);
const handleAutoEdit = async () => {
setIsEditing(true);
setEditError(null);
try {
const apiKey = geminiApiKey || localStorage.getItem('gemini_key');
if (!apiKey) {
throw new Error("Gemini API Key is missing. Please set it in Settings.");
}
// Try Remotion effects endpoint first
const effectsRes = await fetch(getApiUrl('/api/effects/generate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (effectsRes.ok) {
const data = await effectsRes.json();
if (data.effects && data.effects.segments) {
const newLayers = { ...activeLayers, effects: data.effects };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
return;
}
}
// Fallback: legacy FFmpeg edit endpoint
const res = await fetch(getApiUrl('/api/edit'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsEditing(false);
}
};
const handleSubtitle = async (options) => {
setIsSubtitling(true);
setEditError(null);
try {
if (options.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, subtitles: options.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
return;
}
// Fallback: legacy FFmpeg
const res = await fetch(getApiUrl('/api/subtitle'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
position: options.position,
font_size: options.fontSize,
font_name: options.fontName,
font_color: options.fontColor,
border_color: options.borderColor,
border_width: options.borderWidth,
bg_color: options.bgColor,
bg_opacity: options.bgOpacity,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsSubtitling(false);
}
};
const handleHook = async (hookData) => {
setIsHooking(true);
setEditError(null);
try {
if (hookData.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, hook: hookData.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
return;
}
// Fallback: legacy FFmpeg
const payload = typeof hookData === 'string'
? { text: hookData, position: 'top', size: 'M' }
: hookData;
const res = await fetch(getApiUrl('/api/hook'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
text: payload.text,
position: payload.position,
size: payload.size,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsHooking(false);
}
};
const handleTranslate = async (options) => {
console.log('[Translate] Starting translation with options:', options);
setIsTranslating(true);
setEditError(null);
try {
const apiKey = elevenLabsKey;
console.log('[Translate] API Key available:', !!apiKey);
if (!apiKey) {
throw new Error("ElevenLabs API Key is missing. Please set it in Settings.");
}
const requestBody = {
job_id: jobId,
clip_index: index,
target_language: options.targetLanguage,
input_filename: currentVideoUrl.split('/').pop()
};
console.log('[Translate] Request body:', requestBody);
console.log('[Translate] Sending request to /api/translate');
const res = await fetch(getApiUrl('/api/translate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ElevenLabs-Key': apiKey
},
body: JSON.stringify(requestBody)
});
console.log('[Translate] Response status:', res.status);
if (!res.ok) {
const errText = await res.text();
console.error('[Translate] Error response:', errText);
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
if (e.message !== errText) throw e;
throw new Error(errText);
}
}
const data = await res.json();
console.log('[Translate] Success response:', data);
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
setShowTranslateModal(false);
}
} catch (e) {
console.error('[Translate] Exception:', e);
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsTranslating(false);
}
};
const handlePost = async () => {
if (!uploadPostKey || !uploadUserId) {
setPostResult({ success: false, msg: "Missing API Key or User ID." });
return;
}
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
if (selectedPlatforms.length === 0) {
setPostResult({ success: false, msg: "Select at least one platform." });
return;
}
if (isScheduling && !scheduleDate) {
setPostResult({ success: false, msg: "Please select a date and time." });
return;
}
setPosting(true);
setPostResult(null);
try {
const payload = {
job_id: jobId,
clip_index: index,
api_key: uploadPostKey,
user_id: uploadUserId,
platforms: selectedPlatforms,
title: postTitle,
description: postDescription
};
if (isScheduling && scheduleDate) {
// Convert to ISO-8601
payload.scheduled_date = new Date(scheduleDate).toISOString();
// Optional: pass timezone if needed, backend defaults to UTC or we can send user's timezone
payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
const res = await fetch(getApiUrl('/api/social/post'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
setPostResult({ success: true, msg: isScheduling ? "Scheduled successfully!" : "Posted successfully!" });
setTimeout(() => {
setShowModal(false);
setPostResult(null);
}, 3000);
} catch (e) {
setPostResult({ success: false, msg: `Failed: ${e.message}` });
} finally {
setPosting(false);
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl overflow-hidden flex flex-col md:flex-row group hover:border-white/10 transition-all animate-[fadeIn_0.5s_ease-out] min-h-[300px] h-auto" style={{ animationDelay: `${index * 0.1}s` }}>
{/* Left: Video Preview (Responsive Width) */}
<div className="w-full md:w-[180px] lg:w-[200px] bg-black relative shrink-0 aspect-[9/16] md:aspect-auto group/video">
<video
ref={videoRef}
src={currentVideoUrl}
controls
className="w-full h-full object-cover"
playsInline
onPlay={() => {
const currentTime = videoRef.current ? videoRef.current.currentTime : 0;
onPlay && onPlay(clip.start + currentTime);
}}
onPause={() => onPause && onPause()}
onEnded={() => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
videoRef.current.play();
}
}}
/>
<div className="absolute top-3 left-3 flex gap-2">
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 uppercase tracking-wide">
Clip {index + 1}
</span>
</div>
{/* Auto Edit Overlay if Processing */}
{isEditing && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center z-10 p-4 text-center">
<Loader2 size={32} className="text-primary animate-spin mb-3" />
<span className="text-xs font-bold text-white uppercase tracking-wider">AI Magic in Progress...</span>
<span className="text-[10px] text-zinc-400 mt-1">Applying viral edits & zooms</span>
</div>
)}
</div>
{/* Right: Content & Details */}
<div className="flex-1 p-4 md:p-5 flex flex-col bg-[#121214] overflow-hidden min-w-0">
<div className="mb-4">
<h3 className="text-base font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.video_title_for_youtube_short}>
{clip.video_title_for_youtube_short || "Viral Clip Generated"}
</h3>
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">{Math.floor(clip.end - clip.start)}s</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#shorts</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#viral</span>
</div>
</div>
{/* Scrollable Descriptions Area */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2 mb-4">
{/* YouTube */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-red-400 mb-1.5 uppercase tracking-wider">
<Youtube size={12} className="shrink-0" /> <span className="truncate">YouTube Title</span>
</div>
<p className="text-xs text-zinc-300 select-all break-words">
{clip.video_title_for_youtube_short || "Viral Short Video"}
</p>
</div>
{/* TikTok / IG */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-zinc-400 mb-1.5 uppercase tracking-wider">
<Video size={12} className="text-cyan-400 shrink-0" />
<span className="text-zinc-500">/</span>
<Instagram size={12} className="text-pink-400 shrink-0" />
<span className="truncate">Caption</span>
</div>
<p className="text-xs text-zinc-300 line-clamp-3 hover:line-clamp-none transition-all cursor-pointer select-all break-words">
{clip.video_description_for_tiktok || clip.video_description_for_instagram}
</p>
</div>
</div>
{/* Error Message */}
{editError && (
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] rounded-lg flex items-center gap-2">
<AlertCircle size={12} className="shrink-0" />
{editError}
</div>
)}
{/* Actions Footer */}
<div className="grid grid-cols-2 gap-3 mt-auto pt-4 border-t border-white/5">
<button
onClick={handleAutoEdit}
disabled={isEditing}
className="col-span-1 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-purple-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isEditing ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isEditing ? 'Editing...' : 'Auto Edit'}
</button>
<button
onClick={() => setShowSubtitleModal(true)}
disabled={isSubtitling}
className="col-span-1 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isSubtitling ? <Loader2 size={14} className="animate-spin" /> : <Type size={14} />}
{isSubtitling ? 'Adding...' : 'Subtitles'}
</button>
<button
onClick={() => setShowHookModal(true)}
disabled={isHooking}
className="col-span-1 py-2 bg-gradient-to-r from-amber-400 to-yellow-500 hover:from-amber-300 hover:to-yellow-400 text-black rounded-lg text-xs font-bold shadow-lg shadow-yellow-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isHooking ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isHooking ? 'Adding...' : 'Viral Hook'}
</button>
<button
onClick={() => setShowTranslateModal(true)}
disabled={isTranslating}
className="col-span-1 py-2 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-green-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isTranslating ? <Loader2 size={14} className="animate-spin" /> : <Languages size={14} />}
{isTranslating ? 'Translating...' : 'Dub Voice'}
</button>
<button
onClick={() => setShowModal(true)}
className="col-span-1 py-2 bg-primary hover:bg-blue-600 text-white rounded-lg text-xs font-bold shadow-lg shadow-primary/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 truncate px-2"
>
<Share2 size={14} className="shrink-0" /> Post
</button>
<button
onClick={async (e) => {
e.preventDefault();
try {
const response = await fetch(currentVideoUrl);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `clip-${index + 1}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download error:', err);
window.open(currentVideoUrl, '_blank');
}
}}
className="col-span-1 py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5 truncate px-2"
>
<Download size={14} className="shrink-0" /> Download
</button>
</div>
</div>
{/* Post Modal */}
{showModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
<button
onClick={() => setShowModal(false)}
className="absolute top-4 right-4 text-zinc-500 hover:text-white"
>
<X size={20} />
</button>
<h3 className="text-lg font-bold text-white mb-4">Post / Schedule</h3>
{!uploadPostKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configure API Key in Settings first.</div>
</div>
)}
<div className="space-y-4 mb-6">
{/* Title & Description */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Video Title</label>
<input
type="text"
value={postTitle}
onChange={(e) => setPostTitle(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600"
placeholder="Enter a catchy title..."
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Caption / Description</label>
<textarea
value={postDescription}
onChange={(e) => setPostDescription(e.target.value)}
rows={4}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600 resize-none"
placeholder="Write a caption for your post..."
/>
</div>
{/* Scheduling */}
<div className="p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-white font-medium">
<Calendar size={16} className="text-purple-400" /> Schedule Post
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={isScheduling} onChange={(e) => setIsScheduling(e.target.checked)} className="sr-only peer" />
<div className="w-9 h-5 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{isScheduling && (
<div className="mt-3 animate-[fadeIn_0.2s_ease-out]">
<label className="block text-xs text-zinc-400 mb-1">Select Date & Time</label>
<div className="relative">
<input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 pl-9 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
/>
<Clock size={14} className="absolute left-3 top-2.5 text-zinc-500" />
</div>
</div>
)}
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2">Select Platforms</label>
<div className="grid grid-cols-1 gap-2">
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.tiktok} onChange={e => setPlatforms({ ...platforms, tiktok: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Video size={16} className="text-cyan-400" /> TikTok</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.instagram} onChange={e => setPlatforms({ ...platforms, instagram: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Instagram size={16} className="text-pink-400" /> Instagram</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.youtube} onChange={e => setPlatforms({ ...platforms, youtube: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Youtube size={16} className="text-red-400" /> YouTube Shorts</div>
</label>
</div>
</div>
</div>
{postResult && (
<div className={`mb-4 p-3 rounded-lg text-xs flex items-start gap-2 ${postResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{postResult.success ? <CheckCircle size={14} className="mt-0.5 shrink-0" /> : <AlertCircle size={14} className="mt-0.5 shrink-0" />}
<div>{postResult.msg}</div>
</div>
)}
<button
onClick={handlePost}
disabled={posting || !uploadPostKey}
className="w-full py-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white font-bold transition-all flex items-center justify-center gap-2"
>
{posting ? <><Loader2 size={16} className="animate-spin" /> {isScheduling ? 'Scheduling...' : 'Publishing...'}</> : <><Share2 size={16} /> {isScheduling ? 'Schedule Post' : 'Publish Now'}</>}
</button>
</div>
</div>
)}
<SubtitleModal
isOpen={showSubtitleModal}
onClose={() => setShowSubtitleModal(false)}
onGenerate={handleSubtitle}
isProcessing={isSubtitling}
videoUrl={originalVideoUrl}
jobId={jobId}
clipIndex={index}
existingHook={activeLayers.hook}
/>
<HookModal
isOpen={showHookModal}
onClose={() => setShowHookModal(false)}
onGenerate={handleHook}
isProcessing={isHooking}
videoUrl={originalVideoUrl}
initialText={clip.viral_hook_text}
durationInSeconds={clip.end && clip.start ? clip.end - clip.start : 30}
existingSubtitles={activeLayers.subtitles}
/>
<TranslateModal
isOpen={showTranslateModal}
onClose={() => setShowTranslateModal(false)}
onTranslate={handleTranslate}
isProcessing={isTranslating}
videoUrl={currentVideoUrl}
hasApiKey={!!elevenLabsKey}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,388 @@
import React, { useState, useMemo } from 'react';
import { X, Loader2, Calendar, Clock, CheckCircle, AlertCircle, Video, Instagram, Youtube, ChevronLeft, ChevronRight, Globe, ExternalLink } from 'lucide-react';
import { getApiUrl } from '../config';
const DAYS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
const MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const TIMEZONES = [
{ value: 'Pacific/Midway', label: '(GMT-11:00) Midway' },
{ value: 'Pacific/Honolulu', label: '(GMT-10:00) Honolulu' },
{ value: 'America/Anchorage', label: '(GMT-09:00) Alaska' },
{ value: 'America/Los_Angeles', label: '(GMT-08:00) Los Ángeles' },
{ value: 'America/Denver', label: '(GMT-07:00) Denver' },
{ value: 'America/Mexico_City', label: '(GMT-06:00) Ciudad de México' },
{ value: 'America/Chicago', label: '(GMT-06:00) Chicago' },
{ value: 'America/New_York', label: '(GMT-05:00) Nueva York' },
{ value: 'America/Bogota', label: '(GMT-05:00) Bogotá' },
{ value: 'America/Caracas', label: '(GMT-04:00) Caracas' },
{ value: 'America/Santiago', label: '(GMT-04:00) Santiago' },
{ value: 'America/Argentina/Buenos_Aires', label: '(GMT-03:00) Buenos Aires' },
{ value: 'America/Sao_Paulo', label: '(GMT-03:00) São Paulo' },
{ value: 'Atlantic/Azores', label: '(GMT-01:00) Azores' },
{ value: 'UTC', label: '(GMT+00:00) UTC' },
{ value: 'Europe/London', label: '(GMT+00:00) Londres' },
{ value: 'Europe/Madrid', label: '(GMT+01:00) Madrid' },
{ value: 'Europe/Paris', label: '(GMT+01:00) París' },
{ value: 'Europe/Berlin', label: '(GMT+01:00) Berlín' },
{ value: 'Europe/Rome', label: '(GMT+01:00) Roma' },
{ value: 'Africa/Lagos', label: '(GMT+01:00) Lagos' },
{ value: 'Europe/Istanbul', label: '(GMT+03:00) Estambul' },
{ value: 'Asia/Dubai', label: '(GMT+04:00) Dubái' },
{ value: 'Asia/Kolkata', label: '(GMT+05:30) India' },
{ value: 'Asia/Bangkok', label: '(GMT+07:00) Bangkok' },
{ value: 'Asia/Shanghai', label: '(GMT+08:00) Shanghái' },
{ value: 'Asia/Tokyo', label: '(GMT+09:00) Tokio' },
{ value: 'Australia/Sydney', label: '(GMT+10:00) Sídney' },
{ value: 'Pacific/Auckland', label: '(GMT+12:00) Auckland' },
];
function getDayLabel(date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
if (target.getTime() === today.getTime()) return 'Hoy';
if (target.getTime() === tomorrow.getTime()) return 'Mañana';
return DAYS[target.getDay()];
}
function formatDate(date) {
return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
}
function detectTimezone() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (TIMEZONES.find(t => t.value === tz)) return tz;
return 'UTC';
} catch {
return 'UTC';
}
}
export default function ScheduleWeekModal({ isOpen, onClose, clips, jobId, uploadPostKey, uploadUserId }) {
const [time, setTime] = useState('12:00');
const [timezone, setTimezone] = useState(detectTimezone);
const [platforms, setPlatforms] = useState({
tiktok: true,
instagram: true,
youtube: true
});
const [startOffset, setStartOffset] = useState(1);
const schedule = useMemo(() => {
if (!clips) return [];
return clips.map((clip, i) => {
const date = new Date();
date.setDate(date.getDate() + startOffset + i);
date.setHours(0, 0, 0, 0);
return { clip, index: i, date };
});
}, [clips, startOffset]);
const [scheduling, setScheduling] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, results: [] });
const [done, setDone] = useState(false);
// Reset state when modal reopens
const prevOpen = React.useRef(false);
React.useEffect(() => {
if (isOpen && !prevOpen.current) {
setScheduling(false);
setDone(false);
setProgress({ current: 0, total: 0, results: [] });
}
prevOpen.current = isOpen;
}, [isOpen]);
if (!isOpen) return null;
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
const handleScheduleAll = async () => {
if (!uploadPostKey || !uploadUserId) return;
if (selectedPlatforms.length === 0) return;
setScheduling(true);
setDone(false);
const total = schedule.length;
setProgress({ current: 0, total, results: [] });
const results = [];
for (let i = 0; i < schedule.length; i++) {
const { clip, index, date } = schedule[i];
// Build local datetime string: "2026-04-06T12:00:00"
// Upload-Post accepts this + timezone IANA parameter
const pad = (n) => String(n).padStart(2, '0');
const scheduledDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${time}:00`;
const payload = {
job_id: jobId,
clip_index: index,
api_key: uploadPostKey,
user_id: uploadUserId,
platforms: selectedPlatforms,
title: clip.video_title_for_youtube_short || 'Viral Short',
description: clip.video_description_for_instagram || clip.video_description_for_tiktok || '',
scheduled_date: scheduledDate,
timezone
};
try {
const res = await fetch(getApiUrl('/api/social/post'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText);
}
results.push({ index: i, success: true });
} catch (e) {
results.push({ index: i, success: false, error: e.message });
}
setProgress({ current: i + 1, total, results: [...results] });
}
setDone(true);
setScheduling(false);
};
const successCount = progress.results.filter(r => r.success).length;
const failCount = progress.results.filter(r => !r.success).length;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-lg shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
<button
onClick={onClose}
disabled={scheduling}
className="absolute top-4 right-4 text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={20} />
</button>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
<Calendar size={20} className="text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Programar Semana</h3>
<p className="text-xs text-zinc-500">{clips?.length || 0} clips &middot; 1 por día</p>
</div>
</div>
{!uploadPostKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configura tu API Key de Upload-Post en Settings primero.</div>
</div>
)}
{/* Time + Timezone */}
<div className="mb-5 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
<Clock size={14} className="text-purple-400" />
Hora
</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
disabled={scheduling}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
<Globe size={14} className="text-indigo-400" />
Zona horaria
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
disabled={scheduling}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-indigo-500/50 appearance-none cursor-pointer"
>
{TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
</div>
{/* Start day offset */}
<div className="mb-5 flex items-center justify-between">
<span className="text-xs font-bold text-zinc-400">Empezar desde</span>
<div className="flex items-center gap-2">
<button
onClick={() => setStartOffset(Math.max(1, startOffset - 1))}
disabled={startOffset <= 1 || scheduling}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm text-white font-medium min-w-[90px] text-center">
{(() => {
const d = new Date();
d.setDate(d.getDate() + startOffset);
return `${getDayLabel(d)} ${formatDate(d)}`;
})()}
</span>
<button
onClick={() => setStartOffset(startOffset + 1)}
disabled={scheduling}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/* Calendar grid */}
<div className="mb-5 space-y-2">
{schedule.map(({ clip, index, date }) => (
<div key={index} className="flex items-center gap-3 p-3 bg-white/5 rounded-xl border border-white/5 hover:border-white/10 transition-colors">
<div className="w-14 shrink-0 text-center">
<div className="text-[10px] font-bold text-purple-400 uppercase">{getDayLabel(date)}</div>
<div className="text-lg font-bold text-white leading-tight">{date.getDate()}</div>
<div className="text-[10px] text-zinc-500">{MONTHS[date.getMonth()]}</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-white truncate">
Clip {index + 1}
</div>
<div className="text-[10px] text-zinc-500 truncate">
{clip.video_title_for_youtube_short || 'Viral Short'}
</div>
<div className="text-[10px] text-zinc-600 mt-0.5">
{time}h &middot; {TIMEZONES.find(t => t.value === timezone)?.label || timezone}
</div>
</div>
<div className="shrink-0">
{progress.results[index]?.success === true && (
<CheckCircle size={18} className="text-green-400" />
)}
{progress.results[index]?.success === false && (
<AlertCircle size={18} className="text-red-400" />
)}
{scheduling && progress.current === index && (
<Loader2 size={18} className="text-purple-400 animate-spin" />
)}
{!scheduling && progress.results[index] === undefined && (
<div className="w-4 h-4 rounded-full border-2 border-zinc-700" />
)}
</div>
</div>
))}
</div>
{/* Platforms */}
<div className="mb-5">
<label className="block text-xs font-bold text-zinc-400 mb-2">Plataformas</label>
<div className="flex gap-2">
<button
onClick={() => setPlatforms(p => ({ ...p, tiktok: !p.tiktok }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.tiktok ? 'bg-cyan-500/10 border-cyan-500/30 text-cyan-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Video size={14} /> TikTok
</button>
<button
onClick={() => setPlatforms(p => ({ ...p, instagram: !p.instagram }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.instagram ? 'bg-pink-500/10 border-pink-500/30 text-pink-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Instagram size={14} /> Instagram
</button>
<button
onClick={() => setPlatforms(p => ({ ...p, youtube: !p.youtube }))}
disabled={scheduling}
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.youtube ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
>
<Youtube size={14} /> YouTube
</button>
</div>
</div>
{/* Progress bar */}
{(scheduling || done) && (
<div className="mb-5">
<div className="flex items-center justify-between text-xs text-zinc-400 mb-2">
<span>{scheduling ? 'Programando...' : 'Completado'}</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="w-full h-2 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${done && failCount === 0 ? 'bg-green-500' : done && failCount > 0 ? 'bg-yellow-500' : 'bg-purple-500'}`}
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
{done && (
<div className="mt-3 text-xs text-center">
{failCount === 0 ? (
<span className="text-green-400">Todos los clips programados correctamente</span>
) : (
<span className="text-yellow-400">{successCount} programados, {failCount} fallidos</span>
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={scheduling}
className="flex-1 py-3 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{done ? 'Cerrar' : 'Cancelar'}
</button>
{!done ? (
<button
onClick={handleScheduleAll}
disabled={scheduling || !uploadPostKey || selectedPlatforms.length === 0}
className="flex-1 py-3 bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-400 hover:to-indigo-500 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{scheduling ? (
<>
<Loader2 size={16} className="animate-spin" />
Programando...
</>
) : (
<>
<Calendar size={16} />
Programar {clips?.length || 0} Clips
</>
)}
</button>
) : (
<a
href="https://app.upload-post.com/calendar"
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-3 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-400 hover:to-purple-500 text-white rounded-xl font-bold transition-all flex items-center justify-center gap-2 no-underline"
>
<ExternalLink size={16} />
Ver Calendario
</a>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import { X, Type, Loader2 } from 'lucide-react';
import { getApiUrl } from '../config';
import RemotionPreview from './RemotionPreview';
const FONT_OPTIONS = [
{ value: 'Verdana', label: 'Verdana' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Impact', label: 'Impact' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Courier New', label: 'Courier New' },
];
const COLOR_PRESETS = [
{ color: '#FFFFFF', label: 'White' },
{ color: '#FFFF00', label: 'Yellow' },
{ color: '#00FFFF', label: 'Cyan' },
{ color: '#00FF00', label: 'Green' },
{ color: '#FF0000', label: 'Red' },
{ color: '#FF69B4', label: 'Pink' },
];
const ANIMATION_OPTIONS = [
{ value: 'pop', label: 'Pop' },
{ value: 'word-highlight', label: 'Glow' },
{ value: 'karaoke', label: 'Karaoke' },
{ value: 'none', label: 'None' },
];
export default function SubtitleModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, jobId, clipIndex, existingHook }) {
const [position, setPosition] = useState('bottom');
const [fontSize, setFontSize] = useState(24);
const [fontName, setFontName] = useState('Verdana');
const [fontColor, setFontColor] = useState('#FFFFFF');
const [highlightColor, setHighlightColor] = useState('#FFDD00');
const [borderColor, setBorderColor] = useState('#000000');
const [borderWidth, setBorderWidth] = useState(2);
const [bgColor, setBgColor] = useState('#000000');
const [bgOpacity, setBgOpacity] = useState(0.0);
const [animation, setAnimation] = useState('pop');
const [showTextEditor, setShowTextEditor] = useState(false);
// Remotion preview state
const [captions, setCaptions] = useState([]);
const [originalCaptions, setOriginalCaptions] = useState([]);
const [editableText, setEditableText] = useState('');
const [durationSec, setDurationSec] = useState(30);
const [captionsLoading, setCaptionsLoading] = useState(false);
const [useRemotionPreview, setUseRemotionPreview] = useState(false);
// Fetch word-level captions when modal opens
useEffect(() => {
if (!isOpen || !jobId || clipIndex === undefined) return;
setCaptionsLoading(true);
fetch(getApiUrl(`/api/clip/${jobId}/${clipIndex}/transcript`))
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data && data.captions && data.captions.length > 0) {
setCaptions(data.captions);
setOriginalCaptions(data.captions);
setEditableText(data.captions.map(c => c.text).join(' '));
setDurationSec(data.durationSec || 30);
setUseRemotionPreview(true);
} else {
setUseRemotionPreview(false);
}
})
.catch(() => setUseRemotionPreview(false))
.finally(() => setCaptionsLoading(false));
}, [isOpen, jobId, clipIndex]);
// When user edits text, redistribute words across original timestamps
const handleTextEdit = (newText) => {
setEditableText(newText);
const newWords = newText.split(/\s+/).filter(w => w.length > 0);
if (newWords.length === 0 || originalCaptions.length === 0) {
setCaptions([]);
return;
}
// Distribute new words across the time span of original captions
const totalDurationMs = originalCaptions[originalCaptions.length - 1].endMs - originalCaptions[0].startMs;
const startMs = originalCaptions[0].startMs;
const wordDurationMs = totalDurationMs / newWords.length;
const newCaptions = newWords.map((word, i) => ({
text: word,
startMs: Math.round(startMs + i * wordDurationMs),
endMs: Math.round(startMs + (i + 1) * wordDurationMs),
}));
setCaptions(newCaptions);
};
if (!isOpen) return null;
// Build subtitle config for Remotion
const subtitleConfig = {
captions,
position,
style: {
fontFamily: fontName,
fontSize: fontSize * 2.2, // Scale up for 1080p (modal fontSize is for small preview)
fontColor,
highlightColor,
borderColor,
borderWidth: borderWidth * 1.5,
bgColor,
bgOpacity,
animation,
},
};
// Fallback: static CSS preview (same as original)
const bw = Math.max(borderWidth, 0);
const bc = borderColor;
const outlineShadow = bw > 0 ? [
`-${bw}px -${bw}px 0 ${bc}`, `${bw}px -${bw}px 0 ${bc}`,
`-${bw}px ${bw}px 0 ${bc}`, `${bw}px ${bw}px 0 ${bc}`,
`0 -${bw}px 0 ${bc}`, `0 ${bw}px 0 ${bc}`,
`-${bw}px 0 0 ${bc}`, `${bw}px 0 0 ${bc}`,
].join(', ') : 'none';
const fallbackPreviewStyle = {
fontFamily: fontName,
color: fontColor,
fontSize: '20px',
fontWeight: 'bold',
maxWidth: '85%',
padding: '6px 12px',
borderRadius: '4px',
textAlign: 'center',
lineHeight: '1.3',
...(bgOpacity > 0
? {
backgroundColor: `${bgColor}${Math.round(bgOpacity * 255).toString(16).padStart(2, '0')}`,
textShadow: 'none',
}
: { textShadow: outlineShadow }
),
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-5xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
<button
onClick={onClose}
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
>
<X size={20} />
</button>
{/* Left: Preview */}
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
{captionsLoading ? (
<div className="flex items-center gap-2 text-zinc-400">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">Loading preview...</span>
</div>
) : useRemotionPreview ? (
<RemotionPreview
videoUrl={videoUrl}
durationInSeconds={durationSec}
subtitles={subtitleConfig}
hook={existingHook || null}
/>
) : (
<>
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col items-center justify-center
${position === 'top' ? 'top-20' : ''}
${position === 'middle' ? 'top-0 bottom-0' : ''}
${position === 'bottom' ? 'bottom-20' : ''}
`}>
<span style={fallbackPreviewStyle}>
This is how your subtitles<br/>will appear on the video
</span>
</div>
</>
)}
</div>
{/* Right: Controls */}
<div className="w-full md:w-80 flex flex-col">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2 shrink-0">
<Type className="text-primary" /> Auto Subtitles
</h3>
<div className="space-y-5 flex-1 overflow-y-auto custom-scrollbar pr-1">
{/* Position Selector */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Position</label>
<div className="grid grid-cols-3 gap-2">
{['top', 'middle', 'bottom'].map((pos) => (
<button
key={pos}
onClick={() => setPosition(pos)}
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${position === pos ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
>
{pos.charAt(0).toUpperCase() + pos.slice(1)}
</button>
))}
</div>
</div>
{/* Animation Style (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Animation</label>
<div className="grid grid-cols-2 gap-2">
{ANIMATION_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setAnimation(opt.value)}
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${animation === opt.value ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Editable Transcript (collapsible) */}
{useRemotionPreview && (
<div>
<button
type="button"
onClick={() => setShowTextEditor(!showTextEditor)}
className="w-full flex items-center justify-between text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"
>
<span>Edit Text ({captions.length} words)</span>
<span className={`transition-transform ${showTextEditor ? 'rotate-180' : ''}`}></span>
</button>
{showTextEditor && (
<textarea
value={editableText}
onChange={(e) => handleTextEdit(e.target.value)}
rows={5}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-primary/50 resize-none leading-relaxed animate-[fadeIn_0.15s_ease-out]"
placeholder="Edit subtitle text..."
/>
)}
</div>
)}
{/* Font Family */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Font</label>
<select
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50"
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
))}
</select>
</div>
{/* Text Color */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Text Color</label>
<div className="flex flex-wrap gap-2">
{COLOR_PRESETS.map((c) => (
<button
key={c.color}
onClick={() => setFontColor(c.color)}
className={`w-7 h-7 rounded-full border-2 transition-all ${fontColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
style={{ backgroundColor: c.color }}
title={c.label}
/>
))}
<label className="w-7 h-7 rounded-full border-2 border-dashed border-white/20 cursor-pointer flex items-center justify-center hover:border-white/50 transition-all overflow-hidden relative" title="Custom color">
<span className="text-[10px] text-zinc-400">+</span>
<input type="color" value={fontColor} onChange={(e) => setFontColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
</div>
{/* Highlight Color (new) */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Highlight Color</label>
<div className="flex flex-wrap gap-2">
{[{ color: '#FFDD00', label: 'Gold' }, { color: '#FF4444', label: 'Red' }, { color: '#00FF88', label: 'Green' }, { color: '#00BBFF', label: 'Blue' }, { color: '#FF69B4', label: 'Pink' }].map((c) => (
<button
key={c.color}
onClick={() => setHighlightColor(c.color)}
className={`w-7 h-7 rounded-full border-2 transition-all ${highlightColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
style={{ backgroundColor: c.color }}
title={c.label}
/>
))}
</div>
</div>
{/* Border / Outline */}
<div>
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Border</label>
<div className="flex items-center gap-3">
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Border color">
<div className="w-full h-full" style={{ backgroundColor: borderColor }} />
<input type="color" value={borderColor} onChange={(e) => setBorderColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
<div className="flex-1">
<input
type="range"
min="0"
max="5"
value={borderWidth}
onChange={(e) => setBorderWidth(parseInt(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>None</span>
<span>Thick</span>
</div>
</div>
</div>
</div>
{/* Background Box */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Background Box</label>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={bgOpacity > 0} onChange={(e) => setBgOpacity(e.target.checked ? 0.5 : 0)} className="sr-only peer" />
<div className="w-8 h-4 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[0px] after:left-[0px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
{bgOpacity > 0 && (
<div className="space-y-3 animate-[fadeIn_0.2s_ease-out]">
<div className="flex items-center gap-3">
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Background color">
<div className="w-full h-full" style={{ backgroundColor: bgColor }} />
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
</label>
<div className="flex-1">
<input
type="range"
min="10"
max="100"
value={Math.round(bgOpacity * 100)}
onChange={(e) => setBgOpacity(parseInt(e.target.value) / 100)}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>Transparent</span>
<span>{Math.round(bgOpacity * 100)}%</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<button
onClick={() => onGenerate({
position, fontSize, fontName, fontColor, borderColor, borderWidth, bgColor, bgOpacity,
// Remotion data
remotion: useRemotionPreview ? subtitleConfig : null,
})}
disabled={isProcessing}
className="w-full py-3 mt-4 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-400 hover:to-orange-400 text-black font-bold rounded-xl shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 shrink-0"
>
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Type size={20} />}
{isProcessing ? 'Generating...' : 'Generate Subtitles'}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { X, Loader2, Globe, Languages, AlertCircle } from 'lucide-react';
import { getApiUrl } from '../config';
const LANGUAGES = {
"es": "Spanish",
"fr": "French",
"de": "German",
"it": "Italian",
"pt": "Portuguese",
"pl": "Polish",
"hi": "Hindi",
"ja": "Japanese",
"ko": "Korean",
"zh": "Chinese",
"ar": "Arabic",
"ru": "Russian",
"tr": "Turkish",
"nl": "Dutch",
"sv": "Swedish",
"id": "Indonesian",
"fil": "Filipino",
"ms": "Malay",
"vi": "Vietnamese",
"th": "Thai",
"uk": "Ukrainian",
"el": "Greek",
"cs": "Czech",
"fi": "Finnish",
"ro": "Romanian",
"da": "Danish",
"bg": "Bulgarian",
"hr": "Croatian",
"sk": "Slovak",
"ta": "Tamil",
"en": "English",
};
export default function TranslateModal({ isOpen, onClose, onTranslate, isProcessing, videoUrl, hasApiKey }) {
const [targetLanguage, setTargetLanguage] = useState('es');
if (!isOpen) return null;
const handleSubmit = () => {
console.log('[TranslateModal] handleSubmit called, targetLanguage:', targetLanguage);
onTranslate({ targetLanguage });
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative">
<button
onClick={onClose}
disabled={isProcessing}
className="absolute top-4 right-4 text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={20} />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center">
<Languages size={20} className="text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Dub Voice</h3>
<p className="text-xs text-zinc-500">AI voice translation by ElevenLabs</p>
</div>
</div>
{!hasApiKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configure ElevenLabs API Key in Settings first.</div>
</div>
)}
{/* Preview */}
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative">
<video
src={videoUrl}
className="w-full h-full object-contain"
muted
playsInline
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent pointer-events-none" />
</div>
{/* Language Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-zinc-400 mb-2">
<Globe size={14} className="inline mr-2" />
Target Language
</label>
<select
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-green-500/50 appearance-none cursor-pointer"
disabled={isProcessing}
>
{Object.entries(LANGUAGES).sort((a, b) => a[1].localeCompare(b[1])).map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
</div>
{/* Info */}
<div className="mb-6 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-xs text-green-400">
The audio will be dubbed with AI-generated voice in the selected language, matching the original speaker's characteristics.
</p>
</div>
{/* Processing State */}
{isProcessing && (
<div className="mb-4 p-4 bg-white/5 rounded-lg border border-white/10">
<div className="flex items-center gap-3">
<Loader2 size={20} className="text-green-400 animate-spin" />
<div>
<p className="text-sm text-white font-medium">Dubbing audio...</p>
<p className="text-xs text-zinc-500">This may take a few minutes</p>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isProcessing}
className="flex-1 py-3 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-xl font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isProcessing || !hasApiKey}
className="flex-1 py-3 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
Dubbing...
</>
) : (
<>
<Languages size={16} />
Dub Voice
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,237 @@
import React, { useState, useEffect, useRef } from 'react';
import { Film, Download, Copy, Check, ExternalLink, Loader2, Play, User } from 'lucide-react';
import { getApiUrl } from '../config';
export default function UGCGallery() {
const [tab, setTab] = useState('videos');
const [videos, setVideos] = useState([]);
const [avatars, setAvatars] = useState([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
fetch(getApiUrl('/api/saasshorts/gallery?limit=100')).then(r => r.ok ? r.json() : { videos: [] }),
fetch(getApiUrl('/api/saasshorts/actor-gallery')).then(r => r.ok ? r.json() : { images: [] }),
])
.then(([vData, aData]) => {
setVideos(vData.videos || []);
setAvatars(aData.images || []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleCopy = (text, id) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(''), 2000);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-violet-400" />
<span className="ml-2 text-zinc-400">Loading gallery...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-zinc-200">UGC Gallery</h2>
<p className="text-xs text-zinc-500">{videos.length} videos · {avatars.length} avatars</p>
</div>
<a
href={getApiUrl('/gallery')}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-violet-400 hover:text-violet-300 flex items-center gap-1"
>
<ExternalLink size={12} /> Public Gallery
</a>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-white/5 p-1 rounded-lg w-fit">
<button
onClick={() => setTab('videos')}
className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all ${
tab === 'videos' ? 'bg-violet-500/20 text-violet-300' : 'text-zinc-400 hover:text-white'
}`}
>
<Film size={12} className="inline mr-1.5" />Videos ({videos.length})
</button>
<button
onClick={() => setTab('avatars')}
className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all ${
tab === 'avatars' ? 'bg-violet-500/20 text-violet-300' : 'text-zinc-400 hover:text-white'
}`}
>
<User size={12} className="inline mr-1.5" />Avatars ({avatars.length})
</button>
</div>
{/* Videos Tab */}
{tab === 'videos' && (
videos.length === 0 ? (
<div className="text-center py-16">
<Film size={40} className="mx-auto text-zinc-700 mb-3" />
<p className="text-sm text-zinc-500">No videos yet. Generate one from AI Shorts.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{videos.map((video) => (
<VideoCard key={video.video_id} video={video} copied={copied} onCopy={handleCopy} />
))}
</div>
)
)}
{/* Avatars Tab */}
{tab === 'avatars' && (
avatars.length === 0 ? (
<div className="text-center py-16">
<User size={40} className="mx-auto text-zinc-700 mb-3" />
<p className="text-sm text-zinc-500">No avatars yet. Generate actors from AI Shorts.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{avatars.map((avatar, i) => (
<AvatarCard key={avatar.key || i} avatar={avatar} copied={copied} onCopy={handleCopy} />
))}
</div>
)
)}
</div>
);
}
function AvatarCard({ avatar, copied, onCopy }) {
return (
<div className="group rounded-xl overflow-hidden border border-white/10 bg-white/5 hover:border-white/20 transition-all">
<div className="aspect-[3/4] bg-black">
<img src={avatar.url} alt="Avatar" className="w-full h-full object-cover" />
</div>
<div className="p-2 space-y-1">
{avatar.description ? (
<div className="relative pr-4">
<p className="text-[9px] text-zinc-400 line-clamp-2">{avatar.description}</p>
<button
onClick={() => onCopy(avatar.description, `avatar-${avatar.key}`)}
className="absolute top-0 right-0 p-0.5 text-zinc-600 hover:text-zinc-300"
title="Copy prompt"
>
{copied === `avatar-${avatar.key}` ? <Check size={9} /> : <Copy size={9} />}
</button>
</div>
) : (
<p className="text-[9px] text-zinc-600 italic">No description</p>
)}
<a
href={avatar.url}
download
className="block text-center text-[9px] bg-white/5 hover:bg-white/10 text-zinc-400 py-1 rounded-md transition-colors"
>
<Download size={9} className="inline mr-0.5" />Download
</a>
</div>
</div>
);
}
function VideoCard({ video, copied, onCopy }) {
const videoRef = useRef(null);
const [playing, setPlaying] = useState(false);
const handleMouseEnter = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
setPlaying(true);
}
};
const handleMouseLeave = () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
setPlaying(false);
}
};
const mode = video.video_mode;
const caption = video.caption || '';
const hashtags = (video.hashtags || []).join(' ');
return (
<div className="group rounded-xl overflow-hidden border border-white/10 bg-white/5 hover:border-white/20 transition-all">
<div
className="relative aspect-[9/16] bg-black cursor-pointer"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<video
ref={videoRef}
src={video.video_url}
poster={video.actor_url}
muted
playsInline
preload="metadata"
className="w-full h-full object-cover"
/>
{!playing && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Play size={20} className="text-white/70" />
</div>
)}
<div className="absolute top-1.5 right-1.5">
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded-full ${
mode === 'lowcost' ? 'bg-green-500 text-black' : 'bg-violet-500 text-white'
}`}>
{mode === 'lowcost' ? 'LOW COST' : 'PREMIUM'}
</span>
</div>
</div>
<div className="p-2 space-y-1">
<h3 className="text-[11px] font-semibold text-zinc-200 truncate">{video.title || 'Untitled'}</h3>
<p className="text-[9px] text-zinc-500">
{video.duration?.toFixed(0)}s · ${video.cost_estimate?.total?.toFixed(2) || '?'}
</p>
{caption && (
<div className="relative pr-4">
<p className="text-[9px] text-zinc-400 line-clamp-2">{caption}</p>
<button
onClick={() => onCopy(`${caption}\n${hashtags}`, `caption-${video.video_id}`)}
className="absolute top-0 right-0 p-0.5 text-zinc-600 hover:text-zinc-300"
title="Copy caption"
>
{copied === `caption-${video.video_id}` ? <Check size={9} /> : <Copy size={9} />}
</button>
</div>
)}
<div className="flex gap-1 pt-0.5">
<a
href={video.video_url}
download
className="flex-1 text-center text-[9px] bg-white/5 hover:bg-white/10 text-zinc-400 py-1 rounded-md transition-colors"
>
<Download size={9} className="inline mr-0.5" />Download
</a>
<a
href={getApiUrl(`/video/${video.video_id}`)}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-[9px] bg-violet-500/10 hover:bg-violet-500/20 text-violet-400 py-1 rounded-md transition-colors"
>
<ExternalLink size={9} className="inline mr-0.5" />View
</a>
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Configuration for API endpoints
// If VITE_API_URL is set (e.g. in production), use it.
// Otherwise, default to empty string which means relative paths (proxied in dev).
export const API_BASE_URL = import.meta.env.VITE_API_URL || '';
export const getApiUrl = (path) => {
if (path.startsWith('http')) return path;
// Ensure path starts with / if not present
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
};
+72
View File
@@ -0,0 +1,72 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif:wght@700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-background text-white antialiased;
}
}
.glass-panel {
@apply bg-surface/50 backdrop-blur-xl border border-white/10 rounded-2xl shadow-xl;
}
.input-field {
@apply w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all text-sm placeholder:text-zinc-500;
}
.btn-primary {
@apply bg-primary hover:bg-blue-600 text-white px-6 py-3 rounded-xl font-medium transition-all active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/20;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Custom Animations */
@keyframes scan {
0% {
top: 0%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
@keyframes scan-overlay {
0% {
top: -20%;
}
100% {
top: 100%;
}
}
@@ -0,0 +1,74 @@
import { renderMediaOnWeb } from '@remotion/web-renderer';
import { ShortVideo } from '../remotion/compositions/ShortVideo';
/**
* Renders a Remotion composition directly in the browser using WebCodecs.
* Returns a blob URL to the rendered MP4.
*
* @param {object} params
* @param {string} params.videoUrl - Source video URL
* @param {number} params.durationInSeconds - Video duration
* @param {object|null} params.subtitles - SubtitleConfig
* @param {object|null} params.hook - HookConfig
* @param {object|null} params.effects - EffectsConfig
* @param {function} [params.onProgress] - Progress callback (0-1)
* @param {AbortSignal} [params.signal] - Abort signal for cancellation
* @returns {Promise<string>} Blob URL of the rendered MP4
*/
export async function renderInBrowser({
videoUrl,
durationInSeconds = 30,
subtitles = null,
hook = null,
effects = null,
onProgress,
signal,
}) {
const fps = 30;
const durationInFrames = Math.max(1, Math.round(durationInSeconds * fps));
const { getBlob } = await renderMediaOnWeb({
composition: {
component: ShortVideo,
durationInFrames,
fps,
width: 1080,
height: 1920,
id: 'ShortVideo',
calculateMetadata: null,
},
inputProps: {
videoUrl,
durationInFrames,
fps,
width: 1080,
height: 1920,
subtitles,
hook,
effects,
},
container: 'mp4',
videoCodec: 'h264',
videoBitrate: 'high',
audioCodec: 'aac',
onProgress: onProgress
? ({ progress }) => onProgress(progress)
: undefined,
signal,
});
const blob = await getBlob();
return URL.createObjectURL(blob);
}
/**
* Triggers a download of a blob URL as an MP4 file.
*/
export function downloadBlobUrl(blobUrl, filename = 'output.mp4') {
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
+39
View File
@@ -0,0 +1,39 @@
import { StrictMode, useState, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import Landing from './Landing.jsx'
import Legal from './Legal.jsx'
function Root() {
const resolveView = () => {
const hash = window.location.hash;
if (hash === '#legal') return 'legal';
if (hash === '#app' || localStorage.getItem('openshorts_skip_landing') === '1') return 'app';
return 'landing';
};
const [view, setView] = useState(resolveView);
useEffect(() => {
const handleHashChange = () => setView(resolveView());
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const handleLaunchApp = () => {
localStorage.setItem('openshorts_skip_landing', '1');
window.location.hash = '#app';
setView('app');
};
if (view === 'legal') return <Legal />;
if (view === 'app') return <App />;
return <Landing onLaunchApp={handleLaunchApp} />;
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<Root />
</StrictMode>,
)
@@ -0,0 +1,144 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import type { HookConfig } from "../lib/types";
import { notoSerifFontFace, NOTO_SERIF_FONT_FAMILY } from "../lib/fonts";
interface HookOverlayProps {
config: HookConfig;
}
const SIZE_SCALE: Record<string, number> = {
S: 0.8,
M: 1.0,
L: 1.3,
};
const POSITION_STYLE: Record<string, React.CSSProperties> = {
top: { top: "18%", bottom: "auto" },
center: { top: "50%", bottom: "auto", transform: "translateY(-50%)" },
bottom: { top: "68%", bottom: "auto" },
};
export const HookOverlay: React.FC<HookOverlayProps> = ({ config }) => {
const { fps } = useVideoConfig();
const displayFrames = Math.round(config.displayDurationSec * fps);
return (
<AbsoluteFill>
<style>{notoSerifFontFace}</style>
<Sequence from={0} durationInFrames={displayFrames} layout="none">
<HookBox config={config} displayFrames={displayFrames} />
</Sequence>
</AbsoluteFill>
);
};
interface HookBoxProps {
config: HookConfig;
displayFrames: number;
}
const HookBox: React.FC<HookBoxProps> = ({ config, displayFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = SIZE_SCALE[config.size] ?? 1.0;
// Entrance animation
let animOpacity = 1;
let animScale = 1;
let animTranslateY = 0;
switch (config.entranceAnimation) {
case "spring": {
const prog = spring({
frame,
fps,
config: { mass: 0.8, stiffness: 200, damping: 15 },
durationInFrames: 20,
});
animScale = interpolate(prog, [0, 1], [0.7, 1]);
animOpacity = interpolate(prog, [0, 1], [0, 1]);
break;
}
case "fade": {
animOpacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
break;
}
case "slide-up": {
const prog = spring({
frame,
fps,
config: { mass: 1, stiffness: 150, damping: 18 },
durationInFrames: 20,
});
animTranslateY = interpolate(prog, [0, 1], [60, 0]);
animOpacity = interpolate(prog, [0, 1], [0, 1]);
break;
}
default:
break;
}
// Exit fade (last 15 frames)
const fadeOutStart = displayFrames - 15;
if (frame > fadeOutStart) {
animOpacity *= interpolate(frame, [fadeOutStart, displayFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
}
const positionStyle = POSITION_STYLE[config.position] ?? POSITION_STYLE.top;
// Base font size: 5% of 1080 width (matches hooks.py logic)
const baseFontSize = 1080 * 0.05;
const fontSize = Math.round(baseFontSize * scale);
return (
<div
style={{
position: "absolute",
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
...positionStyle,
}}
>
<div
style={{
opacity: animOpacity,
transform: `scale(${animScale}) translateY(${animTranslateY}px)`,
maxWidth: "90%",
backgroundColor: "rgba(255, 255, 255, 0.94)",
borderRadius: 20,
padding: `${25 * scale}px ${30 * scale}px`,
boxShadow: "5px 5px 15px rgba(0, 0, 0, 0.25)",
textAlign: "center",
}}
>
<span
style={{
fontFamily: `'${NOTO_SERIF_FONT_FAMILY}', 'Noto Serif', Georgia, serif`,
fontSize,
fontWeight: 700,
color: "#000000",
lineHeight: 1.4,
wordBreak: "break-word",
}}
>
{config.text}
</span>
</div>
</div>
);
};
@@ -0,0 +1,33 @@
import React from "react";
import { AbsoluteFill } from "remotion";
import { Video } from "@remotion/media";
import type { ShortVideoProps } from "../lib/types";
import { Subtitles } from "./Subtitles";
import { HookOverlay } from "./HookOverlay";
import { VideoEffects } from "./VideoEffects";
/**
* Main composition that layers all post-processing on top of the base video.
* Uses @remotion/media Video for browser-side rendering compatibility.
*/
export const ShortVideo: React.FC<Record<string, unknown>> = (rawProps) => {
const { videoUrl, subtitles, hook, effects } =
rawProps as unknown as ShortVideoProps;
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* Layer 1: Base video with optional zoom/color effects */}
<VideoEffects config={effects}>
<Video
src={videoUrl}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</VideoEffects>
{/* Layer 2: Animated subtitles */}
{subtitles && <Subtitles config={subtitles} />}
{/* Layer 3: Hook text overlay */}
{hook && <HookOverlay config={hook} />}
</AbsoluteFill>
);
};
@@ -0,0 +1,227 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import type { SubtitleConfig } from "../lib/types";
import { groupCaptionsIntoBlocks, getActiveWordIndex } from "../lib/captions";
import { getFontStack } from "../lib/fonts";
interface SubtitlesProps {
config: SubtitleConfig;
}
const POSITION_MAP: Record<string, React.CSSProperties> = {
top: { top: "12%", bottom: "auto" },
middle: { top: "45%", bottom: "auto" },
bottom: { bottom: "10%", top: "auto" },
};
export const Subtitles: React.FC<SubtitlesProps> = ({ config }) => {
const { fps } = useVideoConfig();
const blocks = groupCaptionsIntoBlocks(config.captions);
return (
<AbsoluteFill>
{blocks.map((block, i) => {
const startFrame = Math.round((block.startMs / 1000) * fps);
const durationFrames = Math.max(
1,
Math.round(((block.endMs - block.startMs) / 1000) * fps)
);
return (
<Sequence
key={i}
from={startFrame}
durationInFrames={durationFrames}
layout="none"
>
<SubtitleBlock
block={block}
config={config}
blockStartMs={block.startMs}
/>
</Sequence>
);
})}
</AbsoluteFill>
);
};
interface SubtitleBlockProps {
block: ReturnType<typeof groupCaptionsIntoBlocks>[number];
config: SubtitleConfig;
blockStartMs: number;
}
const SubtitleBlock: React.FC<SubtitleBlockProps> = ({
block,
config,
blockStartMs,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { style, position } = config;
// Current time relative to composition start (sequence-relative frame)
const currentTimeMs = blockStartMs + (frame / fps) * 1000;
const activeIndex = getActiveWordIndex(block.words, currentTimeMs);
const positionStyle = POSITION_MAP[position] ?? POSITION_MAP.bottom;
const fontStack = getFontStack(style.fontFamily);
// Background box style
const hasBg = style.bgOpacity > 0;
const bgStyle: React.CSSProperties = hasBg
? {
backgroundColor: `${style.bgColor}${Math.round(style.bgOpacity * 255)
.toString(16)
.padStart(2, "0")}`,
borderRadius: 8,
padding: "8px 16px",
}
: {};
return (
<div
style={{
position: "absolute",
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
...positionStyle,
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "6px 8px",
maxWidth: "85%",
...bgStyle,
}}
>
{block.words.map((word, i) => (
<WordSpan
key={i}
word={word.text}
isActive={i === activeIndex}
style={style}
fontStack={fontStack}
animation={style.animation}
frame={frame}
fps={fps}
wordStartMs={word.startMs}
blockStartMs={blockStartMs}
/>
))}
</div>
</div>
);
};
interface WordSpanProps {
word: string;
isActive: boolean;
style: SubtitleConfig["style"];
fontStack: string;
animation: SubtitleConfig["style"]["animation"];
frame: number;
fps: number;
wordStartMs: number;
blockStartMs: number;
}
const WordSpan: React.FC<WordSpanProps> = ({
word,
isActive,
style,
fontStack,
animation,
frame,
fps,
wordStartMs,
blockStartMs,
}) => {
const wordStartFrame = Math.round(
((wordStartMs - blockStartMs) / 1000) * fps
);
let transform = "";
let color = style.fontColor;
let extraStyle: React.CSSProperties = {};
if (isActive) {
color = style.highlightColor;
switch (animation) {
case "pop": {
const scale = spring({
frame: frame - wordStartFrame,
fps,
config: { mass: 0.5, stiffness: 300, damping: 12 },
durationInFrames: 10,
});
const scaleValue = interpolate(scale, [0, 1], [1, 1.25]);
transform = `scale(${scaleValue})`;
break;
}
case "karaoke": {
extraStyle = {
backgroundColor: style.highlightColor,
color: style.bgColor || "#000000",
borderRadius: 4,
padding: "2px 6px",
};
break;
}
case "word-highlight": {
extraStyle = {
textShadow: `0 0 12px ${style.highlightColor}, 0 0 24px ${style.highlightColor}40`,
};
break;
}
default:
break;
}
}
// Text stroke via textShadow (CSS paint-order not reliable in Remotion)
const strokeShadow =
style.borderWidth > 0
? [
`${style.borderWidth}px 0 0 ${style.borderColor}`,
`-${style.borderWidth}px 0 0 ${style.borderColor}`,
`0 ${style.borderWidth}px 0 ${style.borderColor}`,
`0 -${style.borderWidth}px 0 ${style.borderColor}`,
].join(", ")
: "none";
return (
<span
style={{
fontFamily: fontStack,
fontSize: style.fontSize,
fontWeight: 700,
color: animation === "karaoke" && isActive ? undefined : color,
textShadow:
animation !== "karaoke"
? [strokeShadow, extraStyle.textShadow].filter(Boolean).join(", ")
: strokeShadow,
transform,
display: "inline-block",
transition: "none",
...extraStyle,
}}
>
{word}
</span>
);
};
@@ -0,0 +1,209 @@
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import type { EffectsConfig, EffectSegment } from "../lib/types";
interface VideoEffectsProps {
config: EffectsConfig | null;
children: React.ReactNode;
}
/**
* Wraps children (typically <OffthreadVideo>) with dynamic CSS transforms and filters.
* Interpolates smoothly between effect segments.
*/
export const VideoEffects: React.FC<VideoEffectsProps> = ({
config,
children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (!config || config.segments.length === 0) {
return <>{children}</>;
}
const currentTimeSec = frame / fps;
const { zoom, centerX, centerY, brightness, contrast, saturate } =
getInterpolatedValues(config.segments, currentTimeSec, frame, fps);
const filterParts: string[] = [];
if (brightness !== 1) filterParts.push(`brightness(${brightness})`);
if (contrast !== 1) filterParts.push(`contrast(${contrast})`);
if (saturate !== 1) filterParts.push(`saturate(${saturate})`);
const filterStr = filterParts.length > 0 ? filterParts.join(" ") : "none";
return (
<div
style={{
width: "100%",
height: "100%",
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
width: "100%",
height: "100%",
transform: `scale(${zoom})`,
transformOrigin: `${centerX * 100}% ${centerY * 100}%`,
filter: filterStr,
}}
>
{children}
</div>
</div>
);
};
interface InterpolatedValues {
zoom: number;
centerX: number;
centerY: number;
brightness: number;
contrast: number;
saturate: number;
}
function getInterpolatedValues(
segments: EffectSegment[],
timeSec: number,
frame: number,
fps: number
): InterpolatedValues {
// Default values (no effect)
const defaults: InterpolatedValues = {
zoom: 1,
centerX: 0.5,
centerY: 0.5,
brightness: 1,
contrast: 1,
saturate: 1,
};
// Find active segment
const active = segments.find(
(s) => timeSec >= s.startSec && timeSec < s.endSec
);
if (!active) {
// Check if we're transitioning between segments (smooth fade)
const prev = segments.filter((s) => s.endSec <= timeSec).pop();
const next = segments.find((s) => s.startSec > timeSec);
if (prev && next) {
const gap = next.startSec - prev.endSec;
if (gap < 1.0) {
// Short gap: interpolate between prev and next
const progress = (timeSec - prev.endSec) / gap;
return lerpSegments(prev, next, progress);
}
}
// Transition out from previous segment
if (prev) {
const fadeOutDuration = 0.3; // seconds
const elapsed = timeSec - prev.endSec;
if (elapsed < fadeOutDuration) {
const progress = elapsed / fadeOutDuration;
return lerpToDefaults(prev, progress, defaults);
}
}
// Transition into next segment
if (next) {
const fadeInDuration = 0.3;
const remaining = next.startSec - timeSec;
if (remaining < fadeInDuration) {
const progress = 1 - remaining / fadeInDuration;
return lerpFromDefaults(next, progress, defaults);
}
}
return defaults;
}
// Inside active segment: smooth entrance/exit at edges
const segDuration = active.endSec - active.startSec;
const transitionSec = Math.min(0.3, segDuration * 0.15);
const startFrame = Math.round(active.startSec * fps);
const endFrame = Math.round(active.endSec * fps);
const transitionFrames = Math.round(transitionSec * fps);
// Entrance ease
const entranceFactor = interpolate(
frame,
[startFrame, startFrame + transitionFrames],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Exit ease
const exitFactor = interpolate(
frame,
[endFrame - transitionFrames, endFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const factor = Math.min(entranceFactor, exitFactor);
return {
zoom: lerp(1, active.zoom, factor),
centerX: lerp(0.5, active.zoomCenterX, factor),
centerY: lerp(0.5, active.zoomCenterY, factor),
brightness: lerp(1, active.brightness, factor),
contrast: lerp(1, active.contrast, factor),
saturate: lerp(1, active.saturate, factor),
};
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function lerpSegments(
a: EffectSegment,
b: EffectSegment,
t: number
): InterpolatedValues {
return {
zoom: lerp(a.zoom, b.zoom, t),
centerX: lerp(a.zoomCenterX, b.zoomCenterX, t),
centerY: lerp(a.zoomCenterY, b.zoomCenterY, t),
brightness: lerp(a.brightness, b.brightness, t),
contrast: lerp(a.contrast, b.contrast, t),
saturate: lerp(a.saturate, b.saturate, t),
};
}
function lerpToDefaults(
seg: EffectSegment,
t: number,
defaults: InterpolatedValues
): InterpolatedValues {
return {
zoom: lerp(seg.zoom, defaults.zoom, t),
centerX: lerp(seg.zoomCenterX, defaults.centerX, t),
centerY: lerp(seg.zoomCenterY, defaults.centerY, t),
brightness: lerp(seg.brightness, defaults.brightness, t),
contrast: lerp(seg.contrast, defaults.contrast, t),
saturate: lerp(seg.saturate, defaults.saturate, t),
};
}
function lerpFromDefaults(
seg: EffectSegment,
t: number,
defaults: InterpolatedValues
): InterpolatedValues {
return {
zoom: lerp(defaults.zoom, seg.zoom, t),
centerX: lerp(defaults.centerX, seg.zoomCenterX, t),
centerY: lerp(defaults.centerY, seg.zoomCenterY, t),
brightness: lerp(defaults.brightness, seg.brightness, t),
contrast: lerp(defaults.contrast, seg.contrast, t),
saturate: lerp(defaults.saturate, seg.saturate, t),
};
}
@@ -0,0 +1,83 @@
import type { CaptionWord } from "./types";
export interface CaptionBlock {
words: CaptionWord[];
startMs: number;
endMs: number;
text: string;
}
/**
* Groups word-level captions into display blocks.
* Same logic as OpenShorts' generate_srt: max chars per block, max duration per block.
*/
export function groupCaptionsIntoBlocks(
captions: CaptionWord[],
maxChars = 20,
maxDurationMs = 2000
): CaptionBlock[] {
const blocks: CaptionBlock[] = [];
let currentWords: CaptionWord[] = [];
let blockStartMs = 0;
for (const word of captions) {
if (currentWords.length === 0) {
currentWords.push(word);
blockStartMs = word.startMs;
continue;
}
const currentTextLen = currentWords.reduce(
(sum, w) => sum + w.text.length + 1,
0
);
const duration = word.endMs - blockStartMs;
if (
currentTextLen + word.text.length > maxChars ||
duration > maxDurationMs
) {
// Finalize current block
const lastWord = currentWords[currentWords.length - 1];
blocks.push({
words: [...currentWords],
startMs: blockStartMs,
endMs: lastWord.endMs,
text: currentWords.map((w) => w.text).join(" "),
});
currentWords = [word];
blockStartMs = word.startMs;
} else {
currentWords.push(word);
}
}
// Final block
if (currentWords.length > 0) {
const lastWord = currentWords[currentWords.length - 1];
blocks.push({
words: [...currentWords],
startMs: blockStartMs,
endMs: lastWord.endMs,
text: currentWords.map((w) => w.text).join(" "),
});
}
return blocks;
}
/**
* Find the active word at a given time in milliseconds.
*/
export function getActiveWordIndex(
words: CaptionWord[],
timeMs: number
): number {
for (let i = 0; i < words.length; i++) {
if (timeMs >= words[i].startMs && timeMs < words[i].endMs) {
return i;
}
}
return -1;
}
@@ -0,0 +1,33 @@
import { staticFile } from "remotion";
/**
* CSS @font-face declaration for NotoSerif-Bold (bundled locally).
* Use in components via: <style>{notoSerifFontFace}</style>
*/
export const NOTO_SERIF_FONT_FAMILY = "NotoSerif-Bold";
export const notoSerifFontFace = `
@font-face {
font-family: '${NOTO_SERIF_FONT_FAMILY}';
src: url('${staticFile("fonts/NotoSerif-Bold.ttf")}') format('truetype');
font-weight: 700;
font-style: normal;
}
`;
/**
* Map of subtitle font families to their CSS-safe names.
* These match the options available in SubtitleModal.jsx.
*/
export const SUBTITLE_FONTS: Record<string, string> = {
Verdana: "Verdana, Geneva, sans-serif",
Arial: "Arial, Helvetica, sans-serif",
Impact: "Impact, Haettenschweiler, sans-serif",
Helvetica: "Helvetica, Arial, sans-serif",
Georgia: "Georgia, 'Times New Roman', serif",
"Courier New": "'Courier New', Courier, monospace",
};
export function getFontStack(fontFamily: string): string {
return SUBTITLE_FONTS[fontFamily] ?? fontFamily;
}
@@ -0,0 +1,130 @@
import { z } from "zod";
// --- Word-level caption ---
export interface CaptionWord {
text: string;
startMs: number;
endMs: number;
}
// --- Subtitle config ---
export type SubtitleAnimation = "none" | "word-highlight" | "pop" | "karaoke";
export type SubtitlePosition = "top" | "middle" | "bottom";
export interface SubtitleStyle {
fontFamily: string;
fontSize: number;
fontColor: string;
highlightColor: string;
borderColor: string;
borderWidth: number;
bgColor: string;
bgOpacity: number;
animation: SubtitleAnimation;
}
export interface SubtitleConfig {
captions: CaptionWord[];
position: SubtitlePosition;
style: SubtitleStyle;
}
// --- Hook config ---
export type HookPosition = "top" | "center" | "bottom";
export type HookSize = "S" | "M" | "L";
export type HookEntrance = "spring" | "fade" | "slide-up" | "none";
export interface HookConfig {
text: string;
position: HookPosition;
size: HookSize;
entranceAnimation: HookEntrance;
displayDurationSec: number;
}
// --- Effects config ---
export interface EffectSegment {
startSec: number;
endSec: number;
zoom: number;
zoomCenterX: number;
zoomCenterY: number;
brightness: number;
contrast: number;
saturate: number;
}
export interface EffectsConfig {
segments: EffectSegment[];
}
// --- Main composition props ---
export interface ShortVideoProps {
videoUrl: string;
durationInFrames: number;
fps: number;
width: number;
height: number;
subtitles: SubtitleConfig | null;
hook: HookConfig | null;
effects: EffectsConfig | null;
}
// --- Zod schemas for validation (used by render service) ---
export const captionWordSchema = z.object({
text: z.string(),
startMs: z.number(),
endMs: z.number(),
});
export const subtitleStyleSchema = z.object({
fontFamily: z.string(),
fontSize: z.number(),
fontColor: z.string(),
highlightColor: z.string(),
borderColor: z.string(),
borderWidth: z.number(),
bgColor: z.string(),
bgOpacity: z.number().min(0).max(1),
animation: z.enum(["none", "word-highlight", "pop", "karaoke"]),
});
export const subtitleConfigSchema = z.object({
captions: z.array(captionWordSchema),
position: z.enum(["top", "middle", "bottom"]),
style: subtitleStyleSchema,
});
export const hookConfigSchema = z.object({
text: z.string(),
position: z.enum(["top", "center", "bottom"]),
size: z.enum(["S", "M", "L"]),
entranceAnimation: z.enum(["spring", "fade", "slide-up", "none"]),
displayDurationSec: z.number().positive(),
});
export const effectSegmentSchema = z.object({
startSec: z.number().min(0),
endSec: z.number().positive(),
zoom: z.number().min(0.5).max(3),
zoomCenterX: z.number().min(0).max(1),
zoomCenterY: z.number().min(0).max(1),
brightness: z.number().min(0).max(3),
contrast: z.number().min(0).max(3),
saturate: z.number().min(0).max(3),
});
export const effectsConfigSchema = z.object({
segments: z.array(effectSegmentSchema),
});
export const shortVideoPropsSchema = z.object({
videoUrl: z.string(),
durationInFrames: z.number().int().positive(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
subtitles: subtitleConfigSchema.nullable(),
hook: hookConfigSchema.nullable(),
effects: effectsConfigSchema.nullable(),
});
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: "#09090b",
surface: "#18181b",
primary: "#3b82f6",
accent: "#8b5cf6",
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}
},
},
plugins: [],
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: [
'openshorts.app',
'www.openshorts.app'
],
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/videos': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/thumbnails': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/gallery': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/video': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/render': {
target: 'http://renderer:3100',
changeOrigin: true,
}
}
}
})
+40
View File
@@ -0,0 +1,40 @@
services:
backend:
build: .
container_name: openshorts-backend
ports:
- "8000:8000"
volumes:
- .:/app
- /app/__pycache__
- ./output:/app/output
restart: unless-stopped
frontend:
build: ./dashboard
container_name: openshorts-frontend
ports:
- "5175:5173"
volumes:
- ./dashboard:/app
- /app/node_modules
restart: unless-stopped
depends_on:
- backend
renderer:
build:
context: .
dockerfile: render-service/Dockerfile
container_name: openshorts-renderer
ports:
- "3100:3100"
volumes:
- ./output:/output
environment:
- REMOTION_BUNDLE_PATH=/app/remotion
- OUTPUT_DIR=/output
- PORT=3100
restart: unless-stopped
depends_on:
- backend
+376
View File
@@ -0,0 +1,376 @@
import os
import json
import re
import subprocess
import time
from google import genai
from google.genai import types
class VideoEditor:
def __init__(self, api_key):
self.client = genai.Client(api_key=api_key)
self.model_name = "gemini-3-flash-preview"
def upload_video(self, video_path):
"""Uploads video to Gemini File API."""
print(f"📤 Uploading {video_path} to Gemini...")
# Ensure we are passing a path that exists
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
# Using 'file' keyword instead of 'path'
try:
file_upload = self.client.files.upload(file=video_path)
except Exception as e:
print(f"❌ Gemini Upload Error: {e}")
raise e
# Wait for processing
print("⏳ Waiting for video processing by Gemini...")
while True:
file_info = self.client.files.get(name=file_upload.name)
if file_info.state == "ACTIVE":
print("✅ Video processed and ready.")
return file_upload
elif file_info.state == "FAILED":
raise Exception("Video processing failed by Gemini.")
time.sleep(2)
def get_ffmpeg_filter(self, video_file_obj, duration, fps=30, width=None, height=None, transcript=None):
"""Asks Gemini for a raw FFmpeg filter string."""
if width is None or height is None:
# Keep prompt usable even if caller didn't pass dimensions.
width, height = 1080, 1920
transcript_text = json.dumps(transcript) if transcript else "Not available."
prompt = f"""
You are an expert FFmpeg video editor. Your task is to generate a complex video filter string to make a short video viral, BUT ONLY apply effects where they make sense contextually.
Video Duration: {duration} seconds.
Video FPS: {fps}
Video Resolution (MUST KEEP EXACT): {width}x{height}
TRANSCRIPT (Context of what is being said):
{transcript_text}
Goal: Enhance the video with dynamic zooms, cuts (simulated with punch-ins), and visual effects to increase retention, but DO NOT overdo it. Random effects are bad. Contextual effects are good.
Instructions:
1. ANALYZE THE VIDEO AND TRANSCRIPT: Understand the mood, the pacing, and the key moments.
2. APPLY EFFECTS ONLY WHEN RELEVANT:
- Use "punch-in" zooms (zoompan) to emphasize key points, jokes, or dramatic moments in the speech.
- slow zooms to face when the speaker is speaking
- Use visual effects (contrast, saturation, sharpness) to highlight mood changes or specific segments.
- If nothing significant is happening, keep it simple. It is BETTER to have no effect than a random/distracting one.
- Avoid constant motion if the speaker is delivering a serious or steady message.
3. Create a single valid FFmpeg filter complex string (for the -vf flag).
4. Use filters like `zoompan`, `eq` (contrast), `hue` (saturation/bw), `unsharp`.
5. Pacing: Align effects with the rhythm of the speech (from transcript) or visual action.
6. CRITICAL SYNTAX RULES:
- DO NOT use comparison operators like `<`, `>`, `<=`, `>=` anywhere. They frequently break FFmpeg expression parsing.
- USE FFmpeg expression FUNCTIONS instead:
- `between(x,a,b)`
- `lt(x,y)`, `lte(x,y)`, `gt(x,y)`, `gte(x,y)`
- `if(cond,then,else)`
- Always wrap expression values in single quotes: `z='...'`, `x='...'`, `y='...'`, `enable='...'`.
- FOR `zoompan`:
- Prefer `on` (output frame index) to avoid time-variable quirks.
- Convert seconds to frames using FPS={fps}: `frame = seconds * {fps}`.
- Use `between(on, startFrame, endFrame)` for segmenting and pacing.
- Example:
`zoompan=z='1.1*between(on,0,75)+1.3*between(on,76,150)+1.15*between(on,151,300)+1.2*gte(on,301)'`
- ALWAYS set zoompan output size to EXACT `{width}x{height}` using `s={width}x{height}`.
- ALWAYS set `fps={fps}` and `d=1`.
- DO NOT use `scale`, `crop`, `pad` unless you keep EXACT `{width}x{height}` (no aspect ratio changes).
- FOR `eq`, `hue`, `curves`, `unsharp` (Visual Effects):
- **DO NOT** use dynamic expressions for parameter values (e.g. `contrast='1+0.5*t'`).
- **USE TIMELINE EDITING** via the `enable` option.
- Create MULTIPLE filter instances for different time ranges.
- **SYNTAX FOR ENABLE:**
- **USE** `between(t,start,end)` for clarity and robustness.
- **USE** single quotes around the enable expression.
- **Example:** `eq=contrast=1.2:enable='between(t,0,3)'`
- **Example:** `hue=s=0:enable='between(t,10,12)'`
- This is much safer and robust than boolean multiplication.
Constraints:
- Output JSON with a single key: "filter_string".
- The value must be the RAW filter string ready to be passed to `-vf`.
- OUTPUT MUST KEEP EXACT RESOLUTION AND ASPECT RATIO: {width}x{height}.
- Do NOT output 1280x720 or 1080x1080 unless the input is exactly that.
- IMPORTANT: Do NOT include the `-vf` flag itself, just the filter content.
- IMPORTANT: Ensure syntax is correct for FFmpeg.
Output JSON:
{{
"filter_string": "..."
}}
"""
print("🤖 Asking Gemini for FFmpeg filter...")
response = self.client.models.generate_content(
model=self.model_name,
contents=[video_file_obj, prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json"
)
)
print(f"🔍 DEBUG: Gemini Raw Response:\n{response.text}")
try:
# Clean response text (remove potential markdown blocks)
text = response.text
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
# Additional cleanup for potential trailing characters outside JSON
# Find the first '{' and last '}'
start_idx = text.find('{')
end_idx = text.rfind('}')
if start_idx != -1 and end_idx != -1:
text = text[start_idx:end_idx+1]
print(f"🔍 DEBUG: Cleaned JSON Text:\n{text}")
return json.loads(text)
except json.JSONDecodeError:
print(f"❌ Failed to parse JSON: {response.text}")
return None
def get_effects_config(self, video_file_obj, duration, fps=30, width=None, height=None, transcript=None):
"""Asks Gemini for a structured EffectsConfig JSON for Remotion rendering."""
if width is None or height is None:
width, height = 1080, 1920
transcript_text = json.dumps(transcript) if transcript else "Not available."
prompt = f"""
You are an expert video editor analyzing a video and its transcript to generate dynamic visual effects for a Remotion-based renderer.
Video Duration: {duration} seconds.
Video FPS: {fps}
Video Resolution: {width}x{height}
TRANSCRIPT (Context of what is being said):
{transcript_text}
Your task is to produce a structured JSON describing time-based effect segments that cover the FULL video duration.
Each segment has these fields:
- "startSec" (number): Start time in seconds.
- "endSec" (number): End time in seconds.
- "zoom" (number): Zoom level. 1.0 = no zoom, max 1.5. Use subtle values like 1.05-1.2 for most cases.
- "zoomCenterX" (number): Horizontal focus point for zoom, 0.0 (left) to 1.0 (right). 0.5 = center.
- "zoomCenterY" (number): Vertical focus point for zoom, 0.0 (top) to 1.0 (bottom). 0.5 = center.
- "brightness" (number): Brightness multiplier. 1.0 = normal. Range 0.8-1.2.
- "contrast" (number): Contrast multiplier. 1.0 = normal. Range 0.8-1.3.
- "saturate" (number): Saturation multiplier. 1.0 = normal. Range 0.8-1.3.
Instructions:
1. ANALYZE the video content and transcript to understand mood, pacing, and key moments.
2. Apply CONTEXTUAL effects aligned with speech and action:
- Use slow, subtle zooms toward the speaker's face during speaking moments.
- Emphasize key moments, punchlines, or dramatic beats with slightly stronger zoom or contrast.
- Keep transitions smooth — avoid jarring jumps between segments.
- If nothing significant is happening, keep values at defaults (zoom 1.0, all multipliers 1.0).
3. Segments MUST cover the entire video duration from 0 to {duration} seconds with no gaps.
4. Prefer fewer, longer segments with gradual changes over many rapid short segments.
5. Output ONLY valid JSON, no explanations.
Output format:
{{
"segments": [
{{
"startSec": 0,
"endSec": 3.5,
"zoom": 1.0,
"zoomCenterX": 0.5,
"zoomCenterY": 0.5,
"brightness": 1.0,
"contrast": 1.0,
"saturate": 1.0
}}
]
}}
"""
print("🤖 Asking Gemini for Remotion effects config...")
response = self.client.models.generate_content(
model=self.model_name,
contents=[video_file_obj, prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json"
)
)
print(f"🔍 DEBUG: Gemini Raw Response:\n{response.text}")
try:
# Clean response text (remove potential markdown blocks)
text = response.text
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
# Find the first '{' and last '}'
start_idx = text.find('{')
end_idx = text.rfind('}')
if start_idx != -1 and end_idx != -1:
text = text[start_idx:end_idx+1]
print(f"🔍 DEBUG: Cleaned JSON Text:\n{text}")
return json.loads(text)
except json.JSONDecodeError:
print(f"❌ Failed to parse effects config JSON: {response.text}")
return None
@staticmethod
def _split_filter_chain(filter_string: str) -> list[str]:
"""Split a -vf filter chain on commas, respecting single-quoted substrings."""
parts: list[str] = []
start = 0
in_quote = False
for i, ch in enumerate(filter_string):
if ch == "'":
in_quote = not in_quote
elif ch == "," and not in_quote:
parts.append(filter_string[start:i])
start = i + 1
parts.append(filter_string[start:])
return parts
@classmethod
def _enforce_zoompan_output_size(cls, filter_string: str, width: int, height: int) -> str:
"""Force any zoompan filter to output the same geometry as the input clip."""
parts = cls._split_filter_chain(filter_string)
out_parts: list[str] = []
for part in parts:
if "zoompan=" in part:
# Force s=WxH inside zoompan options (digitsxdigits only).
if re.search(r":s=\d+x\d+", part):
part = re.sub(r":s=\d+x\d+", f":s={width}x{height}", part)
else:
part = f"{part}:s={width}x{height}"
out_parts.append(part)
return ",".join(out_parts)
@staticmethod
def _sanitize_filter_string(filter_string: str) -> str:
"""
Best-effort sanitizer for Gemini-generated FFmpeg expressions.
Converts comparison operators (t<3, on>=75, etc.) into FFmpeg expr functions (lt(), gte(), ...),
which are far more reliably parsed across FFmpeg builds.
"""
s = filter_string
# Order matters: handle >= / <= before > / <
patterns: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"(?<![A-Za-z0-9_])([A-Za-z_]\w*)\s*>=\s*(-?\d+(?:\.\d+)?)"), r"gte(\1,\2)"),
(re.compile(r"(?<![A-Za-z0-9_])([A-Za-z_]\w*)\s*<=\s*(-?\d+(?:\.\d+)?)"), r"lte(\1,\2)"),
(re.compile(r"(?<![A-Za-z0-9_])([A-Za-z_]\w*)\s*>\s*(-?\d+(?:\.\d+)?)"), r"gt(\1,\2)"),
(re.compile(r"(?<![A-Za-z0-9_])([A-Za-z_]\w*)\s*<\s*(-?\d+(?:\.\d+)?)"), r"lt(\1,\2)"),
]
for pat, repl in patterns:
s = pat.sub(repl, s)
return s
def apply_edits(self, input_path, output_path, filter_data):
"""Executes FFmpeg with the generated filter."""
if not filter_data or "filter_string" not in filter_data:
print("⚠️ No filter string found. Copying original.")
subprocess.run(['ffmpeg', '-y', '-i', input_path, '-c', 'copy', output_path])
return
filter_string = filter_data["filter_string"]
# Get input dimensions so we can enforce geometry (avoid broken aspect ratios).
try:
probe_cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', input_path]
res_out = subprocess.check_output(probe_cmd, env={**os.environ, "LANG": "C.UTF-8"}).decode().strip()
w, h = map(int, res_out.split('x'))
except Exception as e:
print(f"⚠️ Could not probe resolution: {e}")
w, h = None, None
# Sanitize common expression pitfalls (e.g., t<3 / on>=75) before executing FFmpeg.
sanitized = self._sanitize_filter_string(filter_string)
if sanitized != filter_string:
print("🧼 Sanitized AI Filter (converted comparisons to lt/lte/gt/gte functions)")
print(f"🧼 Before: {filter_string}")
print(f"🧼 After: {sanitized}")
filter_string = sanitized
# Enforce zoompan output size to preserve aspect ratio / resolution.
if w and h:
enforced = self._enforce_zoompan_output_size(filter_string, w, h)
if enforced != filter_string:
print(f"📐 Enforced zoompan output size to {w}x{h}")
filter_string = enforced
# Ensure square pixels (avoid weird display stretching in some players).
if "setsar=" not in filter_string:
filter_string = f"{filter_string},setsar=1"
print(f"🎬 Executing AI Filter: {filter_string}")
cmd = [
'ffmpeg', '-y',
'-i', input_path,
'-vf', filter_string,
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
'-c:a', 'copy',
output_path
]
# Use explicit environment with UTF-8 to avoid ascii errors in subprocess
env = os.environ.copy()
# On some minimal docker images, we need to ensure we use a UTF-8 locale
# Try C.UTF-8 first, fallback to en_US.UTF-8 if available, but C.UTF-8 is usually safer for minimal
env["LANG"] = "C.UTF-8"
env["LC_ALL"] = "C.UTF-8"
try:
# We must encode arguments if filesystem is ascii but we have unicode chars
# But subprocess in Python 3 handles unicode args by encoding them with os.fsencode().
# If sys.getfilesystemencoding() is ascii, this fails.
# We can't change fs encoding at runtime easily.
# Workaround: pass bytes directly? subprocess allows bytes in args.
# Convert command elements to bytes assuming utf-8 if they are strings
cmd_bytes = []
for arg in cmd:
if isinstance(arg, str):
cmd_bytes.append(arg.encode('utf-8'))
else:
cmd_bytes.append(arg)
subprocess.run(cmd_bytes, check=True, env=env)
except subprocess.CalledProcessError as e:
print(f"❌ FFmpeg failed: {e}")
raise e
if __name__ == "__main__":
pass
Binary file not shown.
Binary file not shown.
Binary file not shown.
+241
View File
@@ -0,0 +1,241 @@
import os
import textwrap
import subprocess
import urllib.request
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONT_URL = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf"
FONT_DIR = "fonts"
FONT_PATH = os.path.join(FONT_DIR, "NotoSerif-Bold.ttf")
def download_font_if_needed():
"""Downloads a serif font for the hook text if not present."""
if not os.path.exists(FONT_DIR):
os.makedirs(FONT_DIR)
if not os.path.exists(FONT_PATH):
print(f"⬇️ Downloading font from {FONT_URL}...")
try:
# Add user agent to avoid 403s slightly
req = urllib.request.Request(
FONT_URL,
headers={'User-Agent': 'Mozilla/5.0'}
)
with urllib.request.urlopen(req) as response, open(FONT_PATH, 'wb') as out_file:
out_file.write(response.read())
print("✅ Font downloaded.")
except Exception as e:
print(f"❌ Failed to download font: {e}")
def create_hook_image(text, target_width, output_image_path="hook_overlay.png", font_scale=1.0):
"""
Generates a white box with black serif text using pixel-based wrapping.
target_width: The max width the box should occupy (e.g. 85% of video)
"""
download_font_if_needed()
# Configuration
padding_x = 30 # Balanced padding
padding_y = 25
line_spacing = 20 # Increased spacing
cornerradius = 20
shadow_offset = (5, 5)
shadow_blur = 10
# Font Size Calculation (approx 5% of width - tuned to match Noto Serif Bold metrics in browser)
base_font_size = int(target_width * 0.05)
font_size = int(base_font_size * font_scale)
try:
font = ImageFont.truetype(FONT_PATH, font_size)
except Exception as e:
print(f"⚠️ Warning: Could not load font {FONT_PATH}, using default. Error: {e}")
font = ImageFont.load_default()
# Wrap text logic (Pixel-based)
dummy_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(dummy_img)
max_text_width = target_width - (2 * padding_x)
# Handle manual newlines first
paragraphs = text.split('\n')
lines = []
for p in paragraphs:
if not p.strip():
lines.append("")
continue
words = p.split()
current_line = []
for word in words:
# Test if adding word fits
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_text_width:
current_line.append(word)
else:
# Line full, push current_line and start new
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
else:
# Single word too long? Force it.
lines.append(word)
current_line = []
if current_line:
lines.append(' '.join(current_line))
# Recalculate true width/height
max_line_width = 0
text_heights = []
for line in lines:
if not line:
text_heights.append(font_size) # Use font size for empty line height
continue
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
max_line_width = max(max_line_width, w)
text_heights.append(h)
# Box dimensions
# We want the box to fit the text exactly + padding
# Ensure min width for aesthetic reasons if text is short (at least 30% of target)
box_width = max(max_line_width + (2 * padding_x), int(target_width * 0.3))
# Total Text Height: sum(heights) + spacing * (n-1)
if not text_heights:
total_text_height = font_size
else:
total_text_height = sum(text_heights) + (len(text_heights) - 1) * line_spacing
box_height = total_text_height + (2 * padding_y)
# Create Final Image with Rounded Corners and Shadow
# 1. Canvas for Shadow (larger than box)
canvas_w = box_width + 40
canvas_h = box_height + 40
img = Image.new('RGBA', (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 2. Draw Shadow
shadow_box = [
(20 + shadow_offset[0], 20 + shadow_offset[1]),
(20 + box_width + shadow_offset[0], 20 + box_height + shadow_offset[1])
]
draw.rounded_rectangle(shadow_box, radius=cornerradius, fill=(0, 0, 0, 100))
# 3. Blur Shadow
img = img.filter(ImageFilter.GaussianBlur(5))
# 4. Draw White Box (sharper, on top of blurred shadow)
draw_final = ImageDraw.Draw(img)
main_box = [
(20, 20),
(20 + box_width, 20 + box_height)
]
# Semi-transparent white (240/255 alpha ~ 94% opacity)
draw_final.rounded_rectangle(main_box, radius=cornerradius, fill=(255, 255, 255, 240))
# 5. Draw Text
current_y = 20 + padding_y - 2 # Minor visual adjustment
for i, line in enumerate(lines):
if not line:
current_y += font_size + line_spacing
continue
bbox = draw_final.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
line_h = text_heights[i] if i < len(text_heights) else bbox[3] - bbox[1]
# Center X
x = 20 + (box_width - line_w) // 2
# Draw Black Text
draw_final.text((x, current_y), line, font=font, fill="black")
current_y += line_h + line_spacing
img.save(output_image_path)
return output_image_path, canvas_w, canvas_h
def add_hook_to_video(video_path, text, output_path, position="top", font_scale=1.0):
"""
Overlays text hook onto video.
position: 'top', 'center', 'bottom'
font_scale: float multiplier (1.0 = default)
"""
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video {video_path} not found")
# 1. Probe video width to scale text properly
try:
cmd = ['ffprobe', '-v', 'error', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', video_path]
res = subprocess.check_output(cmd).decode().strip()
# Takes first stream if multiple
dims = res.split('\n')[0].split('x')
video_width = int(dims[0])
video_height = int(dims[1])
except Exception as e:
print(f"⚠️ FFprobe failed: {e}. Assuming 1080x1920")
video_width = 1080
video_height = 1920
# 2. Generate Image
# Box check: Don't let it be wider than 90% of screen
target_box_width = int(video_width * 0.9)
hook_filename = f"temp_hook_{os.path.basename(video_path)}.png"
# Ensure unique or temp location if needed, but relative is fine for this app structure
try:
img_path, box_w, box_h = create_hook_image(text, target_box_width, hook_filename, font_scale=font_scale)
# 3. Calculate Overlay Position
overlay_x = (video_width - box_w) // 2
if position == "center":
overlay_y = (video_height - box_h) // 2
elif position == "bottom":
# Bottom 20% mark (approx)
overlay_y = int(video_height * 0.70)
else:
# Top 20% mark
overlay_y = int(video_height * 0.20)
# 4. FFmpeg Command
print(f"🎬 Overlaying hook: '{text}' at {overlay_x},{overlay_y}")
ffmpeg_cmd = [
'ffmpeg', '-y',
'-i', video_path,
'-i', img_path,
'-filter_complex', f"[0:v][1:v]overlay={overlay_x}:{overlay_y}",
'-c:a', 'copy',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
output_path
]
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"✅ Hook added to {output_path}")
return True
except subprocess.CalledProcessError as e:
print(f"❌ FFmpeg Error: {e.stderr.decode() if e.stderr else 'Unknown'}")
raise e
except Exception as e:
print(f"❌ Hook Gen Error: {e}")
raise e
finally:
# Cleanup temp image
if os.path.exists(hook_filename):
os.remove(hook_filename)
+1166
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+27
View File
@@ -0,0 +1,27 @@
{
"name": "openshorts-remotion",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"studio": "remotion studio",
"render": "remotion render",
"build": "tsc --noEmit"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/google-fonts": "^4.0.0",
"@remotion/layout-utils": "^4.0.0",
"@remotion/media": "^4.0.447",
"@remotion/player": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"typescript": "^5.4.0"
}
}
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
import React from "react";
import { Composition } from "remotion";
import { ShortVideo } from "./compositions/ShortVideo";
import type { ShortVideoProps } from "./lib/types";
import { shortVideoPropsSchema } from "./lib/types";
const DEFAULT_PROPS: ShortVideoProps = {
videoUrl: "",
durationInFrames: 900, // 30s at 30fps
fps: 30,
width: 1080,
height: 1920,
subtitles: {
captions: [
{ text: "This", startMs: 0, endMs: 400 },
{ text: "is", startMs: 400, endMs: 600 },
{ text: "a", startMs: 600, endMs: 750 },
{ text: "demo", startMs: 750, endMs: 1200 },
{ text: "of", startMs: 1200, endMs: 1400 },
{ text: "animated", startMs: 1400, endMs: 2000 },
{ text: "subtitles", startMs: 2000, endMs: 2800 },
{ text: "in", startMs: 2800, endMs: 3000 },
{ text: "Remotion", startMs: 3000, endMs: 3800 },
{ text: "with", startMs: 4000, endMs: 4300 },
{ text: "word", startMs: 4300, endMs: 4700 },
{ text: "level", startMs: 4700, endMs: 5100 },
{ text: "highlighting", startMs: 5100, endMs: 6000 },
],
position: "bottom",
style: {
fontFamily: "Arial",
fontSize: 52,
fontColor: "#FFFFFF",
highlightColor: "#FFDD00",
borderColor: "#000000",
borderWidth: 3,
bgColor: "#000000",
bgOpacity: 0,
animation: "pop",
},
},
hook: {
text: "POV: You just discovered OpenShorts",
position: "top",
size: "M",
entranceAnimation: "spring",
displayDurationSec: 5,
},
effects: {
segments: [
{
startSec: 2,
endSec: 5,
zoom: 1.2,
zoomCenterX: 0.5,
zoomCenterY: 0.35,
brightness: 1.05,
contrast: 1.1,
saturate: 1.15,
},
{
startSec: 8,
endSec: 12,
zoom: 1.15,
zoomCenterX: 0.5,
zoomCenterY: 0.4,
brightness: 1,
contrast: 1,
saturate: 1,
},
],
},
};
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="ShortVideo"
schema={shortVideoPropsSchema}
component={ShortVideo}
durationInFrames={DEFAULT_PROPS.durationInFrames}
fps={DEFAULT_PROPS.fps}
width={DEFAULT_PROPS.width}
height={DEFAULT_PROPS.height}
defaultProps={DEFAULT_PROPS}
/>
</>
);
};
@@ -0,0 +1,144 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import type { HookConfig } from "../lib/types";
import { notoSerifFontFace, NOTO_SERIF_FONT_FAMILY } from "../lib/fonts";
interface HookOverlayProps {
config: HookConfig;
}
const SIZE_SCALE: Record<string, number> = {
S: 0.8,
M: 1.0,
L: 1.3,
};
const POSITION_STYLE: Record<string, React.CSSProperties> = {
top: { top: "18%", bottom: "auto" },
center: { top: "50%", bottom: "auto", transform: "translateY(-50%)" },
bottom: { top: "68%", bottom: "auto" },
};
export const HookOverlay: React.FC<HookOverlayProps> = ({ config }) => {
const { fps } = useVideoConfig();
const displayFrames = Math.round(config.displayDurationSec * fps);
return (
<AbsoluteFill>
<style>{notoSerifFontFace}</style>
<Sequence from={0} durationInFrames={displayFrames} layout="none">
<HookBox config={config} displayFrames={displayFrames} />
</Sequence>
</AbsoluteFill>
);
};
interface HookBoxProps {
config: HookConfig;
displayFrames: number;
}
const HookBox: React.FC<HookBoxProps> = ({ config, displayFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = SIZE_SCALE[config.size] ?? 1.0;
// Entrance animation
let animOpacity = 1;
let animScale = 1;
let animTranslateY = 0;
switch (config.entranceAnimation) {
case "spring": {
const prog = spring({
frame,
fps,
config: { mass: 0.8, stiffness: 200, damping: 15 },
durationInFrames: 20,
});
animScale = interpolate(prog, [0, 1], [0.7, 1]);
animOpacity = interpolate(prog, [0, 1], [0, 1]);
break;
}
case "fade": {
animOpacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
break;
}
case "slide-up": {
const prog = spring({
frame,
fps,
config: { mass: 1, stiffness: 150, damping: 18 },
durationInFrames: 20,
});
animTranslateY = interpolate(prog, [0, 1], [60, 0]);
animOpacity = interpolate(prog, [0, 1], [0, 1]);
break;
}
default:
break;
}
// Exit fade (last 15 frames)
const fadeOutStart = displayFrames - 15;
if (frame > fadeOutStart) {
animOpacity *= interpolate(frame, [fadeOutStart, displayFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
}
const positionStyle = POSITION_STYLE[config.position] ?? POSITION_STYLE.top;
// Base font size: 5% of 1080 width (matches hooks.py logic)
const baseFontSize = 1080 * 0.05;
const fontSize = Math.round(baseFontSize * scale);
return (
<div
style={{
position: "absolute",
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
...positionStyle,
}}
>
<div
style={{
opacity: animOpacity,
transform: `scale(${animScale}) translateY(${animTranslateY}px)`,
maxWidth: "90%",
backgroundColor: "rgba(255, 255, 255, 0.94)",
borderRadius: 20,
padding: `${25 * scale}px ${30 * scale}px`,
boxShadow: "5px 5px 15px rgba(0, 0, 0, 0.25)",
textAlign: "center",
}}
>
<span
style={{
fontFamily: `'${NOTO_SERIF_FONT_FAMILY}', 'Noto Serif', Georgia, serif`,
fontSize,
fontWeight: 700,
color: "#000000",
lineHeight: 1.4,
wordBreak: "break-word",
}}
>
{config.text}
</span>
</div>
</div>
);
};
@@ -0,0 +1,33 @@
import React from "react";
import { AbsoluteFill } from "remotion";
import { Video } from "@remotion/media";
import type { ShortVideoProps } from "../lib/types";
import { Subtitles } from "./Subtitles";
import { HookOverlay } from "./HookOverlay";
import { VideoEffects } from "./VideoEffects";
/**
* Main composition that layers all post-processing on top of the base video.
* Uses @remotion/media Video for browser-side rendering compatibility.
*/
export const ShortVideo: React.FC<Record<string, unknown>> = (rawProps) => {
const { videoUrl, subtitles, hook, effects } =
rawProps as unknown as ShortVideoProps;
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* Layer 1: Base video with optional zoom/color effects */}
<VideoEffects config={effects}>
<Video
src={videoUrl}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</VideoEffects>
{/* Layer 2: Animated subtitles */}
{subtitles && <Subtitles config={subtitles} />}
{/* Layer 3: Hook text overlay */}
{hook && <HookOverlay config={hook} />}
</AbsoluteFill>
);
};
@@ -0,0 +1,227 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import type { SubtitleConfig } from "../lib/types";
import { groupCaptionsIntoBlocks, getActiveWordIndex } from "../lib/captions";
import { getFontStack } from "../lib/fonts";
interface SubtitlesProps {
config: SubtitleConfig;
}
const POSITION_MAP: Record<string, React.CSSProperties> = {
top: { top: "12%", bottom: "auto" },
middle: { top: "45%", bottom: "auto" },
bottom: { bottom: "10%", top: "auto" },
};
export const Subtitles: React.FC<SubtitlesProps> = ({ config }) => {
const { fps } = useVideoConfig();
const blocks = groupCaptionsIntoBlocks(config.captions);
return (
<AbsoluteFill>
{blocks.map((block, i) => {
const startFrame = Math.round((block.startMs / 1000) * fps);
const durationFrames = Math.max(
1,
Math.round(((block.endMs - block.startMs) / 1000) * fps)
);
return (
<Sequence
key={i}
from={startFrame}
durationInFrames={durationFrames}
layout="none"
>
<SubtitleBlock
block={block}
config={config}
blockStartMs={block.startMs}
/>
</Sequence>
);
})}
</AbsoluteFill>
);
};
interface SubtitleBlockProps {
block: ReturnType<typeof groupCaptionsIntoBlocks>[number];
config: SubtitleConfig;
blockStartMs: number;
}
const SubtitleBlock: React.FC<SubtitleBlockProps> = ({
block,
config,
blockStartMs,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { style, position } = config;
// Current time relative to composition start (sequence-relative frame)
const currentTimeMs = blockStartMs + (frame / fps) * 1000;
const activeIndex = getActiveWordIndex(block.words, currentTimeMs);
const positionStyle = POSITION_MAP[position] ?? POSITION_MAP.bottom;
const fontStack = getFontStack(style.fontFamily);
// Background box style
const hasBg = style.bgOpacity > 0;
const bgStyle: React.CSSProperties = hasBg
? {
backgroundColor: `${style.bgColor}${Math.round(style.bgOpacity * 255)
.toString(16)
.padStart(2, "0")}`,
borderRadius: 8,
padding: "8px 16px",
}
: {};
return (
<div
style={{
position: "absolute",
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
...positionStyle,
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "6px 8px",
maxWidth: "85%",
...bgStyle,
}}
>
{block.words.map((word, i) => (
<WordSpan
key={i}
word={word.text}
isActive={i === activeIndex}
style={style}
fontStack={fontStack}
animation={style.animation}
frame={frame}
fps={fps}
wordStartMs={word.startMs}
blockStartMs={blockStartMs}
/>
))}
</div>
</div>
);
};
interface WordSpanProps {
word: string;
isActive: boolean;
style: SubtitleConfig["style"];
fontStack: string;
animation: SubtitleConfig["style"]["animation"];
frame: number;
fps: number;
wordStartMs: number;
blockStartMs: number;
}
const WordSpan: React.FC<WordSpanProps> = ({
word,
isActive,
style,
fontStack,
animation,
frame,
fps,
wordStartMs,
blockStartMs,
}) => {
const wordStartFrame = Math.round(
((wordStartMs - blockStartMs) / 1000) * fps
);
let transform = "";
let color = style.fontColor;
let extraStyle: React.CSSProperties = {};
if (isActive) {
color = style.highlightColor;
switch (animation) {
case "pop": {
const scale = spring({
frame: frame - wordStartFrame,
fps,
config: { mass: 0.5, stiffness: 300, damping: 12 },
durationInFrames: 10,
});
const scaleValue = interpolate(scale, [0, 1], [1, 1.25]);
transform = `scale(${scaleValue})`;
break;
}
case "karaoke": {
extraStyle = {
backgroundColor: style.highlightColor,
color: style.bgColor || "#000000",
borderRadius: 4,
padding: "2px 6px",
};
break;
}
case "word-highlight": {
extraStyle = {
textShadow: `0 0 12px ${style.highlightColor}, 0 0 24px ${style.highlightColor}40`,
};
break;
}
default:
break;
}
}
// Text stroke via textShadow (CSS paint-order not reliable in Remotion)
const strokeShadow =
style.borderWidth > 0
? [
`${style.borderWidth}px 0 0 ${style.borderColor}`,
`-${style.borderWidth}px 0 0 ${style.borderColor}`,
`0 ${style.borderWidth}px 0 ${style.borderColor}`,
`0 -${style.borderWidth}px 0 ${style.borderColor}`,
].join(", ")
: "none";
return (
<span
style={{
fontFamily: fontStack,
fontSize: style.fontSize,
fontWeight: 700,
color: animation === "karaoke" && isActive ? undefined : color,
textShadow:
animation !== "karaoke"
? [strokeShadow, extraStyle.textShadow].filter(Boolean).join(", ")
: strokeShadow,
transform,
display: "inline-block",
transition: "none",
...extraStyle,
}}
>
{word}
</span>
);
};
@@ -0,0 +1,209 @@
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import type { EffectsConfig, EffectSegment } from "../lib/types";
interface VideoEffectsProps {
config: EffectsConfig | null;
children: React.ReactNode;
}
/**
* Wraps children (typically <OffthreadVideo>) with dynamic CSS transforms and filters.
* Interpolates smoothly between effect segments.
*/
export const VideoEffects: React.FC<VideoEffectsProps> = ({
config,
children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (!config || config.segments.length === 0) {
return <>{children}</>;
}
const currentTimeSec = frame / fps;
const { zoom, centerX, centerY, brightness, contrast, saturate } =
getInterpolatedValues(config.segments, currentTimeSec, frame, fps);
const filterParts: string[] = [];
if (brightness !== 1) filterParts.push(`brightness(${brightness})`);
if (contrast !== 1) filterParts.push(`contrast(${contrast})`);
if (saturate !== 1) filterParts.push(`saturate(${saturate})`);
const filterStr = filterParts.length > 0 ? filterParts.join(" ") : "none";
return (
<div
style={{
width: "100%",
height: "100%",
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
width: "100%",
height: "100%",
transform: `scale(${zoom})`,
transformOrigin: `${centerX * 100}% ${centerY * 100}%`,
filter: filterStr,
}}
>
{children}
</div>
</div>
);
};
interface InterpolatedValues {
zoom: number;
centerX: number;
centerY: number;
brightness: number;
contrast: number;
saturate: number;
}
function getInterpolatedValues(
segments: EffectSegment[],
timeSec: number,
frame: number,
fps: number
): InterpolatedValues {
// Default values (no effect)
const defaults: InterpolatedValues = {
zoom: 1,
centerX: 0.5,
centerY: 0.5,
brightness: 1,
contrast: 1,
saturate: 1,
};
// Find active segment
const active = segments.find(
(s) => timeSec >= s.startSec && timeSec < s.endSec
);
if (!active) {
// Check if we're transitioning between segments (smooth fade)
const prev = segments.filter((s) => s.endSec <= timeSec).pop();
const next = segments.find((s) => s.startSec > timeSec);
if (prev && next) {
const gap = next.startSec - prev.endSec;
if (gap < 1.0) {
// Short gap: interpolate between prev and next
const progress = (timeSec - prev.endSec) / gap;
return lerpSegments(prev, next, progress);
}
}
// Transition out from previous segment
if (prev) {
const fadeOutDuration = 0.3; // seconds
const elapsed = timeSec - prev.endSec;
if (elapsed < fadeOutDuration) {
const progress = elapsed / fadeOutDuration;
return lerpToDefaults(prev, progress, defaults);
}
}
// Transition into next segment
if (next) {
const fadeInDuration = 0.3;
const remaining = next.startSec - timeSec;
if (remaining < fadeInDuration) {
const progress = 1 - remaining / fadeInDuration;
return lerpFromDefaults(next, progress, defaults);
}
}
return defaults;
}
// Inside active segment: smooth entrance/exit at edges
const segDuration = active.endSec - active.startSec;
const transitionSec = Math.min(0.3, segDuration * 0.15);
const startFrame = Math.round(active.startSec * fps);
const endFrame = Math.round(active.endSec * fps);
const transitionFrames = Math.round(transitionSec * fps);
// Entrance ease
const entranceFactor = interpolate(
frame,
[startFrame, startFrame + transitionFrames],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Exit ease
const exitFactor = interpolate(
frame,
[endFrame - transitionFrames, endFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const factor = Math.min(entranceFactor, exitFactor);
return {
zoom: lerp(1, active.zoom, factor),
centerX: lerp(0.5, active.zoomCenterX, factor),
centerY: lerp(0.5, active.zoomCenterY, factor),
brightness: lerp(1, active.brightness, factor),
contrast: lerp(1, active.contrast, factor),
saturate: lerp(1, active.saturate, factor),
};
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function lerpSegments(
a: EffectSegment,
b: EffectSegment,
t: number
): InterpolatedValues {
return {
zoom: lerp(a.zoom, b.zoom, t),
centerX: lerp(a.zoomCenterX, b.zoomCenterX, t),
centerY: lerp(a.zoomCenterY, b.zoomCenterY, t),
brightness: lerp(a.brightness, b.brightness, t),
contrast: lerp(a.contrast, b.contrast, t),
saturate: lerp(a.saturate, b.saturate, t),
};
}
function lerpToDefaults(
seg: EffectSegment,
t: number,
defaults: InterpolatedValues
): InterpolatedValues {
return {
zoom: lerp(seg.zoom, defaults.zoom, t),
centerX: lerp(seg.zoomCenterX, defaults.centerX, t),
centerY: lerp(seg.zoomCenterY, defaults.centerY, t),
brightness: lerp(seg.brightness, defaults.brightness, t),
contrast: lerp(seg.contrast, defaults.contrast, t),
saturate: lerp(seg.saturate, defaults.saturate, t),
};
}
function lerpFromDefaults(
seg: EffectSegment,
t: number,
defaults: InterpolatedValues
): InterpolatedValues {
return {
zoom: lerp(defaults.zoom, seg.zoom, t),
centerX: lerp(defaults.centerX, seg.zoomCenterX, t),
centerY: lerp(defaults.centerY, seg.zoomCenterY, t),
brightness: lerp(defaults.brightness, seg.brightness, t),
contrast: lerp(defaults.contrast, seg.contrast, t),
saturate: lerp(defaults.saturate, seg.saturate, t),
};
}
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+83
View File
@@ -0,0 +1,83 @@
import type { CaptionWord } from "./types";
export interface CaptionBlock {
words: CaptionWord[];
startMs: number;
endMs: number;
text: string;
}
/**
* Groups word-level captions into display blocks.
* Same logic as OpenShorts' generate_srt: max chars per block, max duration per block.
*/
export function groupCaptionsIntoBlocks(
captions: CaptionWord[],
maxChars = 20,
maxDurationMs = 2000
): CaptionBlock[] {
const blocks: CaptionBlock[] = [];
let currentWords: CaptionWord[] = [];
let blockStartMs = 0;
for (const word of captions) {
if (currentWords.length === 0) {
currentWords.push(word);
blockStartMs = word.startMs;
continue;
}
const currentTextLen = currentWords.reduce(
(sum, w) => sum + w.text.length + 1,
0
);
const duration = word.endMs - blockStartMs;
if (
currentTextLen + word.text.length > maxChars ||
duration > maxDurationMs
) {
// Finalize current block
const lastWord = currentWords[currentWords.length - 1];
blocks.push({
words: [...currentWords],
startMs: blockStartMs,
endMs: lastWord.endMs,
text: currentWords.map((w) => w.text).join(" "),
});
currentWords = [word];
blockStartMs = word.startMs;
} else {
currentWords.push(word);
}
}
// Final block
if (currentWords.length > 0) {
const lastWord = currentWords[currentWords.length - 1];
blocks.push({
words: [...currentWords],
startMs: blockStartMs,
endMs: lastWord.endMs,
text: currentWords.map((w) => w.text).join(" "),
});
}
return blocks;
}
/**
* Find the active word at a given time in milliseconds.
*/
export function getActiveWordIndex(
words: CaptionWord[],
timeMs: number
): number {
for (let i = 0; i < words.length; i++) {
if (timeMs >= words[i].startMs && timeMs < words[i].endMs) {
return i;
}
}
return -1;
}
+33
View File
@@ -0,0 +1,33 @@
import { staticFile } from "remotion";
/**
* CSS @font-face declaration for NotoSerif-Bold (bundled locally).
* Use in components via: <style>{notoSerifFontFace}</style>
*/
export const NOTO_SERIF_FONT_FAMILY = "NotoSerif-Bold";
export const notoSerifFontFace = `
@font-face {
font-family: '${NOTO_SERIF_FONT_FAMILY}';
src: url('${staticFile("fonts/NotoSerif-Bold.ttf")}') format('truetype');
font-weight: 700;
font-style: normal;
}
`;
/**
* Map of subtitle font families to their CSS-safe names.
* These match the options available in SubtitleModal.jsx.
*/
export const SUBTITLE_FONTS: Record<string, string> = {
Verdana: "Verdana, Geneva, sans-serif",
Arial: "Arial, Helvetica, sans-serif",
Impact: "Impact, Haettenschweiler, sans-serif",
Helvetica: "Helvetica, Arial, sans-serif",
Georgia: "Georgia, 'Times New Roman', serif",
"Courier New": "'Courier New', Courier, monospace",
};
export function getFontStack(fontFamily: string): string {
return SUBTITLE_FONTS[fontFamily] ?? fontFamily;
}
+130
View File
@@ -0,0 +1,130 @@
import { z } from "zod";
// --- Word-level caption ---
export interface CaptionWord {
text: string;
startMs: number;
endMs: number;
}
// --- Subtitle config ---
export type SubtitleAnimation = "none" | "word-highlight" | "pop" | "karaoke";
export type SubtitlePosition = "top" | "middle" | "bottom";
export interface SubtitleStyle {
fontFamily: string;
fontSize: number;
fontColor: string;
highlightColor: string;
borderColor: string;
borderWidth: number;
bgColor: string;
bgOpacity: number;
animation: SubtitleAnimation;
}
export interface SubtitleConfig {
captions: CaptionWord[];
position: SubtitlePosition;
style: SubtitleStyle;
}
// --- Hook config ---
export type HookPosition = "top" | "center" | "bottom";
export type HookSize = "S" | "M" | "L";
export type HookEntrance = "spring" | "fade" | "slide-up" | "none";
export interface HookConfig {
text: string;
position: HookPosition;
size: HookSize;
entranceAnimation: HookEntrance;
displayDurationSec: number;
}
// --- Effects config ---
export interface EffectSegment {
startSec: number;
endSec: number;
zoom: number;
zoomCenterX: number;
zoomCenterY: number;
brightness: number;
contrast: number;
saturate: number;
}
export interface EffectsConfig {
segments: EffectSegment[];
}
// --- Main composition props ---
export interface ShortVideoProps {
videoUrl: string;
durationInFrames: number;
fps: number;
width: number;
height: number;
subtitles: SubtitleConfig | null;
hook: HookConfig | null;
effects: EffectsConfig | null;
}
// --- Zod schemas for validation (used by render service) ---
export const captionWordSchema = z.object({
text: z.string(),
startMs: z.number(),
endMs: z.number(),
});
export const subtitleStyleSchema = z.object({
fontFamily: z.string(),
fontSize: z.number(),
fontColor: z.string(),
highlightColor: z.string(),
borderColor: z.string(),
borderWidth: z.number(),
bgColor: z.string(),
bgOpacity: z.number().min(0).max(1),
animation: z.enum(["none", "word-highlight", "pop", "karaoke"]),
});
export const subtitleConfigSchema = z.object({
captions: z.array(captionWordSchema),
position: z.enum(["top", "middle", "bottom"]),
style: subtitleStyleSchema,
});
export const hookConfigSchema = z.object({
text: z.string(),
position: z.enum(["top", "center", "bottom"]),
size: z.enum(["S", "M", "L"]),
entranceAnimation: z.enum(["spring", "fade", "slide-up", "none"]),
displayDurationSec: z.number().positive(),
});
export const effectSegmentSchema = z.object({
startSec: z.number().min(0),
endSec: z.number().positive(),
zoom: z.number().min(0.5).max(3),
zoomCenterX: z.number().min(0).max(1),
zoomCenterY: z.number().min(0).max(1),
brightness: z.number().min(0).max(3),
contrast: z.number().min(0).max(3),
saturate: z.number().min(0).max(3),
});
export const effectsConfigSchema = z.object({
segments: z.array(effectSegmentSchema),
});
export const shortVideoPropsSchema = z.object({
videoUrl: z.string(),
durationInFrames: z.number().int().positive(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
subtitles: subtitleConfigSchema.nullable(),
hook: hookConfigSchema.nullable(),
effects: effectsConfigSchema.nullable(),
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src/**/*"]
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+34
View File
@@ -0,0 +1,34 @@
FROM node:18-bookworm-slim
# Install Chromium and FFmpeg for Remotion rendering
RUN apt-get update && \
apt-get install -y --no-install-recommends chromium ffmpeg && \
rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV REMOTION_BUNDLE_PATH=/app/remotion
ENV OUTPUT_DIR=/output
WORKDIR /app
# Copy render service source
COPY render-service/package.json ./
RUN npm install
COPY render-service/tsconfig.json ./
COPY render-service/src/ ./src/
# Copy remotion source for bundling
COPY remotion/package.json /app/remotion/package.json
COPY remotion/tsconfig.json /app/remotion/tsconfig.json
COPY remotion/src/ /app/remotion/src/
COPY remotion/public/ /app/remotion/public/
# Install remotion deps
RUN cd /app/remotion && npm install
# Build TypeScript
RUN npm run build
EXPOSE 3100
CMD ["node", "dist/server.js"]
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "openshorts-render-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"@remotion/bundler": "^4.0.0",
"@remotion/renderer": "^4.0.0",
"express": "^4.21.0",
"remotion": "^4.0.0",
"uuid": "^10.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
import path from "node:path";
import { bundle } from "@remotion/bundler";
let bundleLocation: string | null = null;
/**
* Bundles the Remotion project on startup and caches the result.
* Uses REMOTION_BUNDLE_PATH env var to locate the remotion source
* (default: "../remotion" relative to this service's root).
*/
export async function initBundle(): Promise<void> {
const remotionRoot = process.env.REMOTION_BUNDLE_PATH
? path.resolve(process.env.REMOTION_BUNDLE_PATH)
: path.resolve(import.meta.dirname, "../../remotion");
const entryPoint = path.join(remotionRoot, "src", "index.ts");
console.log(`[bundle] Bundling Remotion project from: ${entryPoint}`);
bundleLocation = await bundle({
entryPoint,
onProgress: (progress: number) => {
if (progress % 10 === 0) {
console.log(`[bundle] Progress: ${progress}%`);
}
},
});
console.log(`[bundle] Bundle created at: ${bundleLocation}`);
}
/**
* Returns the cached bundle location.
* Throws if initBundle() has not been called yet.
*/
export function getBundleLocation(): string {
if (!bundleLocation) {
throw new Error(
"Bundle not initialized. Call initBundle() before getBundleLocation()."
);
}
return bundleLocation;
}
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { selectComposition, renderMedia } from "@remotion/renderer";
import { getBundleLocation } from "./bundle.js";
import { renderJobs } from "./server.js";
export interface RenderParams {
renderId: string;
jobId: string;
clipIndex: number;
props: {
videoUrl: string;
durationInFrames: number;
fps: number;
width: number;
height: number;
subtitles: unknown;
hook: unknown;
effects: unknown;
};
}
/**
* Executes a Remotion render in the background.
* Updates the in-memory render job map with progress and final status.
*/
export async function executeRender(params: RenderParams): Promise<void> {
const { renderId, jobId, clipIndex, props } = params;
const job = renderJobs.get(renderId);
if (!job) {
console.error(`[render-worker] Job ${renderId} not found in map`);
return;
}
try {
job.status = "rendering";
job.progress = 0;
console.log(
`[render-worker] Starting render ${renderId} (job=${jobId}, clip=${clipIndex})`
);
const bundleLocation = getBundleLocation();
// Select the composition with the provided input props
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "ShortVideo",
inputProps: props,
});
// Determine output directory and file path
const outputDir = process.env.OUTPUT_DIR
? path.resolve(process.env.OUTPUT_DIR)
: path.resolve(import.meta.dirname, "../../output");
const jobOutputDir = path.join(outputDir, jobId);
fs.mkdirSync(jobOutputDir, { recursive: true });
const timestamp = Date.now();
const outputFileName = `remotion_${clipIndex}_${timestamp}.mp4`;
const outputLocation = path.join(jobOutputDir, outputFileName);
console.log(`[render-worker] Output: ${outputLocation}`);
// Render the video
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
crf: 22,
outputLocation,
onProgress: ({ progress }) => {
const percent = Math.round(progress * 100);
job.progress = percent;
if (percent % 10 === 0) {
console.log(`[render-worker] ${renderId} progress: ${percent}%`);
}
},
});
// Success
job.status = "done";
job.progress = 100;
job.outputUrl = outputLocation;
console.log(`[render-worker] Render ${renderId} completed: ${outputLocation}`);
} catch (err) {
job.status = "error";
job.error = err instanceof Error ? err.message : String(err);
console.error(`[render-worker] Render ${renderId} failed:`, err);
}
}
+166
View File
@@ -0,0 +1,166 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { initBundle } from "./bundle.js";
import { executeRender } from "./render-worker.js";
// --- Render status types ---
export type RenderStatus = "queued" | "rendering" | "done" | "error";
export interface RenderJob {
renderId: string;
jobId: string;
clipIndex: number;
status: RenderStatus;
progress: number;
outputUrl?: string;
error?: string;
}
// In-memory render job map
export const renderJobs = new Map<string, RenderJob>();
// --- Request validation schema ---
const renderRequestSchema = z.object({
jobId: z.string().min(1),
clipIndex: z.number().int().min(0),
props: z.object({
videoUrl: z.string(),
durationInFrames: z.number().int().positive(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
subtitles: z.any().nullable().optional(),
hook: z.any().nullable().optional(),
effects: z.any().nullable().optional(),
}),
});
// --- Express app ---
const app = express();
app.use(express.json({ limit: "10mb" }));
const PORT = parseInt(process.env.PORT || "3100", 10);
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/output";
// Serve video files from the shared output volume so Remotion can access them via HTTP
app.use("/output", express.static(OUTPUT_DIR));
// Health check
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Submit a render job
app.post("/render", (req, res) => {
const parsed = renderRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: "Invalid request body",
details: parsed.error.issues,
});
return;
}
const { jobId, clipIndex, props } = parsed.data;
const renderId = uuidv4();
const job: RenderJob = {
renderId,
jobId,
clipIndex,
status: "queued",
progress: 0,
};
renderJobs.set(renderId, job);
console.log(
`[render] Queued render ${renderId} for job=${jobId} clip=${clipIndex}`
);
// Resolve video URL: convert frontend/backend URLs to renderer's own static server
// The renderer serves /output/* from the shared Docker volume
let resolvedVideoUrl = props.videoUrl;
const videoPathMatch = props.videoUrl.match(/\/videos\/([^/]+)\/(.+)$/);
if (videoPathMatch) {
resolvedVideoUrl = `http://localhost:${PORT}/output/${videoPathMatch[1]}/${videoPathMatch[2]}`;
console.log(`[render] Resolved video URL: ${props.videoUrl} -> ${resolvedVideoUrl}`);
}
// Fire and forget - render runs in background
executeRender({
renderId,
jobId,
clipIndex,
props: {
videoUrl: resolvedVideoUrl,
durationInFrames: props.durationInFrames,
fps: props.fps,
width: props.width,
height: props.height,
subtitles: props.subtitles ?? null,
hook: props.hook ?? null,
effects: props.effects ?? null,
},
}).catch((err) => {
console.error(`[render] Unhandled error for ${renderId}:`, err);
const existingJob = renderJobs.get(renderId);
if (existingJob) {
existingJob.status = "error";
existingJob.error =
err instanceof Error ? err.message : "Unknown error";
}
});
res.status(202).json({ renderId, status: "queued" });
});
// Get render status
app.get("/render/:renderId", (req, res) => {
const { renderId } = req.params;
const job = renderJobs.get(renderId);
if (!job) {
res.status(404).json({ error: "Render not found" });
return;
}
const response: Record<string, unknown> = {
renderId: job.renderId,
status: job.status,
};
if (job.progress !== undefined) {
response.progress = job.progress;
}
if (job.outputUrl) {
response.outputUrl = job.outputUrl;
}
if (job.error) {
response.error = job.error;
}
res.json(response);
});
// --- Start server ---
async function main() {
console.log("[render-service] Initializing Remotion bundle...");
await initBundle();
console.log("[render-service] Bundle ready.");
app.listen(PORT, () => {
console.log(`[render-service] Listening on port ${PORT}`);
});
}
main().catch((err) => {
console.error("[render-service] Fatal error during startup:", err);
process.exit(1);
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+18
View File
@@ -0,0 +1,18 @@
scenedetect==0.7
ultralytics==8.4.46
torch==2.11.0
torchvision==0.26.0
tqdm==4.67.3
yt-dlp
faster-whisper==1.2.1
google-genai==1.75.0
python-dotenv==1.2.2
mediapipe==0.10.14
boto3==1.43.4
fastapi==0.136.1
uvicorn==0.46.0
python-multipart==0.0.27
httpx==0.28.1
Pillow==12.2.0
beautifulsoup4==4.14.3
+446
View File
@@ -0,0 +1,446 @@
import os
from dotenv import load_dotenv
load_dotenv()
import boto3
from botocore.exceptions import ClientError
import logging
# Configure silent logging for boto3 and botocore
logging.getLogger('boto3').setLevel(logging.CRITICAL)
logging.getLogger('botocore').setLevel(logging.CRITICAL)
logging.getLogger('s3transfer').setLevel(logging.CRITICAL)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def upload_file_to_s3(file_path, bucket_name, s3_key):
"""
Upload a file to an S3 bucket silently.
"""
access_key = os.environ.get('AWS_ACCESS_KEY_ID')
secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
region = os.environ.get('AWS_REGION', 'eu-west-3')
if not access_key or not secret_key:
return False
s3_client = boto3.client(
's3',
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region
)
try:
# Extra arguments for public read if needed, but the user didn't specify.
# Given the bucket name, it might be for a web app.
s3_client.upload_file(file_path, bucket_name, s3_key)
return True
except ClientError:
return False
except Exception:
return False
from botocore.config import Config
import json
import time as time_module
# Simple in-memory cache for gallery clips
_clips_cache = {
"data": None,
"timestamp": 0
}
CACHE_TTL_SECONDS = 300 # 5 minutes
def get_s3_client():
"""Returns an authenticated S3 client."""
access_key = os.environ.get('AWS_ACCESS_KEY_ID')
secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
region = os.environ.get('AWS_REGION', 'eu-west-3')
if not access_key or not secret_key:
return None
return boto3.client(
's3',
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=Config(signature_version='s3v4')
)
def generate_presigned_url(bucket_name, object_key, expiration=3600):
"""Generate a presigned URL to share an S3 object."""
s3_client = get_s3_client()
if not s3_client:
return None
try:
response = s3_client.generate_presigned_url('get_object',
Params={'Bucket': bucket_name,
'Key': object_key},
ExpiresIn=expiration)
return response
except ClientError as e:
logger.error(e)
return None
def list_all_clips(bucket_name=None, limit=50, force_refresh=False):
"""
List recent clips from the S3 bucket by finding metadata files.
Returns a list of dicts containing clip info and signed URLs.
Args:
bucket_name: S3 bucket name (defaults to AWS_S3_BUCKET env var)
limit: Maximum number of clips to return (default 50 for speed)
force_refresh: If True, bypass cache
"""
global _clips_cache
# Check cache first
now = time_module.time()
if not force_refresh and _clips_cache["data"] is not None:
if now - _clips_cache["timestamp"] < CACHE_TTL_SECONDS:
cached = _clips_cache["data"]
return cached[:limit] if limit else cached
if not bucket_name:
bucket_name = os.environ.get('AWS_S3_BUCKET', 'my-clips-bucket')
s3_client = get_s3_client()
if not s3_client:
return []
all_clips = []
try:
# List all objects in bucket
# Note: For very large buckets, pagination is needed.
# Assuming reasonable size for now, but adding continuation token support is best practice.
paginator = s3_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket_name)
metadata_files = []
for page in pages:
if 'Contents' in page:
for obj in page['Contents']:
if obj['Key'].endswith('_metadata.json'):
metadata_files.append(obj)
# Sort metadata by LastModified (newest first)
metadata_files.sort(key=lambda x: x['LastModified'], reverse=True)
for meta_obj in metadata_files:
key = meta_obj['Key']
# key format: {job_id}/..._metadata.json
# Read metadata content
try:
obj_resp = s3_client.get_object(Bucket=bucket_name, Key=key)
content = obj_resp['Body'].read().decode('utf-8')
data = json.loads(content)
parts = key.split('/')
job_id = parts[0] if len(parts) > 1 else "unknown"
# Filename base for clips in same folder
# Meta key: "job_id/filename_metadata.json"
# Base name in metadata usually matches filename without ext
meta_filename = os.path.basename(key)
base_name = meta_filename.replace('_metadata.json', '')
clips_data = data.get('shorts', [])
for i, clip in enumerate(clips_data):
clip_filename = f"{base_name}_clip_{i+1}.mp4"
clip_key = f"{job_id}/{clip_filename}"
# Generate signed URL
signed_url = generate_presigned_url(bucket_name, clip_key, expiration=7200) # 2 hours
if signed_url:
all_clips.append({
"job_id": job_id,
"index": i,
"url": signed_url,
"title": clip.get('video_title_for_youtube_short', 'Untitled Clip'),
"tiktok_desc": clip.get('video_description_for_tiktok', ''),
"insta_desc": clip.get('video_description_for_instagram', ''),
"created_at": meta_obj['LastModified'].isoformat(),
"duration": clip.get('end', 0) - clip.get('start', 0)
})
# Early exit if we have enough clips
if limit and len(all_clips) >= limit:
break
# Early exit if we have enough clips
if limit and len(all_clips) >= limit:
break
except Exception as e:
logger.error(f"Error processing metadata {key}: {e}")
continue
except Exception as e:
logger.error(f"Error listing bucket: {e}")
return []
# Update cache with full results (keep for pagination later)
_clips_cache["data"] = all_clips
_clips_cache["timestamp"] = now
return all_clips[:limit] if limit else all_clips
def upload_actor_to_s3(file_path, description=""):
"""
Upload an actor image to the public S3 bucket.
Returns the public URL or None on failure.
"""
bucket_name = os.environ.get('AWS_S3_PUBLIC_BUCKET', 'my-public-bucket')
region = os.environ.get('AWS_REGION', 'eu-west-3')
s3_client = get_s3_client()
if not s3_client:
return None
import uuid
unique_id = str(uuid.uuid4())[:8]
filename = os.path.basename(file_path)
name, ext = os.path.splitext(filename)
s3_key = f"avatars/{name}_{unique_id}{ext}"
try:
# Skip broken/tiny files
if os.path.getsize(file_path) < 1000:
logger.warning(f"Skipping tiny file ({os.path.getsize(file_path)} bytes): {file_path}")
return None
s3_client.upload_file(
file_path, bucket_name, s3_key,
ExtraArgs={'ContentType': 'image/png'},
)
public_url = f"https://{bucket_name}.s3.{region}.amazonaws.com/{s3_key}"
# Save metadata JSON alongside the image
if description:
import datetime
meta_key = s3_key.rsplit('.', 1)[0] + '.json'
meta = json.dumps({
"description": description,
"url": public_url,
"created_at": datetime.datetime.utcnow().isoformat() + "Z",
}, ensure_ascii=False)
s3_client.put_object(
Bucket=bucket_name, Key=meta_key,
Body=meta.encode('utf-8'),
ContentType='application/json',
)
logger.info(f"Uploaded actor to S3: {public_url}")
return public_url
except Exception as e:
logger.error(f"Failed to upload actor to S3: {e}")
return None
def list_actor_gallery():
"""
List all actor images from the public S3 bucket.
Returns list with URLs and descriptions, newest first.
"""
bucket_name = os.environ.get('AWS_S3_PUBLIC_BUCKET', 'my-public-bucket')
region = os.environ.get('AWS_REGION', 'eu-west-3')
s3_client = get_s3_client()
if not s3_client:
return []
try:
paginator = s3_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket_name, Prefix='avatars/')
all_objects = {}
for page in pages:
for obj in page.get('Contents', []):
key = obj['Key']
base = key.rsplit('.', 1)[0]
if base not in all_objects:
all_objects[base] = {}
if key.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
all_objects[base]['image'] = obj
elif key.endswith('.json'):
all_objects[base]['meta_key'] = key
images = []
for base, data in all_objects.items():
if 'image' not in data:
continue
obj = data['image']
key = obj['Key']
public_url = f"https://{bucket_name}.s3.{region}.amazonaws.com/{key}"
entry = {
"url": public_url,
"key": key,
"created_at": obj['LastModified'].isoformat(),
"description": "",
}
# Try to read metadata JSON
if 'meta_key' in data:
try:
meta_resp = s3_client.get_object(Bucket=bucket_name, Key=data['meta_key'])
meta = json.loads(meta_resp['Body'].read().decode('utf-8'))
entry['description'] = meta.get('description', '')
except Exception:
pass
images.append(entry)
images.sort(key=lambda x: x['created_at'], reverse=True)
return images
except Exception as e:
logger.error(f"Failed to list actor gallery: {e}")
return []
# ── SaaS Video Gallery (public S3) ──────────────────────────────────
_video_gallery_cache = {
"data": None,
"timestamp": 0,
}
def upload_video_to_gallery(video_path, actor_image_path, metadata, video_id=None):
"""
Upload a generated UGC video + actor + metadata to the public S3 bucket.
Returns dict with public URLs or None on failure.
"""
import uuid
bucket_name = os.environ.get('AWS_S3_PUBLIC_BUCKET', 'my-public-bucket')
region = os.environ.get('AWS_REGION', 'eu-west-3')
s3_client = get_s3_client()
if not s3_client:
return None
if not video_id:
video_id = str(uuid.uuid4())[:8]
base_url = f"https://{bucket_name}.s3.{region}.amazonaws.com"
results = {}
try:
# Upload video
if os.path.exists(video_path):
s3_key = f"videos/{video_id}/video.mp4"
s3_client.upload_file(video_path, bucket_name, s3_key,
ExtraArgs={'ContentType': 'video/mp4'})
results["video_url"] = f"{base_url}/{s3_key}"
# Upload actor image
if actor_image_path and os.path.exists(actor_image_path):
s3_key = f"videos/{video_id}/actor.png"
s3_client.upload_file(actor_image_path, bucket_name, s3_key,
ExtraArgs={'ContentType': 'image/png'})
results["actor_url"] = f"{base_url}/{s3_key}"
# Build and upload metadata
import datetime
metadata["video_id"] = video_id
metadata["video_url"] = results.get("video_url", "")
metadata["actor_url"] = results.get("actor_url", "")
metadata["created_at"] = datetime.datetime.utcnow().isoformat() + "Z"
meta_json = json.dumps(metadata, ensure_ascii=False, indent=2)
s3_key = f"videos/{video_id}/metadata.json"
s3_client.put_object(
Bucket=bucket_name, Key=s3_key,
Body=meta_json.encode('utf-8'),
ContentType='application/json',
)
results["metadata_url"] = f"{base_url}/{s3_key}"
results["video_id"] = video_id
logger.info(f"Uploaded video gallery: {video_id}")
# Invalidate cache
_video_gallery_cache["data"] = None
return results
except Exception as e:
logger.error(f"Failed to upload video to gallery: {e}")
return None
def list_video_gallery(limit=50, force_refresh=False):
"""
List all UGC videos from the public S3 bucket.
Returns list of metadata dicts, newest first.
"""
global _video_gallery_cache
now = time_module.time()
if not force_refresh and _video_gallery_cache["data"] is not None:
if now - _video_gallery_cache["timestamp"] < CACHE_TTL_SECONDS:
cached = _video_gallery_cache["data"]
return cached[:limit] if limit else cached
bucket_name = os.environ.get('AWS_S3_PUBLIC_BUCKET', 'my-public-bucket')
s3_client = get_s3_client()
if not s3_client:
return []
videos = []
try:
paginator = s3_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket_name, Prefix='videos/')
meta_files = []
for page in pages:
for obj in page.get('Contents', []):
if obj['Key'].endswith('/metadata.json'):
meta_files.append(obj)
# Newest first
meta_files.sort(key=lambda x: x['LastModified'], reverse=True)
for meta_obj in meta_files:
try:
obj_resp = s3_client.get_object(Bucket=bucket_name, Key=meta_obj['Key'])
content = obj_resp['Body'].read().decode('utf-8')
data = json.loads(content)
videos.append(data)
if limit and len(videos) >= limit:
break
except Exception as e:
logger.error(f"Error reading metadata {meta_obj['Key']}: {e}")
continue
except Exception as e:
logger.error(f"Failed to list video gallery: {e}")
return []
_video_gallery_cache["data"] = videos
_video_gallery_cache["timestamp"] = now
return videos[:limit] if limit else videos
def upload_job_artifacts(directory, job_id):
"""
Upload all generated clips and metadata for a job to S3.
"""
bucket_name = os.environ.get('AWS_S3_BUCKET', 'my-clips-bucket')
if not os.path.exists(directory):
return
for filename in os.listdir(directory):
# Upload .mp4 clips and the metadata JSON
if (filename.endswith(".mp4") or filename.endswith(".json")) and not filename.startswith("temp_"):
file_path = os.path.join(directory, filename)
s3_key = f"{job_id}/{filename}"
upload_file_to_s3(file_path, bucket_name, s3_key)
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

+374
View File
@@ -0,0 +1,374 @@
import os
import subprocess
def transcribe_audio(video_path):
"""
Transcribe audio from a video file using faster-whisper.
Returns transcript in the same format as main.py for compatibility.
"""
from faster_whisper import WhisperModel
print(f"🎙️ Transcribing audio from: {video_path}")
# Run on CPU with INT8 quantization for speed
model = WhisperModel("base", device="cpu", compute_type="int8")
segments, info = model.transcribe(video_path, word_timestamps=True)
transcript = {
"segments": [],
"language": info.language
}
for segment in segments:
seg_data = {
"start": segment.start,
"end": segment.end,
"text": segment.text,
"words": []
}
if segment.words:
for word in segment.words:
seg_data["words"].append({
"word": word.word.strip(),
"start": word.start,
"end": word.end
})
transcript["segments"].append(seg_data)
print(f"✅ Transcription complete. Language: {info.language}")
return transcript
def generate_srt_from_video(video_path, output_path, max_chars=20, max_duration=2.0):
"""
Transcribe a video and generate SRT directly.
Used for dubbed videos that don't have a pre-existing transcript.
"""
transcript = transcribe_audio(video_path)
# Get video duration to use as clip_end
import cv2
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = frame_count / fps if fps else 0
cap.release()
return generate_srt(transcript, 0, duration, output_path, max_chars, max_duration)
import re
def load_swears(swears_path):
if not swears_path or not os.path.exists(swears_path):
return set()
with open(swears_path, "r", encoding="utf-8") as f:
return {w.strip().lower() for w in f if w.strip()}
def censor_word(word):
if len(word) <= 2:
return "*" * len(word)
return word[0] + "*" * (len(word) - 2) + word[-1]
def _merge_overlapping(ranges, pad_seconds=0.05):
if not ranges:
return []
# Apply padding
padded_ranges = []
for s, e in ranges:
padded_ranges.append((max(0.0, s - pad_seconds), e + pad_seconds))
padded_ranges = sorted(padded_ranges)
merged = [padded_ranges[0]]
for s, e in padded_ranges[1:]:
ps, pe = merged[-1]
if s <= pe:
merged[-1] = (ps, max(pe, e))
else:
merged.append((s, e))
return merged
def generate_srt(transcript, clip_start, clip_end, output_path, max_chars=20, max_duration=2.0, swears_path=None, return_mute_ranges=False):
"""
Generates an SRT file from the transcript for a specific time range.
Groups words into short lines suitable for vertical video.
If swears_path is provided, censors swear words in the text and extracts mute ranges.
"""
swears = load_swears(swears_path) if swears_path else set()
raw_mute_ranges = []
words = []
# 1. Extract and flatten words within range
for segment in transcript.get('segments', []):
for word_info in segment.get('words', []):
# Check overlap
if word_info['end'] > clip_start and word_info['start'] < clip_end:
word_copy = dict(word_info)
word_text = word_copy['word']
cleaned_word = re.sub(r'[^\w]', '', word_text.lower())
if cleaned_word in swears:
# Record absolute mute range relative to clip start
rel_start = max(0.0, word_copy['start'] - clip_start)
rel_end = max(0.0, word_copy['end'] - clip_start)
raw_mute_ranges.append((rel_start, rel_end))
# Censor word
word_copy['word'] = censor_word(word_text)
words.append(word_copy)
if not words:
if return_mute_ranges:
return False, []
return False
import json
srt_content = ""
index = 1
blocks = []
current_block = []
block_start = None
for i, word in enumerate(words):
# Adjust times relative to clip
start = max(0, word['start'] - clip_start)
end = max(0, word['end'] - clip_start)
# Word info copy with relative times
w_rel = {
'word': word['word'],
'start': start,
'end': end
}
if not current_block:
current_block.append(w_rel)
block_start = start
else:
current_text_len = sum(len(w['word']) + 1 for w in current_block)
duration = end - block_start
if current_text_len + len(w_rel['word']) > max_chars or duration > max_duration:
blocks.append(current_block)
block_end = current_block[-1]['end']
text = " ".join([w['word'] for w in current_block]).strip()
srt_content += format_srt_block(index, block_start, block_end, text)
index += 1
current_block = [w_rel]
block_start = start
else:
current_block.append(w_rel)
# Final block
if current_block:
blocks.append(current_block)
block_end = current_block[-1]['end']
text = " ".join([w['word'] for w in current_block]).strip()
srt_content += format_srt_block(index, block_start, block_end, text)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(srt_content)
# Write words json file
words_json_path = os.path.splitext(output_path)[0] + ".words.json"
with open(words_json_path, 'w', encoding='utf-8') as f:
json.dump(blocks, f, indent=2)
mute_ranges = _merge_overlapping(raw_mute_ranges)
if return_mute_ranges:
return True, mute_ranges
return True
def format_srt_block(index, start, end, text):
def format_time(seconds):
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds - int(seconds)) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
return f"{index}\n{format_time(start)} --> {format_time(end)}\n{text}\n\n"
def hex_to_ass_color(hex_color, opacity=1.0):
"""Convert #RRGGBB to ASS &HAABBGGRR format. opacity: 0.0=transparent, 1.0=opaque"""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
hex_color = "FFFFFF"
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
alpha = round((1.0 - opacity) * 255)
return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
def burn_subtitles(video_path, srt_path, output_path, alignment=2, fontsize=16,
font_name="Verdana", font_color="#FFFFFF",
border_color="#000000", border_width=2,
bg_color="#000000", bg_opacity=0.0, fonts_dir=None):
"""
Burns subtitles into the video using FFmpeg by converting SRT to a styled ASS file.
Supports outline mode, box mode, and active word-level highlight box if a words JSON is available.
"""
import pysubs2
import os
import json
# 1. Load subtitles
subs = pysubs2.load(srt_path, encoding="utf-8")
# 2. Configure project resolutions to match vertical video standards
subs.info["PlayResX"] = "1080"
subs.info["PlayResY"] = "1920"
subs.info["ScaledBorderAndShadow"] = "yes"
# 3. Configure Default style
style = subs.styles["Default"]
style.fontname = font_name
style.fontsize = int(fontsize * 6.66)
style.bold = True
# Position mapping
ass_alignment = 2
align_lower = str(alignment).lower()
if align_lower == 'top':
ass_alignment = 8
elif align_lower == 'middle':
ass_alignment = 5
elif align_lower == 'bottom':
ass_alignment = 2
style.alignment = ass_alignment
style.marginv = 200
# Convert colors
def hex_to_rgb(hex_str):
hex_str = hex_str.lstrip('#')
if len(hex_str) != 6:
hex_str = "FFFFFF"
return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
pr, pg, pb = hex_to_rgb(font_color)
or_, og, ob = hex_to_rgb(border_color)
br, bg, bb = hex_to_rgb(bg_color)
# Check if we have word-level highlights
words_json_path = os.path.splitext(srt_path)[0] + ".words.json"
has_words = os.path.exists(words_json_path)
if has_words:
# Highlight Mode:
# Default style is plain white text with black outline (no background box)
style.borderstyle = 1
style.primarycolor = pysubs2.Color(pr, pg, pb, 0)
style.outlinecolor = pysubs2.Color(or_, og, ob, 0)
style.backcolor = pysubs2.Color(0, 0, 0, 255) # transparent shadow
style.outline = border_width
style.shadow = 0
# Define Highlight style (pink box, black outline inside the box)
highlight_style = style.copy()
highlight_style.borderstyle = 3 # Opaque box
highlight_style.outlinecolor = pysubs2.Color(br, bg, bb, round((1.0 - bg_opacity) * 255))
highlight_style.backcolor = pysubs2.Color(or_, og, ob, 0) # Text outline inside the box
highlight_style.outline = border_width # Box padding
subs.styles["Highlight"] = highlight_style
# Load words and rebuild events
try:
with open(words_json_path, 'r', encoding='utf-8') as f:
blocks = json.load(f)
subs.events.clear()
for block in blocks:
if not block:
continue
block_start = block[0]['start']
block_end = block[-1]['end']
n_words = len(block)
for idx in range(n_words):
if idx == 0:
event_start = block_start
else:
event_start = block[idx]['start']
if idx == n_words - 1:
event_end = block_end
else:
event_end = block[idx+1]['start']
text_parts = []
for j, w in enumerate(block):
word_str = w['word']
if j == idx:
text_parts.append(f"{{\\rHighlight}}{word_str}{{\\r}}")
else:
text_parts.append(word_str)
event_text = " ".join(text_parts)
start_ms = int(event_start * 1000)
end_ms = int(event_end * 1000)
subs.events.append(pysubs2.SSAEvent(start=start_ms, end=end_ms, text=event_text))
except Exception as e:
print(f"⚠️ Failed to parse words JSON: {e}. Falling back to standard ASS.")
has_words = False
if not has_words:
# Fallback to standard full-block style (original style behavior)
style.primarycolor = pysubs2.Color(pr, pg, pb, 0)
if bg_opacity > 0:
style.borderstyle = 3 # Opaque box
style.outlinecolor = pysubs2.Color(br, bg, bb, round((1.0 - bg_opacity) * 255))
style.backcolor = pysubs2.Color(or_, og, ob, 0)
style.outline = border_width
style.shadow = 0
else:
style.borderstyle = 1
style.outlinecolor = pysubs2.Color(or_, og, ob, 0)
style.backcolor = pysubs2.Color(0, 0, 0, 255)
style.outline = border_width
style.shadow = 0
# Save styled ASS file
ass_path = os.path.splitext(srt_path)[0] + ".ass"
subs.save(ass_path, format_="ass")
# 4. Burn using FFmpeg
safe_ass_path = ass_path.replace('\\', '/').replace(':', '\\:')
subtitles_filter = f"subtitles='{safe_ass_path}'"
if fonts_dir:
safe_fonts_dir = fonts_dir.replace('\\', '/').replace(':', '\\:')
subtitles_filter += f":fontsdir='{safe_fonts_dir}'"
cmd = [
'ffmpeg', '-y',
'-i', video_path,
'-vf', subtitles_filter,
'-c:a', 'copy',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
output_path
]
print(f"🎬 Burning subtitles using ASS file: {' '.join(cmd)}")
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
# Clean up files
if os.path.exists(ass_path):
os.remove(ass_path)
if os.path.exists(words_json_path):
os.remove(words_json_path)
if result.returncode != 0:
print(f"❌ FFmpeg Subtitle Error: {result.stderr.decode()}")
raise Exception(f"FFmpeg failed: {result.stderr.decode()}")
return True
+335
View File
@@ -0,0 +1,335 @@
import os
import uuid
import time
import json
from google import genai
from google.genai import types
from PIL import Image
def analyze_video_for_titles(api_key, video_path, transcript=None):
"""
Transcribes a video and uses Gemini to suggest viral YouTube titles.
If transcript is provided, skips Whisper transcription.
Returns: { "titles": [...], "transcript_summary": "...", "language": "...", "segments": [...], "video_duration": ... }
"""
if transcript is None:
from main import transcribe_video
print("🎬 [Thumbnail] Transcribing video...")
transcript = transcribe_video(video_path)
else:
print("🎬 [Thumbnail] Using pre-computed transcript (Whisper already done)...")
print("📤 [Thumbnail] Uploading video to Gemini...")
client = genai.Client(api_key=api_key)
file_upload = client.files.upload(file=video_path)
while True:
file_info = client.files.get(name=file_upload.name)
if file_info.state == "ACTIVE":
break
elif file_info.state == "FAILED":
raise Exception("Video processing failed by Gemini.")
time.sleep(2)
prompt = f"""You are a YouTube title expert who creates viral, click-worthy titles.
Analyze this video and its transcript, then suggest 10 YouTube titles that would maximize CTR (click-through rate).
TRANSCRIPT:
{transcript['text']}
RULES:
- Titles must be under 70 characters
- Use power words, curiosity gaps, and emotional triggers
- Mix styles: how-to, listicle, story-driven, controversial, question-based
- Make them specific to the actual content, not generic
- Include numbers where appropriate
- Consider the language of the video (detected: {transcript['language']})
- Titles should be in the SAME LANGUAGE as the video transcript
Also provide a brief summary of the video content (2-3 sentences).
After generating all 10 titles, pick the TOP 2 you most recommend and explain concisely WHY (CTR potential, emotional hook, uniqueness, etc.). Reference them by their 0-based index in the titles array.
OUTPUT JSON:
{{
"titles": ["title1", "title2", ...],
"transcript_summary": "Brief summary of the video content...",
"language": "{transcript['language']}",
"recommended": [
{{"index": 0, "reason": "Why this title is best..."}},
{{"index": 3, "reason": "Why this title is second best..."}}
]
}}"""
print("🤖 [Thumbnail] Asking Gemini for title suggestions...")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[file_upload, prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json"
)
)
# Extract segments and duration from transcript for later use
segments = transcript.get("segments", [])
video_duration = segments[-1]["end"] if segments else 0
try:
text = response.text.strip()
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
start_idx = text.find('{')
end_idx = text.rfind('}')
if start_idx != -1 and end_idx != -1:
text = text[start_idx:end_idx + 1]
result = json.loads(text)
result["transcript_summary"] = result.get("transcript_summary", "")
result["language"] = result.get("language", transcript["language"])
result["segments"] = segments
result["video_duration"] = video_duration
return result
except json.JSONDecodeError:
print(f"❌ [Thumbnail] Failed to parse titles JSON: {response.text}")
return {
"titles": ["Could not generate titles - please try again"],
"transcript_summary": transcript["text"][:500],
"language": transcript["language"],
"segments": segments,
"video_duration": video_duration
}
def refine_titles(api_key, context, user_message, conversation_history=None):
"""
Takes video context + user feedback and returns refined title suggestions.
"""
client = genai.Client(api_key=api_key)
history_text = ""
if conversation_history:
for msg in conversation_history:
role = msg.get("role", "user")
history_text += f"\n{role.upper()}: {msg['content']}"
prompt = f"""You are a YouTube title expert. Based on the video context and the user's feedback, suggest 8 new refined YouTube titles.
VIDEO CONTEXT:
{context}
CONVERSATION HISTORY:{history_text}
USER'S NEW REQUEST:
{user_message}
RULES:
- Titles must be under 70 characters
- Incorporate the user's feedback/direction
- Keep titles viral and click-worthy
- If the user asks for a specific style, follow it
- Titles should be in the same language as the original content
OUTPUT JSON:
{{
"titles": ["title1", "title2", ...]
}}"""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json"
)
)
try:
text = response.text.strip()
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
start_idx = text.find('{')
end_idx = text.rfind('}')
if start_idx != -1 and end_idx != -1:
text = text[start_idx:end_idx + 1]
return json.loads(text)
except json.JSONDecodeError:
print(f"❌ [Thumbnail] Failed to parse refined titles: {response.text}")
return {"titles": ["Could not refine titles - please try again"]}
def generate_thumbnail(api_key, title, session_id, face_image_path=None, bg_image_path=None, extra_prompt="", count=3, video_context=""):
"""
Generates YouTube thumbnails using Gemini image generation.
Returns list of saved image paths (relative URLs).
"""
client = genai.Client(api_key=api_key)
output_dir = os.path.join("output", "thumbnails", session_id)
os.makedirs(output_dir, exist_ok=True)
prompt_parts = []
# Add face image if provided
if face_image_path and os.path.exists(face_image_path):
face_img = Image.open(face_image_path)
prompt_parts.append(face_img)
# Add background image if provided
if bg_image_path and os.path.exists(bg_image_path):
bg_img = Image.open(bg_image_path)
prompt_parts.append(bg_img)
# Build video context block
context_block = ""
if video_context:
context_block = f"""
VIDEO CONTEXT (use this to understand the video and design a relevant thumbnail):
{video_context}
"""
# Build extra instructions block (high priority)
extra_block = ""
if extra_prompt:
extra_block = f"""
MANDATORY USER INSTRUCTIONS (MUST follow these exactly they override any default behavior):
{extra_prompt}
"""
text_prompt = f"""Generate a professional, eye-catching YouTube thumbnail image.
VIDEO TITLE (for reference do NOT put the full title on the thumbnail): "{title}"
{context_block}
TEXT ON THE THUMBNAIL:
- Based on the title AND the video context, create a SHORT visual hook: 1 to 5 words maximum
- It should capture the core emotion, surprise, or promise of the video
- The thumbnail text should COMPLEMENT the YouTube title (which appears below), not repeat it
- Examples: "$10K EN 30 DÍAS", "ESTO FUNCIONA", "NO LO SABÍAS", "GRATIS 🔥"
- Use ALL CAPS for maximum impact, split into 2-3 lines
{extra_block}
DESIGN REQUIREMENTS:
- The text MUST be large, bold, and high-contrast (readable at small sizes)
- Use vibrant, eye-catching colors that match the video's mood
- Professional YouTube thumbnail aesthetic
- Clean composition text and face/subject as clear focal points
- NO clutter, NO small text, NO watermarks"""
if face_image_path and os.path.exists(face_image_path):
text_prompt += "\n- Include the provided face/person prominently with an exaggerated expression (surprise, excitement, shock)"
if bg_image_path and os.path.exists(bg_image_path):
text_prompt += "\n- Use the provided background image as the base/backdrop"
prompt_parts.append(text_prompt)
thumbnails = []
last_error = None
for i in range(count):
print(f"🎨 [Thumbnail] Generating thumbnail {i + 1}/{count}...")
try:
response = client.models.generate_content(
model="gemini-3.1-flash-image-preview",
contents=prompt_parts,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio="16:9",
image_size="2K"
)
)
)
for part in response.parts:
if part.text is not None:
print(f"📝 [Thumbnail] Gemini text: {part.text}")
elif image := part.as_image():
filename = f"thumb_{i + 1}.jpg"
filepath = os.path.join(output_dir, filename)
image.save(filepath)
thumbnails.append(f"/thumbnails/{session_id}/{filename}")
print(f"✅ [Thumbnail] Saved: {filepath}")
break
except Exception as e:
last_error = str(e)
print(f"❌ [Thumbnail] Generation {i + 1} failed: {e}")
if not thumbnails and last_error:
raise RuntimeError(f"All thumbnail generations failed. Last error: {last_error}")
return thumbnails
def generate_youtube_description(api_key, title, transcript_segments, language, video_duration):
"""
Uses Gemini to generate a YouTube description with chapter markers from transcript segments.
Returns: { "description": "full description text with chapters" }
"""
client = genai.Client(api_key=api_key)
# Format segments for the prompt
formatted_segments = []
for seg in transcript_segments:
start = seg.get("start", 0)
mins = int(start // 60)
secs = int(start % 60)
timestamp = f"{mins}:{secs:02d}"
formatted_segments.append(f"[{timestamp}] {seg.get('text', '').strip()}")
segments_text = "\n".join(formatted_segments)
# Format total duration
dur_mins = int(video_duration // 60)
dur_secs = int(video_duration % 60)
duration_str = f"{dur_mins}:{dur_secs:02d}"
prompt = f"""You are a YouTube SEO expert. Generate a complete YouTube video description for the following video.
VIDEO TITLE: "{title}"
VIDEO LANGUAGE: {language}
VIDEO DURATION: {duration_str}
TRANSCRIPT WITH TIMESTAMPS:
{segments_text}
REQUIREMENTS:
1. Write the description in the SAME LANGUAGE as the video ({language})
2. Start with a compelling 2-3 sentence summary/hook
3. Add relevant CTAs (subscribe, like, comment)
4. Generate YouTube CHAPTERS based on the transcript timestamps:
- First chapter MUST start at 0:00
- Minimum 3 chapters, each at least 10 seconds apart
- Chapter titles should be concise and descriptive
- Format: 0:00 Chapter Title
- Place chapters in their own section with a blank line before and after
5. Add 5-10 relevant hashtags at the end
6. Keep the total description under 5000 characters
OUTPUT: Return ONLY the description text (no JSON wrapper, no markdown code blocks). The description should be ready to paste directly into YouTube."""
print("🤖 [Thumbnail] Generating YouTube description with chapters...")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt],
)
description = response.text.strip()
# Clean up any accidental markdown wrappers
if description.startswith("```"):
lines = description.split("\n")
description = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
return {"description": description}
+239
View File
@@ -0,0 +1,239 @@
"""
ElevenLabs Video Translation/Dubbing Module
Uses ElevenLabs Dubbing API to translate video audio to different languages.
"""
import os
import time
import httpx
from typing import Optional
ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1"
# Supported target languages for dubbing
SUPPORTED_LANGUAGES = {
"en": "English",
"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",
}
def create_dubbing_project(
video_path: str,
target_language: str,
api_key: str,
source_language: Optional[str] = None,
) -> dict:
"""
Create a new dubbing project with ElevenLabs.
Args:
video_path: Path to the video file
target_language: Target language code (e.g., 'es', 'fr', 'de')
api_key: ElevenLabs API key
source_language: Source language code (auto-detected if None)
Returns:
dict with dubbing_id and expected_duration_sec
"""
url = f"{ELEVENLABS_API_BASE}/dubbing"
headers = {
"xi-api-key": api_key,
}
# Prepare form data
data = {
"target_lang": target_language,
"mode": "automatic",
"num_speakers": "0",
"watermark": "false",
}
if source_language:
data["source_lang"] = source_language
# Open and send the video file
with open(video_path, "rb") as video_file:
files = {
"file": (os.path.basename(video_path), video_file, "video/mp4")
}
print(f"[ElevenLabs] Creating dubbing project for {target_language}...")
with httpx.Client(timeout=300.0) as client:
response = client.post(url, headers=headers, data=data, files=files)
if response.status_code not in [200, 201]:
error_msg = response.text
try:
error_data = response.json()
error_msg = error_data.get("detail", {}).get("message", response.text)
except:
pass
raise Exception(f"ElevenLabs API error: {error_msg}")
result = response.json()
print(f"[ElevenLabs] Dubbing project created: {result.get('dubbing_id')}")
return result
def get_dubbing_status(dubbing_id: str, api_key: str) -> dict:
"""
Check the status of a dubbing project.
Returns:
dict with status ('dubbing', 'dubbed', 'failed') and other metadata
"""
url = f"{ELEVENLABS_API_BASE}/dubbing/{dubbing_id}"
headers = {
"xi-api-key": api_key,
}
with httpx.Client(timeout=30.0) as client:
response = client.get(url, headers=headers)
if response.status_code != 200:
raise Exception(f"Failed to get dubbing status: {response.text}")
return response.json()
def download_dubbed_video(
dubbing_id: str,
target_language: str,
output_path: str,
api_key: str
) -> str:
"""
Download the dubbed video file.
Args:
dubbing_id: The dubbing project ID
target_language: Target language code
output_path: Where to save the dubbed video
api_key: ElevenLabs API key
Returns:
Path to the downloaded file
"""
url = f"{ELEVENLABS_API_BASE}/dubbing/{dubbing_id}/audio/{target_language}"
headers = {
"xi-api-key": api_key,
}
print(f"[ElevenLabs] Downloading dubbed video...")
with httpx.Client(timeout=120.0) as client:
with client.stream("GET", url, headers=headers) as response:
if response.status_code != 200:
raise Exception(f"Failed to download dubbed video: {response.text}")
with open(output_path, "wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
print(f"[ElevenLabs] Dubbed video saved to: {output_path}")
return output_path
def translate_video(
video_path: str,
output_path: str,
target_language: str,
api_key: str,
source_language: Optional[str] = None,
max_wait_seconds: int = 600,
poll_interval: int = 5,
) -> str:
"""
Translate a video to a target language using ElevenLabs dubbing.
This is a blocking call that waits for the dubbing to complete.
Args:
video_path: Path to input video
output_path: Path to save translated video
target_language: Target language code
api_key: ElevenLabs API key
source_language: Source language code (auto-detected if None)
max_wait_seconds: Maximum time to wait for dubbing (default 10 min)
poll_interval: Seconds between status checks
Returns:
Path to the translated video
"""
# Create dubbing project
project = create_dubbing_project(
video_path=video_path,
target_language=target_language,
api_key=api_key,
source_language=source_language,
)
dubbing_id = project["dubbing_id"]
expected_duration = project.get("expected_duration_sec", 60)
print(f"[ElevenLabs] Dubbing ID: {dubbing_id}, Expected duration: {expected_duration}s")
# Poll for completion
start_time = time.time()
while True:
elapsed = time.time() - start_time
if elapsed > max_wait_seconds:
raise Exception(f"Dubbing timed out after {max_wait_seconds} seconds")
status = get_dubbing_status(dubbing_id, api_key)
current_status = status.get("status", "unknown")
print(f"[ElevenLabs] Status: {current_status} (elapsed: {int(elapsed)}s)")
if current_status == "dubbed":
# Download the result
return download_dubbed_video(
dubbing_id=dubbing_id,
target_language=target_language,
output_path=output_path,
api_key=api_key,
)
elif current_status == "failed":
error = status.get("error", "Unknown error")
raise Exception(f"Dubbing failed: {error}")
# Still processing, wait and poll again
time.sleep(poll_interval)
def get_supported_languages() -> dict:
"""Return dict of supported language codes and names."""
return SUPPORTED_LANGUAGES.copy()
+37
View File
@@ -0,0 +1,37 @@
import os
import shutil
# Check if PIL is installed, if not we can't run this locally but it will run in docker
try:
from hooks import create_hook_image
except ImportError:
print("⚠️ PIL not found locally. Please run this inside the Docker container.")
# Mocking for local check if needed or just exit
exit(1)
def verify():
print("🧪 Verifying Hook Aesthetics...")
test_text = "POV: You are testing\nthe new aesthetic feature\nwith explicit lines."
output_path = "aesthetic_hook.png"
target_width = 800
try:
path, w, h = create_hook_image(test_text, target_width, output_image_path=output_path)
print(f"✅ Image generated at {path}")
print(f" Dimensions including shadow: {w}x{h}")
# Verify it's larger than the text box would be (due to shadow/padding)
# Just rudimentary checks
if not os.path.exists(path):
print("❌ File does not exist")
return False
print("✨ Verification Successful! (Inspect aesthetic_hook.png visually)")
return True
except Exception as e:
print(f"❌ Verification Failed: {e}")
return False
if __name__ == "__main__":
verify()
+32
View File
@@ -0,0 +1,32 @@
import os
try:
from hooks import create_hook_image
except ImportError:
print("⚠️ PIL not found locally. Run inside Docker.")
exit(1)
def verify():
print("🧪 Verifying Hook Customization...")
test_text = "Custom Position\n& Size Test"
# Test 1: Small + Top
print(" Testing Small + Top...")
p1, w1, h1 = create_hook_image(test_text, 800, "hook_small.png", font_scale=0.8)
print(f" ✅ Small: {w1}x{h1}")
# Test 2: Large + Center
print(" Testing Large...")
p2, w2, h2 = create_hook_image(test_text, 800, "hook_large.png", font_scale=1.3)
print(f" ✅ Large: {w2}x{h2}")
if w2 > w1 and h2 > h1:
print(" ✅ Scaling logic works (Large > Small)")
else:
print(" ❌ Scaling logic failed")
# Cleanup
if os.path.exists(p1): os.remove(p1)
if os.path.exists(p2): os.remove(p2)
if __name__ == "__main__":
verify()
+36
View File
@@ -0,0 +1,36 @@
import os
import shutil
from hooks import create_hook_image
def verify():
print("🧪 Verifying Hook Image Generation...")
test_text = "POV: You are testing the viral hook feature\nand it works perfectly."
output_path = "test_hook.png"
target_width = 800
try:
path, w, h = create_hook_image(test_text, target_width, output_image_path=output_path)
print(f"✅ Image generated at {path}")
print(f" Dimensions: {w}x{h}")
if not os.path.exists(path):
print("❌ File does not exist")
return False
if os.path.getsize(path) == 0:
print("❌ File is empty")
return False
print("✨ Verification Successful!")
return True
except Exception as e:
print(f"❌ Verification Failed: {e}")
return False
finally:
if os.path.exists(output_path):
os.remove(output_path)
if __name__ == "__main__":
verify()