feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync
@@ -0,0 +1,55 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
remotion/node_modules/
|
||||
render-service/node_modules/
|
||||
dashboard/node_modules/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Video files (will be mounted via volume)
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.mkv
|
||||
*.gif
|
||||
videos/
|
||||
|
||||
# Model files (downloaded at build time)
|
||||
*.pt
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Docs
|
||||
README.md
|
||||
*.md
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# AWS S3 (optional — for clip backup/gallery)
|
||||
AWS_ACCESS_KEY_ID=your_aws_access_key_here
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
|
||||
AWS_REGION=eu-west-3
|
||||
AWS_S3_BUCKET=your-bucket-name
|
||||
AWS_S3_PUBLIC_BUCKET=your-public-bucket-name
|
||||
|
||||
# YouTube cookies (optional — paste Netscape-format cookies to bypass bot detection)
|
||||
# YOUTUBE_COOKIES=...
|
||||
@@ -0,0 +1,42 @@
|
||||
# Video files
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mov
|
||||
*.mkv
|
||||
|
||||
# YOLO model
|
||||
*.pt
|
||||
|
||||
# Python virtual environment
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Temporary files / runtime dirs
|
||||
temp_*
|
||||
uploads/
|
||||
downloads/
|
||||
videos/
|
||||
output/
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Generated metadata
|
||||
*_metadata.json
|
||||
|
||||
# Cache dirs
|
||||
.cache/
|
||||
.config/
|
||||
# Multi-agent Skills
|
||||
.agents/
|
||||
.agent/
|
||||
.claude/
|
||||
skills/
|
||||
skills-lock.json
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
OpenShorts is an AI-powered vertical video generator that transforms long YouTube videos or local uploads into viral-ready short clips (9:16 format) for TikTok, Instagram Reels, and YouTube Shorts. Uses Google Gemini 2.0 Flash for viral moment detection and title generation.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Local Development (Docker)
|
||||
```bash
|
||||
docker compose up --build # Build and run full stack
|
||||
```
|
||||
- Backend: http://localhost:8000 (FastAPI/Uvicorn)
|
||||
- Frontend: http://localhost:5175 (Vite proxies API calls to backend)
|
||||
|
||||
### Frontend Only (Dashboard)
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run dev # Dev server with HMR (port 5173)
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint (strict, --max-warnings 0)
|
||||
```
|
||||
|
||||
### Backend Only
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Processing Pipeline
|
||||
1. **Ingest** - YouTube download (yt-dlp) or local upload
|
||||
2. **Transcription** - faster-whisper with word-level timestamps
|
||||
3. **Scene Detection** - PySceneDetect for segment boundaries
|
||||
4. **AI Analysis** - Gemini identifies 3-15 viral moments (15-60 sec each)
|
||||
5. **FFmpeg Extraction** - Precise clip cutting
|
||||
6. **AI Cropping** - Vertical reframing with subject tracking
|
||||
7. **Effects/Subtitles** - Optional AI-generated FFmpeg filters
|
||||
8. **Hook Overlay** - Text overlays with styled fonts
|
||||
9. **Voice Dubbing** - Optional ElevenLabs AI translation (30+ languages)
|
||||
10. **S3 Backup** - Silent background upload
|
||||
11. **Social Distribution** - Upload-Post API (async upload)
|
||||
|
||||
### Key Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.py` | Core video processing: transcription, scene detection, clip extraction, vertical reframing |
|
||||
| `app.py` | FastAPI server with async job queue and REST endpoints |
|
||||
| `editor.py` | Gemini AI integration for dynamic video effects (FFmpeg filter generation) |
|
||||
| `hooks.py` | Hook text overlay generation with font rendering |
|
||||
| `s3_uploader.py` | AWS S3 upload with caching |
|
||||
| `subtitles.py` | SRT generation, FFmpeg subtitle burning, and dubbed video transcription |
|
||||
| `translate.py` | ElevenLabs dubbing API for AI voice translation |
|
||||
| `dashboard/src/App.jsx` | Main React component with state management |
|
||||
| `dashboard/src/components/TranslateModal.jsx` | Voice dubbing UI with language selection |
|
||||
|
||||
### Dual-Mode Video Reframing
|
||||
- **TRACK Mode** (single subject): MediaPipe face detection + YOLOv8 fallback with "Heavy Tripod" stabilization
|
||||
- **GENERAL Mode** (groups/landscapes): Blurred background layout preserving full width
|
||||
|
||||
### Key Classes
|
||||
- `SmoothedCameraman` - Stabilized camera movement with safe zone logic (prevents jitter)
|
||||
- `SpeakerTracker` - Prevents rapid speaker switching, handles temporary occlusions
|
||||
|
||||
### API Endpoints
|
||||
| Method | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| POST | `/api/process` | Submit video for processing |
|
||||
| GET | `/api/status/{job_id}` | Poll job status and logs |
|
||||
| POST | `/api/edit` | Apply AI video effects |
|
||||
| POST | `/api/subtitle` | Generate and apply subtitles (auto-transcribes dubbed videos) |
|
||||
| POST | `/api/hook` | Add text hook overlays |
|
||||
| POST | `/api/translate` | AI voice dubbing via ElevenLabs |
|
||||
| GET | `/api/translate/languages` | List supported dubbing languages |
|
||||
| POST | `/api/social/post` | Post to social media (async upload) |
|
||||
|
||||
### Concurrency Model
|
||||
Async job queue with semaphore-based concurrency control. Configure via `MAX_CONCURRENT_JOBS` env var (default: 5). Jobs auto-cleanup after 1 hour.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**Server-side (.env):**
|
||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_S3_BUCKET` - For S3 backup
|
||||
- `MAX_CONCURRENT_JOBS` - Concurrent processing limit (default: 5)
|
||||
- `VITE_API_URL` - Production API URL override
|
||||
|
||||
**Client-side (localStorage, encrypted):**
|
||||
- `GEMINI_API_KEY` - Google Gemini API key (required)
|
||||
- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice dubbing (optional)
|
||||
- `UPLOAD_POST_API_KEY` - Upload-Post API key for social posting (optional)
|
||||
|
||||
> API keys are stored encrypted in the browser and sent via headers only when needed. Never stored server-side.
|
||||
|
||||
## Tech Stack
|
||||
- **Backend:** Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx
|
||||
- **Frontend:** React 18, Vite 4, Tailwind CSS 3.4
|
||||
- **External APIs:** Google Gemini, ElevenLabs Dubbing, Upload-Post
|
||||
- **Infrastructure:** Docker + Docker Compose, AWS S3
|
||||
@@ -0,0 +1,64 @@
|
||||
# Multi-stage build for smaller final image
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install Python dependencies
|
||||
# Copy and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Final stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install FFmpeg, OpenCV dependencies, and Node.js (for yt-dlp JS challenges)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy virtual env from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Always upgrade yt-dlp to latest (YouTube bot-detection changes frequently)
|
||||
RUN pip install --upgrade --no-cache-dir yt-dlp
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create a non-root user (Moved up)
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
|
||||
|
||||
# Create directories including Ultralytics cache config
|
||||
RUN mkdir -p /app/uploads /app/output /tmp/Ultralytics
|
||||
# Fix permissions: /app for code/uploads, /tmp/Ultralytics for AI cache
|
||||
RUN chown -R appuser:appuser /app /tmp/Ultralytics
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Pre-download YOLO model on build (now running as appuser)
|
||||
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"
|
||||
|
||||
# Expose FastAPI port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run FastAPI app
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 OpenShorts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,297 @@
|
||||
# OpenShorts.app
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://opensource.org/)
|
||||
[](http://makeapullrequest.com)
|
||||
[](https://docs.docker.com/compose/)
|
||||
[](https://github.com/mutonby/openshorts)
|
||||
[](https://github.com/mutonby/openshorts/commits/main)
|
||||
|
||||
**Free & open source AI video platform** with 3 tools in one: **Clip Generator**, **AI Shorts (UGC videos with AI actors)**, and **YouTube Studio**. Self-hosted with Docker. No watermarks, no limits.
|
||||
|
||||
https://github.com/user-attachments/assets/b45fa983-16b4-48b5-ac5b-a267836b9ad9
|
||||
|
||||
|
||||
|
||||
### Video Tutorial: How it works
|
||||
[](https://www.youtube.com/watch?v=xlyjD1qCaX0 "Click to watch the video on YouTube")
|
||||
|
||||
*Click the image above to watch the full walkthrough.*
|
||||
|
||||
---
|
||||
|
||||
## 3 Tools in 1 Platform
|
||||
|
||||
### 1. Clip Generator
|
||||
Turn your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready 9:16 shorts for TikTok, Instagram Reels, and YouTube Shorts.
|
||||
|
||||

|
||||
|
||||
### 2. AI Shorts (UGC Video Creator)
|
||||
Generate marketing videos with AI actors for **any product or business**. No camera, no studio, no influencer budget. Just describe your product or paste a URL.
|
||||
|
||||

|
||||
|
||||
- **Two cost modes**: Low Cost (~$0.65/video) and Premium (~$2/video)
|
||||
- Works for any business: SaaS, restaurants, e-commerce, coaching, local businesses
|
||||
- AI-generated actors with lip-sync, voiceover, b-roll, and TikTok-style subtitles
|
||||
- Choose from a shared avatar gallery or upload your own photo
|
||||
- Publish directly to TikTok, Instagram, and YouTube
|
||||
|
||||
### 3. YouTube Studio
|
||||
Complete free AI YouTube toolkit: thumbnails, titles, descriptions, and direct publishing.
|
||||
|
||||

|
||||
|
||||
- AI thumbnail generator with face overlay
|
||||
- 10 viral title suggestions with refinement chat
|
||||
- Auto-generated descriptions with chapter timestamps
|
||||
- One-click publish to YouTube
|
||||
|
||||
### UGC Video Gallery
|
||||
All generated videos and avatars are saved to a public gallery with SEO pages for each video.
|
||||
|
||||

|
||||
|
||||
- Public gallery page with hover-to-play (`/gallery`)
|
||||
- Individual SEO video pages with og:video meta tags (`/video/{id}`)
|
||||
- JSON-LD structured data for search engines
|
||||
- Avatar gallery with prompt history
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Clip Generator
|
||||
- **Viral Moment Detection**: Google Gemini 3.0 Flash analyzes transcripts and scene boundaries to detect 3-15 high-potential moments
|
||||
- **Smart 9:16 Cropping**: Dual-mode AI reframing — TRACK mode (MediaPipe + YOLOv8 face tracking) and GENERAL mode (blurred background)
|
||||
- **Auto Subtitles**: faster-whisper with word-level timestamps, styled and burned into clips
|
||||
- **AI Voice Dubbing**: ElevenLabs integration for 30+ languages with voice cloning
|
||||
- **Hook Text Overlays**: AI-generated attention-grabbing text overlays
|
||||
- **AI Video Effects**: Gemini-generated FFmpeg filters for professional effects
|
||||
|
||||
### AI Shorts Pipeline
|
||||
1. **Analyze**: Scrape website URL + web research, or generate from manual description
|
||||
2. **Script**: AI writes viral scripts (hook - problem - solution - CTA format)
|
||||
3. **Actor**: Generate AI actors with Flux 2 Pro or select from shared gallery
|
||||
4. **Voice**: ElevenLabs TTS voiceover (English/Spanish, male/female)
|
||||
5. **Video**: Talking head generation (Hailuo 2.3 Fast img2video + VEED Lipsync)
|
||||
6. **B-roll**: AI-generated visuals with Ken Burns effect
|
||||
7. **Composite**: FFmpeg final assembly with subtitles and hook overlays
|
||||
8. **Publish**: Direct posting to TikTok, Instagram Reels, YouTube Shorts via Upload-Post
|
||||
|
||||
### YouTube Studio
|
||||
- AI-powered title generation with 10 viral options
|
||||
- Interactive refinement chat for titles
|
||||
- AI thumbnail generation with custom face + background
|
||||
- Auto descriptions with chapter timestamps from Whisper transcript
|
||||
- Direct YouTube publishing via Upload-Post
|
||||
|
||||
### Social Auto-Publishing
|
||||
- **One-click posting** to TikTok, Instagram Reels, and YouTube Shorts simultaneously
|
||||
- **Schedule uploads** for any date and time — plan your content calendar and let OpenShorts publish automatically
|
||||
- **Multi-platform distribution** — publish to all your social networks at once from a single interface
|
||||
- Upload-Post integration with async uploads
|
||||
|
||||
### Infrastructure
|
||||
- S3 cloud backup (private bucket for clips, public bucket for gallery/avatars)
|
||||
- SEO gallery pages served by FastAPI with JSON-LD structured data
|
||||
- Shared avatar gallery across all users
|
||||
- Async job queue with configurable concurrency
|
||||
|
||||
---
|
||||
|
||||
## Who Is This For?
|
||||
|
||||
- **Content creators** — Turn long videos into shorts automatically, publish to all platforms at once
|
||||
- **Marketing agencies** — Generate UGC videos for clients at scale, no actors or studios needed
|
||||
- **SaaS founders** — Create product demos and marketing shorts from just a URL
|
||||
- **E-commerce brands** — Product videos with AI actors for TikTok Shop, Instagram, YouTube
|
||||
- **Local businesses** — Restaurants, gyms, real estate, coaching — affordable video marketing
|
||||
- **Developers** — Self-host, customize the pipeline, integrate via API
|
||||
|
||||
---
|
||||
|
||||
## AI Shorts Showcase
|
||||
|
||||
Videos generated with OpenShorts AI Shorts — no camera, no studio, no actors:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
| [](https://openshorts.app/video/cdceec1b) | [](https://openshorts.app/video/d3a80b6b) | [](https://openshorts.app/video/8ab7de92) |
|
||||
| **Biohacking for Investors** · LOW COST | **Secret Weapon for Devs** · LOW COST | **El Secreto de los Agentes de IA** · PREMIUM |
|
||||
|
||||
> Browse all videos at [openshorts.app/gallery](https://openshorts.app/gallery)
|
||||
|
||||
---
|
||||
|
||||
## OpenShorts vs Competitors
|
||||
|
||||
| Feature | OpenShorts | Opus Clip | CapCut | Vizard | Klap | Descript |
|
||||
|---------|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **Price** | **Free** | $15-29/mo | $8/mo | $15-20/mo | $23-63/mo | $24-65/mo |
|
||||
| **Self-hosted** | **Yes** | No | No | No | No | No |
|
||||
| **Open source** | **Yes** | No | No | No | No | No |
|
||||
| **Watermark** | **Never** | Free tier | Some | Free tier | Free tier | Free tier |
|
||||
| **Upload limits** | **None** | 10-30GB | Credit-based | 60min-10hr | 10-100 vids/mo | 60min-40hr |
|
||||
| **AI clip detection** | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| **Smart 9:16 reframing** | Yes | Yes | Yes | Yes | Yes | No |
|
||||
| **Auto subtitles** | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| **Voice dubbing (30+ langs)** | Yes | No | Pro only | No | Pro only | Business only |
|
||||
| **AI UGC actors** | **Yes** | No | No | No | No | No |
|
||||
| **AI video effects** | Yes | No | Yes | No | No | No |
|
||||
| **Hook text overlays** | Yes | No | No | No | No | No |
|
||||
| **YouTube Studio (titles, thumbnails)** | **Yes** | No | No | No | No | No |
|
||||
| **Social auto-publishing** | Yes | Pro only | TikTok only | Paid only | Paid only | No |
|
||||
| **Schedule uploads** | Yes | Pro only | No | Paid only | Paid only | No |
|
||||
| **Data privacy** | **Your server** | Their cloud | Their cloud | Their cloud | Their cloud | Their cloud |
|
||||
|
||||
---
|
||||
|
||||
## How Much Does It Cost?
|
||||
|
||||
OpenShorts is free. You only pay for the AI APIs you use — and most have generous free tiers:
|
||||
|
||||
| Service | Free Tier | Paid Cost | Used For |
|
||||
|---------|-----------|-----------|----------|
|
||||
| **Google Gemini** | Free trial with generous limits | < $0.01 per 10-min video | Viral moment detection, script generation, web research |
|
||||
| **fal.ai** | Pay-per-use | ~$0.50-1.50 per AI Short | Actor generation, talking head video, lip-sync |
|
||||
| **ElevenLabs** | Free tier available | Pay-per-use | Voiceover, voice dubbing |
|
||||
| **Upload-Post** | **10 free uploads/month** to all networks (no credit card) | Pay-per-use | Auto-publishing to TikTok, Instagram, YouTube |
|
||||
| **AWS S3** | Optional | ~$0.023/GB | Cloud backup for clips and gallery |
|
||||
|
||||
**Bottom line:** You can clip videos for practically free with Gemini, and publish 10 videos/month to all social networks at zero cost with Upload-Post.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker & Docker Compose**
|
||||
- **Google Gemini API Key** ([Free — get it here](https://aistudio.google.com/app/apikey)) — required for all AI features
|
||||
- **fal.ai API Key** ([Pay-per-use](https://fal.ai)) — required for AI Shorts (actor generation, video, lip-sync)
|
||||
- **ElevenLabs API Key** ([Free tier](https://elevenlabs.io)) — required for voiceover/dubbing
|
||||
- **Upload-Post API Key** ([free tier](https://upload-post.com)) — required for direct social posting
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Clone
|
||||
```bash
|
||||
git clone https://github.com/your-username/OpenShorts.git
|
||||
cd OpenShorts
|
||||
```
|
||||
|
||||
### 2. Configure (optional)
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your AWS keys for S3 backup
|
||||
```
|
||||
|
||||
### 3. Launch
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### 4. Open Dashboard
|
||||
Navigate to **`http://localhost:5175`**
|
||||
|
||||
1. Go to **Settings** and enter your API keys (Gemini, fal.ai, ElevenLabs, Upload-Post)
|
||||
2. **Clip Generator**: Upload a long-form video to generate viral shorts
|
||||
3. **AI Shorts**: Describe your product or paste a URL to generate UGC marketing videos
|
||||
4. **YouTube Studio**: Generate thumbnails, titles, and descriptions for YouTube
|
||||
5. **UGC Gallery**: Browse all generated videos and avatars
|
||||
|
||||
---
|
||||
|
||||
## Technical Pipeline
|
||||
|
||||
### Clip Generator
|
||||
1. **Ingest** — Local video upload (or self-hosted URL ingest via yt-dlp)
|
||||
2. **Transcribe** — faster-whisper with word-level timestamps
|
||||
3. **Detect** — PySceneDetect for scene boundaries
|
||||
4. **Analyze** — Gemini identifies 3-15 viral moments (15-60s each)
|
||||
5. **Extract** — FFmpeg precise clip cutting
|
||||
6. **Reframe** — AI vertical cropping with subject tracking
|
||||
7. **Effects** — Subtitles, hooks, AI video effects
|
||||
8. **Publish** — S3 backup + Upload-Post social distribution
|
||||
|
||||
### AI Shorts
|
||||
1. **Analyze** — Website scraping + Gemini web research (or manual description)
|
||||
2. **Script** — Gemini generates viral scripts with segments
|
||||
3. **Actor** — Flux 2 Pro portrait generation (or gallery/upload)
|
||||
4. **Voice** — ElevenLabs TTS voiceover
|
||||
5. **Video** — Hailuo 2.3 Fast img2video + VEED Lipsync (Low Cost) or Kling Avatar v2 (Premium)
|
||||
6. **B-roll** — Flux 2 Pro image generation + Ken Burns effect
|
||||
7. **Composite** — FFmpeg assembly with ASS subtitles and hook overlays
|
||||
8. **Gallery** — Upload to public S3 with metadata for SEO pages
|
||||
9. **Publish** — Upload-Post to TikTok, Instagram, YouTube
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx |
|
||||
| Frontend | React 18, Vite 4, Tailwind CSS 3.4 |
|
||||
| AI APIs | Google Gemini, fal.ai (Flux, Hailuo, VEED, Kling), ElevenLabs |
|
||||
| Infrastructure | Docker + Docker Compose, AWS S3 |
|
||||
| Publishing | Upload-Post API (TikTok, Instagram, YouTube) |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**Server-side (.env):**
|
||||
| Variable | Description |
|
||||
|----------|------------|
|
||||
| `AWS_ACCESS_KEY_ID` | AWS access key for S3 |
|
||||
| `AWS_SECRET_ACCESS_KEY` | AWS secret key |
|
||||
| `AWS_REGION` | AWS region (default: us-east-1) |
|
||||
| `AWS_S3_BUCKET` | Private bucket for clip backup |
|
||||
| `AWS_S3_PUBLIC_BUCKET` | Public bucket for gallery/avatars |
|
||||
| `MAX_CONCURRENT_JOBS` | Concurrent processing limit (default: 5) |
|
||||
|
||||
**Client-side (encrypted in localStorage):**
|
||||
| Key | Description |
|
||||
|-----|------------|
|
||||
| `GEMINI_API_KEY` | Google Gemini — required |
|
||||
| `FAL_KEY` | fal.ai — required for AI Shorts |
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs — required for voiceover/dubbing |
|
||||
| `UPLOAD_POST_API_KEY` | Upload-Post — required, for social posting |
|
||||
|
||||
---
|
||||
|
||||
## Security & Performance
|
||||
|
||||
- **Non-Root Execution**: Containers run as dedicated `appuser`
|
||||
- **Concurrency Control**: Semaphore-based job queue (`MAX_CONCURRENT_JOBS`)
|
||||
- **Auto-Cleanup**: Automatic purging of old jobs (1h retention)
|
||||
- **Encrypted Keys**: API keys encrypted client-side, never stored server-side
|
||||
- **Upload Validation**: Image uploads validated for format and minimum size
|
||||
- **File Limits**: 2GB upload limit protection
|
||||
|
||||
---
|
||||
|
||||
## Social Media Setup (Upload-Post)
|
||||
|
||||
1. **Register**: [app.upload-post.com/login](https://app.upload-post.com/login)
|
||||
2. **Create Profile**: Go to [Manage Users](https://app.upload-post.com/manage-users)
|
||||
3. **Connect Accounts**: Link TikTok, Instagram, and/or YouTube
|
||||
4. **Get API Key**: Navigate to [API Keys](https://app.upload-post.com/api-keys)
|
||||
5. **Use in OpenShorts**: Paste the key in Settings
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#mutonby/openshorts&Date)
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are welcome! Whether it's adding new AI models, improving the lip-sync pipeline, or building new features — feel free to open a PR.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. OpenShorts is yours to use, modify, and scale.
|
||||
|
After Width: | Height: | Size: 62 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.config
|
||||
.config/*
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,194 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo-openshorts.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary SEO Meta Tags -->
|
||||
<title>OpenShorts - Free Open Source Clip Generator & AI UGC Video Creator | TikTok, Reels & Shorts</title>
|
||||
<meta name="description" content="Free open source clip generator & AI UGC video creator. Turn your long-form videos into viral shorts or generate marketing videos with AI actors for any business. From $0.65/video." />
|
||||
<meta name="keywords"
|
||||
content="free clip generator, open source clip generator, AI clip generator, free video clip generator, AI video clipping tool, AI short video generator, vertical video maker, open source video editor, YouTube Shorts maker, Instagram Reels creator, video repurposing tool, AI viral moment detection, auto subtitle generator, AI voice dubbing, Opus Clip alternative free, free YouTube thumbnail generator, AI YouTube title generator, free YouTube description generator" />
|
||||
<meta name="author" content="OpenShorts" />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||
<link rel="canonical" href="https://openshorts.app" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://openshorts.app" />
|
||||
<meta property="og:title"
|
||||
content="OpenShorts - Free Clip Generator & AI UGC Video Creator" />
|
||||
<meta property="og:description"
|
||||
content="3 free tools in 1: clip generator, AI UGC video creator with AI actors, and YouTube Studio. Turn videos into viral shorts or generate marketing videos for any business. Open source, self-hosted." />
|
||||
<meta property="og:image" content="https://openshorts.app/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:site_name" content="OpenShorts" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- Twitter Cards -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://openshorts.app" />
|
||||
<meta name="twitter:title"
|
||||
content="OpenShorts - Free Clip Generator & AI UGC Video Creator" />
|
||||
<meta name="twitter:description"
|
||||
content="3 free tools in 1: clip generator, AI UGC video creator with AI actors, and YouTube Studio. Open source, self-hosted, no watermarks." />
|
||||
<meta name="twitter:image" content="https://openshorts.app/og-image.png" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"name": "OpenShorts - Free Open Source Clip Generator",
|
||||
"description": "Free open source clip generator that turns your long-form videos into viral TikTok, Instagram Reels & YouTube Shorts automatically with AI-powered viral moment detection, smart cropping, and a free AI YouTube thumbnail studio.",
|
||||
"url": "https://openshorts.app",
|
||||
"datePublished": "2024-06-01",
|
||||
"dateModified": "2026-03-20",
|
||||
"inLanguage": "en-US",
|
||||
"speakable": {
|
||||
"@type": "SpeakableSpecification",
|
||||
"cssSelector": ["h1", ".hero-description", ".faq-answer"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "OpenShorts",
|
||||
"alternateName": "OpenShorts Free Clip Generator",
|
||||
"description": "Free open source AI clip generator. Transforms your long-form videos into viral-ready short clips for TikTok, Instagram Reels, and YouTube Shorts using Google Gemini AI. Includes free AI YouTube thumbnail generator, title suggestions, description writer, and direct YouTube publishing.",
|
||||
"applicationCategory": "MultimediaApplication",
|
||||
"operatingSystem": "Cross-platform (Docker)",
|
||||
"url": "https://openshorts.app",
|
||||
"downloadUrl": "https://github.com/mutonby/openshorts",
|
||||
"softwareVersion": "2.0",
|
||||
"screenshot": "https://openshorts.app/og-image.png",
|
||||
"featureList": [
|
||||
"Free AI clip generator with viral moment detection",
|
||||
"Open source clip generator — self-hosted with Docker",
|
||||
"AI viral moment detection with Google Gemini 3.0 Flash",
|
||||
"Smart 9:16 vertical cropping with face tracking",
|
||||
"Automatic subtitle generation with word-level timestamps",
|
||||
"AI voice dubbing in 30+ languages via ElevenLabs",
|
||||
"Local video file upload support",
|
||||
"Hook text overlays with styled fonts",
|
||||
"AI-generated video effects via FFmpeg",
|
||||
"Direct social media posting",
|
||||
"Free AI YouTube thumbnail generator",
|
||||
"Free AI YouTube title generator with 10 viral suggestions",
|
||||
"Free AI YouTube description generator with chapter timestamps",
|
||||
"YouTube Studio: thumbnails, titles, descriptions & direct publishing — all free",
|
||||
"AI UGC video creator with AI actors and lip-sync",
|
||||
"Generate marketing videos for any business from $0.65",
|
||||
"Shared AI avatar gallery",
|
||||
"Public video gallery with SEO pages",
|
||||
"S3 cloud backup",
|
||||
"Self-hosted Docker deployment"
|
||||
],
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD",
|
||||
"availability": "https://schema.org/InStock"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "OpenShorts",
|
||||
"url": "https://openshorts.app"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is OpenShorts?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "OpenShorts is a free, open source AI clip generator that automatically transforms your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready short clips (9:16 vertical format) for TikTok, Instagram Reels, and YouTube Shorts. It uses Google Gemini 3.0 Flash AI to detect the most engaging viral moments, then applies smart cropping with MediaPipe face tracking, automatic subtitles via faster-whisper, and optional AI voice dubbing in over 30 languages via ElevenLabs. According to HubSpot's 2025 State of Marketing report, short-form video delivers the highest ROI of any content format."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How does OpenShorts detect viral moments in videos?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "OpenShorts uses a multi-step AI pipeline: first, it transcribes the video with word-level timestamps using faster-whisper. Then, PySceneDetect identifies scene boundaries. Finally, Google Gemini 3.0 Flash AI analyzes the transcript and scene data to identify the 3 to 15 most engaging moments (15-60 seconds each), scoring them for viral potential based on emotional impact, hook strength, and shareability."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is OpenShorts really free? What's the catch?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "OpenShorts is 100% free and open source. You self-host it using Docker on your own machine or server. It uses three external APIs — all with free tiers. Google Gemini API (required) powers AI analysis, viral moment detection, and thumbnail generation — free tier includes 1,500 requests per day. ElevenLabs API (optional) enables AI voice dubbing in 30+ languages — free tier included. Upload-Post API (optional) allows direct publishing to YouTube, TikTok, and Instagram — also free tier. There are no watermarks, no usage limits, and no subscription fees."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How does the AI vertical cropping work?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "OpenShorts offers two intelligent cropping modes. TRACK mode uses MediaPipe face detection with YOLOv8 fallback to follow a single subject with stabilized 'Heavy Tripod' camera movement, preventing jitter. GENERAL mode handles group shots and landscapes by creating a blurred background layout that preserves the full width. A SpeakerTracker prevents rapid switching between subjects for smooth, professional results."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is there a free alternative to Opus Clip?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. OpenShorts is a free, open source alternative to Opus Clip that you can self-host. Unlike Opus Clip's $15-228/month pricing, OpenShorts is completely free with no watermarks or usage limits. It offers comparable AI viral moment detection powered by Google Gemini, smart vertical cropping with face tracking, automatic subtitles, and AI voice dubbing — all running on your own infrastructure with full data privacy."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can OpenShorts add subtitles and translate videos?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. OpenShorts automatically generates subtitles using faster-whisper with word-level timestamps and burns them into the video using FFmpeg. For translation, it integrates with ElevenLabs AI dubbing to translate and dub video audio into over 30 languages, preserving the original speaker's voice characteristics. The dubbed video is automatically re-transcribed and subtitled in the new language."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can OpenShorts generate YouTube thumbnails and titles for free?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. OpenShorts includes a free AI YouTube thumbnail generator, a free AI YouTube title generator, and a free AI YouTube description generator — all powered by Google Gemini 3.0 Flash. Upload your video and the AI suggests 10 viral title options with an interactive refinement chat. Then it generates multiple thumbnail designs using AI image generation — upload a face photo and background for personalized results. The studio also auto-generates YouTube descriptions with chapter timestamps and lets you publish directly to YouTube. Everything is 100% free with the Gemini free tier."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is there a free open source clip generator?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes — OpenShorts is a 100% free, open source clip generator. Unlike paid clip generators like Opus Clip ($15-228/month) or Kapwing ($24-79/month), OpenShorts lets you generate unlimited clips with no watermarks, no usage limits, and no subscription fees. It also includes a free AI YouTube thumbnail generator, free AI YouTube title generator, and free AI YouTube description generator — features that other clip generators charge extra for. You self-host it with Docker on your own machine for full privacy and control."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"name": "OpenShorts",
|
||||
"url": "https://openshorts.app",
|
||||
"logo": "https://openshorts.app/logo-openshorts.png",
|
||||
"sameAs": [
|
||||
"https://github.com/mutonby/openshorts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Analytics -->
|
||||
<script defer data-domain="openshorts.app" src="https://analytics.fotoexamen.com/js/script.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "openshorts-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/media": "^4.0.447",
|
||||
"@remotion/media-utils": "^4.0.447",
|
||||
"@remotion/player": "^4.0.447",
|
||||
"@remotion/web-renderer": "^4.0.447",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"remotion": "^4.0.447",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^4.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,33 @@
|
||||
# robots.txt for OpenShorts
|
||||
# Allow all search engines and AI bots to crawl the site
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Explicitly allow AI search engine bots
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://openshorts.app/sitemap.xml
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://openshorts.app/</loc>
|
||||
<lastmod>2026-03-07</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Zap, Globe, FileVideo, Subtitles, Youtube, Instagram, Shield, Github, ArrowRight, Play, Check, ChevronDown, Monitor, Cpu, Languages, Type, Upload, Scissors } from 'lucide-react';
|
||||
|
||||
const TikTokIcon = ({ size = 16, className = "" }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M19.589 6.686a4.793 4.793 0 0 1-3.77-4.245V2h-3.445v13.672a2.896 2.896 0 0 1-5.201 1.743l-.002-.001.002.001a2.895 2.895 0 0 1 3.183-4.51v-3.5a6.329 6.329 0 0 0-5.394 10.692 6.33 6.33 0 0 0 10.857-4.424V8.687a8.182 8.182 0 0 0 4.773 1.526V6.79a4.831 4.831 0 0 1-1.003-.104z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FeatureCard = ({ icon: Icon, title, description }) => (
|
||||
<div className="group bg-surface/50 backdrop-blur-xl border border-white/10 rounded-2xl p-6 hover:border-primary/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<Icon size={24} className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StepCard = ({ number, title, description }) => (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 border border-primary/30 flex items-center justify-center text-primary font-bold text-sm">
|
||||
{number}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-1">{title}</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ComparisonRow = ({ feature, openshorts, opusclip, kapwing }) => (
|
||||
<tr className="border-b border-white/5">
|
||||
<td className="py-3 px-4 text-sm text-zinc-300">{feature}</td>
|
||||
<td className="py-3 px-4 text-center">{openshorts}</td>
|
||||
<td className="py-3 px-4 text-center">{opusclip}</td>
|
||||
<td className="py-3 px-4 text-center">{kapwing}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const FAQItem = ({ question, answer, isOpen, onClick }) => (
|
||||
<div className="border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="text-white font-medium pr-4">{question}</span>
|
||||
<ChevronDown size={18} className={`text-zinc-400 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-6 pb-5">
|
||||
<p className="faq-answer text-zinc-400 text-sm leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Landing({ onLaunchApp }) {
|
||||
const [openFaq, setOpenFaq] = React.useState(null);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "AI Viral Moment Detection",
|
||||
description: "Google Gemini 3.0 Flash analyzes your video transcript and scene boundaries to detect the 3-15 most engaging moments. Each clip is scored for viral potential based on emotional impact, hook strength, and shareability — similar to how TikTok's algorithm ranks content for the For You page."
|
||||
},
|
||||
{
|
||||
icon: Scissors,
|
||||
title: "Smart 9:16 Vertical Cropping",
|
||||
description: "Dual-mode AI reframing: TRACK mode follows subjects with MediaPipe face detection + YOLOv8 fallback. GENERAL mode creates blurred backgrounds for group shots and landscapes."
|
||||
},
|
||||
{
|
||||
icon: Subtitles,
|
||||
title: "Automatic Subtitle Generation",
|
||||
description: "Powered by faster-whisper with word-level timestamps. According to Verizon Media research, 80% of viewers are more likely to watch a video to completion when captions are available. Subtitles are auto-generated, styled, and burned into your clips."
|
||||
},
|
||||
{
|
||||
icon: Languages,
|
||||
title: "AI Voice Dubbing in 30+ Languages",
|
||||
description: "ElevenLabs AI integration translates and dubs your video audio while preserving the original speaker's voice characteristics. According to CSA Research, 76% of consumers prefer content in their native language — dubbing unlocks global audiences."
|
||||
},
|
||||
{
|
||||
icon: Type,
|
||||
title: "Hook Text Overlays",
|
||||
description: "Add attention-grabbing text overlays with styled fonts. AI-generated hook titles capture viewers in the first 3 seconds — critical for TikTok and Reels engagement."
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "AI Video Effects",
|
||||
description: "Google Gemini generates dynamic FFmpeg filters for professional video effects — color grading, transitions, and visual enhancements applied automatically."
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
title: "Local Video Upload",
|
||||
description: "Upload your long-form videos — podcasts, webinars, livestreams, vlogs — at full original resolution and audio quality. Process content you own or have rights to."
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "100% Self-Hosted & Private",
|
||||
description: "Deploy with Docker on your own machine. Your videos never leave your infrastructure. API keys are encrypted client-side and never stored on the server."
|
||||
},
|
||||
{
|
||||
icon: Monitor,
|
||||
title: "Free AI YouTube Studio",
|
||||
description: "Free AI YouTube thumbnail generator, AI title suggestions (10 viral options with refinement chat), and auto-generated descriptions with chapter timestamps — all free. Upload a face photo for personalized thumbnails. Publish directly to YouTube from one workflow."
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: "Direct Social Publishing",
|
||||
description: "Post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard. Async uploads with progress tracking and S3 cloud backup."
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "AI UGC Video Generator",
|
||||
description: "Generate marketing videos with AI actors for any product or business. Paste a URL or describe your product — AI writes the script, generates a realistic avatar with lip-sync, adds b-roll, subtitles, and hook overlays. From $0.65/video."
|
||||
},
|
||||
{
|
||||
icon: FileVideo,
|
||||
title: "AI Actors & Lip-Sync",
|
||||
description: "Choose from a gallery of AI-generated actors or upload your own photo. The pipeline generates a talking head video with natural movement and lip-synced voiceover in English or Spanish. Two modes: Low Cost ($0.65) and Premium ($2.00)."
|
||||
}
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ title: "Upload a Long-Form Video", description: "Drop any video file you own — podcasts, webinars, livestreams, interviews. OpenShorts supports all common formats and resolutions." },
|
||||
{ title: "AI Detects the Best Viral Moments", description: "Google Gemini 3.0 Flash transcribes, analyzes scene boundaries, and identifies 3-15 high-potential clips of 15-60 seconds each." },
|
||||
{ title: "Smart Cropping to Vertical 9:16", description: "AI reframes each clip to vertical format with face tracking. Subjects stay centered with stabilized camera movement — no manual positioning." },
|
||||
{ title: "Add Subtitles, Hooks & Effects", description: "Auto-generate styled subtitles, add hook text overlays, and apply AI video effects. Optionally dub into 30+ languages." },
|
||||
{ title: "Download or Post to Social Media", description: "Export your viral-ready clips or post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard." }
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "What is OpenShorts and how does it work?",
|
||||
answer: "OpenShorts is a free, open source AI clip generator that transforms your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready short clips in 9:16 vertical format. It uses a multi-step AI pipeline: faster-whisper for transcription with word-level timestamps, PySceneDetect for scene boundary detection, and Google Gemini 3.0 Flash AI for identifying the most engaging viral moments. According to HubSpot's 2025 State of Marketing report, short-form video delivers the highest ROI of any content format, and repurposing long-form content into shorts increases total reach by up to 300%."
|
||||
},
|
||||
{
|
||||
question: "Is OpenShorts really free? What's the catch?",
|
||||
answer: "OpenShorts is 100% free and open source. You self-host it using Docker on your own machine or server. It uses three external APIs — all with free tiers. Google Gemini API (required) powers the AI analysis, viral moment detection, and thumbnail generation — its free tier includes 1,500 requests per day. ElevenLabs API (optional) enables AI voice dubbing in 30+ languages — free tier included. Upload-Post API (optional) is a social media API that allows direct publishing to YouTube, TikTok, and Instagram — 10 free uploads/month, no credit card required. There are no watermarks, no usage limits, no monthly subscriptions, and no per-video fees — unlike Opus Clip ($15-228/month) or Kapwing ($24-79/month)."
|
||||
},
|
||||
{
|
||||
question: "How does OpenShorts compare to Opus Clip?",
|
||||
answer: "OpenShorts is a free, self-hosted alternative to Opus Clip. Both offer AI viral moment detection and smart vertical cropping. Key differences: OpenShorts is completely free vs Opus Clip's $15-228/month pricing. OpenShorts runs on your infrastructure (full data privacy) vs cloud-only. OpenShorts uses Google Gemini 3.0 Flash for AI analysis vs Opus Clip's proprietary model. OpenShorts adds AI voice dubbing in 30+ languages, AI-generated video effects, and hook text overlays. The trade-off is that OpenShorts requires Docker self-hosting, while Opus Clip is a ready-to-use cloud service."
|
||||
},
|
||||
{
|
||||
question: "How do I turn a long-form video into TikTok or Reels clips?",
|
||||
answer: "Upload your long-form video into OpenShorts, enter your free Gemini API key, and click Process. The AI transcribes it with faster-whisper, detects the best viral moments using Google Gemini 3.0 Flash, and crops them to 9:16 vertical format with MediaPipe face tracking. According to Wyzowl's 2025 Video Marketing Statistics report, 91% of businesses use video as a marketing tool, and repurposed short-form clips drive 2.5x more engagement than original content."
|
||||
},
|
||||
{
|
||||
question: "What AI does OpenShorts use for viral moment detection?",
|
||||
answer: "OpenShorts uses Google Gemini 3.0 Flash, Google's latest multimodal AI model, for viral moment detection and title generation. The AI receives the full video transcript with timestamps, scene boundary data from PySceneDetect, and analyzes engagement patterns to identify the 3-15 most shareable moments. Each clip is scored based on emotional impact, hook strength, and viral potential — similar to how platforms like TikTok and YouTube rank content."
|
||||
},
|
||||
{
|
||||
question: "Can OpenShorts translate and dub videos into other languages?",
|
||||
answer: "Yes. OpenShorts integrates with ElevenLabs AI dubbing to translate your video audio into over 30 languages while preserving the original speaker's voice characteristics. After dubbing, the system automatically re-transcribes the new audio and generates subtitles in the target language. This makes it easy to repurpose content for global audiences — studies show that dubbed content receives 2-3x more engagement in non-English markets."
|
||||
},
|
||||
{
|
||||
question: "How does the smart vertical cropping work?",
|
||||
answer: "OpenShorts offers two intelligent cropping modes for converting 16:9 horizontal video to 9:16 vertical format. TRACK mode uses MediaPipe face detection with YOLOv8 as fallback to follow a single subject with 'Heavy Tripod' stabilization — the camera moves smoothly like a professional cameraman. GENERAL mode handles group shots and landscapes by creating a blurred background layout. A SpeakerTracker prevents rapid switching between subjects and handles temporary occlusions for smooth results."
|
||||
},
|
||||
{
|
||||
question: "Can OpenShorts generate YouTube thumbnails and titles for free?",
|
||||
answer: "Yes. OpenShorts includes a free AI YouTube thumbnail generator, a free AI YouTube title generator, and a free AI YouTube description generator — all powered by Google Gemini 3.0 Flash. Upload your video and the AI suggests 10 viral title options with an interactive refinement chat. Then it generates multiple thumbnail designs using AI image generation — upload a face photo and background image for personalized results. The studio also auto-generates YouTube descriptions with chapter timestamps and lets you publish directly to YouTube. Everything is 100% free with the Gemini free tier."
|
||||
},
|
||||
{
|
||||
question: "What are the system requirements to run OpenShorts?",
|
||||
answer: "OpenShorts runs on any system with Docker installed. The recommended setup is 8GB+ RAM and a modern multi-core CPU. GPU acceleration (NVIDIA CUDA) is optional but speeds up video processing significantly. The Docker Compose setup handles all dependencies automatically — Python 3.11, FFmpeg, YOLOv8, MediaPipe, faster-whisper, and the React dashboard. It works on Linux, macOS, and Windows (via WSL2/Docker Desktop)."
|
||||
},
|
||||
{
|
||||
question: "Is there a free open source clip generator?",
|
||||
answer: "Yes — OpenShorts is a 100% free, open source clip generator. Unlike paid clip generators like Opus Clip ($15-228/month) or Kapwing ($24-79/month), OpenShorts lets you generate unlimited clips with no watermarks, no usage limits, and no subscription fees. It also includes a free AI YouTube thumbnail generator, free AI YouTube title generator, and free AI YouTube description generator — features that other clip generators charge extra for. You self-host it with Docker on your own machine for full privacy and control."
|
||||
},
|
||||
{
|
||||
question: "What is the AI UGC Video Generator?",
|
||||
answer: "OpenShorts includes an AI UGC (User Generated Content) video creator that generates marketing videos with AI actors for any product or business. You describe your product or paste a website URL — the AI writes a viral script, generates a realistic AI actor with lip-synced voiceover, adds b-roll visuals, TikTok-style subtitles, and hook text overlays. The result is a ready-to-post vertical video for TikTok, Instagram Reels, or YouTube Shorts. Two cost modes: Low Cost (~$0.65/video using Hailuo + VEED Lipsync) and Premium (~$2/video using Kling Avatar v2)."
|
||||
},
|
||||
{
|
||||
question: "How much does it cost to generate an AI UGC video?",
|
||||
answer: "OpenShorts itself is free, but the AI Shorts feature uses external APIs (fal.ai for video generation, ElevenLabs for voiceover) that charge per use. Low Cost mode costs approximately $0.65 per video (Flux image $0.05 + ElevenLabs voice $0.10 + Hailuo img2video $0.19 + VEED Lipsync $0.20 + b-roll $0.10). Premium mode costs approximately $2.00 per video using Kling Avatar v2 for higher quality. Both modes are significantly cheaper than hiring UGC creators ($50-500 per video) or using platforms like HeyGen ($24-180/month)."
|
||||
},
|
||||
{
|
||||
question: "Can I use the AI UGC Video Generator for any type of business?",
|
||||
answer: "Yes. The AI Shorts generator works for any product, service, or business — not just SaaS. You can use it for restaurants, e-commerce stores, coaching services, local businesses, personal brands, apps, and more. Just describe your business in the text field (e.g. 'Artisan pizza restaurant in Madrid, wood-fired oven, home delivery') or paste your website URL, and the AI generates viral marketing scripts tailored to your business."
|
||||
}
|
||||
];
|
||||
|
||||
const checkIcon = <Check size={16} className="text-green-400 mx-auto" />;
|
||||
const xIcon = <span className="text-zinc-500 text-sm">Paid</span>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-background/80 backdrop-blur-xl border-b border-white/5">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/logo-openshorts.png" alt="OpenShorts logo" className="w-8 h-8" />
|
||||
<span className="text-lg font-bold">OpenShorts</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm text-zinc-400">
|
||||
<a href="#features" className="hover:text-white transition-colors">Features</a>
|
||||
<a href="#how-it-works" className="hover:text-white transition-colors">How It Works</a>
|
||||
<a href="#comparison" className="hover:text-white transition-colors">Comparison</a>
|
||||
<a href="#faq" className="hover:text-white transition-colors">FAQ</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/mutonby/openshorts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Github size={18} />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={onLaunchApp}
|
||||
className="bg-primary hover:bg-blue-600 text-white px-5 py-2 rounded-xl text-sm font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20"
|
||||
>
|
||||
Launch App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 px-6">
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-full px-4 py-1.5 text-sm text-primary mb-8">
|
||||
<Sparkles size={14} />
|
||||
<span>Free & Open Source AI Clip Generator + UGC Video Creator</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold leading-tight mb-6 tracking-tight">
|
||||
Free Open Source
|
||||
<span className="bg-gradient-to-r from-primary via-purple-400 to-pink-500 bg-clip-text text-transparent"> Clip Generator </span>
|
||||
& AI UGC Video Creator
|
||||
</h1>
|
||||
|
||||
<p className="hero-description text-lg md:text-xl text-zinc-400 max-w-3xl mx-auto mb-10 leading-relaxed">
|
||||
Three tools in one. <strong className="text-white">Clip Generator:</strong> turn your long-form videos into viral shorts with AI moment detection, smart 9:16 crop, and auto subtitles. <strong className="text-white">AI Shorts:</strong> generate UGC marketing videos with AI actors and lip-sync for any business. <strong className="text-white">YouTube Studio:</strong> free AI thumbnail generator, 10 viral title suggestions with refinement chat, and auto descriptions with chapters. Self-hosted, open source, no limits.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
|
||||
<button
|
||||
onClick={onLaunchApp}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-8 py-3.5 rounded-xl font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20 text-lg"
|
||||
>
|
||||
Get Started Free
|
||||
<ArrowRight size={20} />
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/mutonby/openshorts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 bg-white/5 border border-white/10 text-white px-8 py-3.5 rounded-xl font-medium transition-all hover:bg-white/10 text-lg"
|
||||
>
|
||||
<Github size={20} />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Platform Icons */}
|
||||
<div className="flex items-center justify-center gap-6 text-zinc-500">
|
||||
<span className="text-sm">Export to:</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||
<TikTokIcon size={18} />
|
||||
<span className="text-sm">TikTok</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||
<Instagram size={18} />
|
||||
<span className="text-sm">Reels</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||
<Youtube size={18} />
|
||||
<span className="text-sm">Shorts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<section className="border-y border-white/5 bg-surface/30">
|
||||
<div className="max-w-5xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white">100%</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">Free & Open Source</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white">3</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">Tools in One</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white">30+</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">Dubbing Languages</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white">$0</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">No Watermarks</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3 Tools in 1 Section */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">3 Free Tools in 1 Platform</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">Everything you need to create, optimize, and publish short-form video content — all free and open source.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="bg-surface/50 border border-primary/20 rounded-2xl p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
<Scissors size={28} className="text-primary mb-4" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">Clip Generator</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Turn your long-form videos into viral-ready 9:16 shorts. AI detects the best moments, crops to vertical with face tracking, and adds subtitles automatically.</p>
|
||||
<ul className="space-y-1.5">
|
||||
{['AI viral moment detection', 'Smart face-tracking crop', 'Auto subtitles + hook overlays', 'AI dubbing in 30+ languages'].map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface/50 border border-violet-500/20 rounded-2xl p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-violet-500/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
<Sparkles size={28} className="text-violet-400 mb-4" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">AI Shorts</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Generate UGC marketing videos with AI actors for any product or business. No camera, no studio. Just describe your product and get a viral-ready video.</p>
|
||||
<ul className="space-y-1.5">
|
||||
{['AI actor generation + lip-sync', 'Script writing from URL or description', 'B-roll + TikTok-style subtitles', 'From $0.65 per video'].map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface/50 border border-pink-500/20 rounded-2xl p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-pink-500/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
<Monitor size={28} className="text-pink-400 mb-4" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">YouTube Studio</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed mb-4">Complete free AI YouTube toolkit. Generate thumbnails with your face, get 10 viral title suggestions with refinement chat, and auto-generate descriptions with timestamps.</p>
|
||||
<ul className="space-y-1.5">
|
||||
{['AI thumbnail generator (with face upload)', '10 viral title suggestions + chat', 'Auto descriptions with chapters', 'Direct publish to YouTube'].map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400"><Check size={12} className="text-green-400 shrink-0" />{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Free AI Clip Generator + UGC Video Creator</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">Three tools in one: clip long videos into viral shorts, generate UGC marketing videos with AI actors, and a complete YouTube Studio for thumbnails, titles, and descriptions.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{features.map((feature, i) => (
|
||||
<FeatureCard key={i} {...feature} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Keys Section */}
|
||||
<section className="py-20 px-6 bg-surface/20">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">All APIs Have Free Tiers</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">OpenShorts uses three external APIs — all with generous free tiers. Only Gemini is required. Your API keys are encrypted client-side and never stored on the server.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-5">
|
||||
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
|
||||
<div className="absolute top-4 right-4 bg-primary/20 text-primary text-[10px] font-bold px-2 py-0.5 rounded-full border border-primary/30">REQUIRED</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
|
||||
<Cpu size={24} className="text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Google Gemini API</h3>
|
||||
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier: 1,500 req/day</span>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">Powers all AI features: viral moment detection, title generation, video effects, YouTube thumbnail creation, and description writing. The core engine of OpenShorts.</p>
|
||||
</div>
|
||||
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
|
||||
<div className="absolute top-4 right-4 bg-zinc-700/50 text-zinc-400 text-[10px] font-bold px-2 py-0.5 rounded-full border border-zinc-600/30">OPTIONAL</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-4">
|
||||
<Languages size={24} className="text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">ElevenLabs API</h3>
|
||||
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">Enables AI voice dubbing and translation in 30+ languages. Preserves the original speaker's voice while translating audio. Dubbed clips are auto-subtitled.</p>
|
||||
</div>
|
||||
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
|
||||
<div className="absolute top-4 right-4 bg-zinc-700/50 text-zinc-400 text-[10px] font-bold px-2 py-0.5 rounded-full border border-zinc-600/30">OPTIONAL</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-4">
|
||||
<Globe size={24} className="text-pink-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Upload-Post API</h3>
|
||||
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">Enables direct publishing to YouTube, TikTok, and Instagram Reels from the dashboard. <a href="https://www.upload-post.com" target="_blank" rel="noopener noreferrer" className="text-pink-400 hover:text-pink-300 underline">Social media API</a> that lets you post your clips and thumbnails without leaving OpenShorts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-5 mt-5">
|
||||
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
|
||||
<div className="absolute top-4 right-4 bg-violet-700/50 text-violet-300 text-[10px] font-bold px-2 py-0.5 rounded-full border border-violet-500/30">AI SHORTS</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-4">
|
||||
<Zap size={24} className="text-violet-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">fal.ai API</h3>
|
||||
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Pay-per-use from $0.04</span>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">Powers AI Shorts: generates AI actor images (Flux), talking head videos (Hailuo/Kling), and lip-sync (VEED). Required only for the AI UGC video generator.</p>
|
||||
</div>
|
||||
<div className="bg-surface/50 border border-white/10 rounded-2xl p-6 relative">
|
||||
<div className="absolute top-4 right-4 bg-violet-700/50 text-violet-300 text-[10px] font-bold px-2 py-0.5 rounded-full border border-violet-500/30">AI SHORTS</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-4">
|
||||
<Languages size={24} className="text-violet-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">ElevenLabs TTS</h3>
|
||||
<span className="inline-block text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full mb-3">Free tier included</span>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">Generates natural voiceovers for AI Shorts from the script. Multiple voice options for male and female actors in English and Spanish.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section id="how-it-works" className="py-20 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">How It Works</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">From a long-form video to viral-ready clips in 5 automated steps. The entire pipeline runs on your machine with AI doing the heavy lifting.</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{steps.map((step, i) => (
|
||||
<StepCard key={i} number={i + 1} {...step} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Built with Proven Technology</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">OpenShorts combines industry-leading AI models and open source tools into a production-ready video processing pipeline.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ name: "Google Gemini 3.0", desc: "AI Analysis" },
|
||||
{ name: "faster-whisper", desc: "Transcription" },
|
||||
{ name: "YOLOv8", desc: "Object Detection" },
|
||||
{ name: "MediaPipe", desc: "Face Tracking" },
|
||||
{ name: "FFmpeg", desc: "Video Processing" },
|
||||
{ name: "ElevenLabs", desc: "Voice & TTS" },
|
||||
{ name: "fal.ai", desc: "AI Video Gen" },
|
||||
{ name: "React + Vite", desc: "Dashboard" },
|
||||
{ name: "Docker", desc: "Deployment" }
|
||||
].map((tech, i) => (
|
||||
<div key={i} className="bg-surface/50 border border-white/10 rounded-xl p-4 text-center">
|
||||
<div className="text-white font-medium text-sm">{tech.name}</div>
|
||||
<div className="text-zinc-500 text-xs mt-1">{tech.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section id="comparison" className="py-20 px-6 bg-surface/20">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Free Clip Generator vs Paid Alternatives</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">Why pay $15-228/month for an AI clip generator when you can self-host the same capabilities for free? OpenShorts includes a free YouTube thumbnail generator, AI title suggestions, and auto descriptions — features that paid tools charge extra for.</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="py-3 px-4 text-left text-sm text-zinc-400 font-medium">Feature</th>
|
||||
<th className="py-3 px-4 text-center text-sm font-medium">
|
||||
<span className="text-primary">OpenShorts</span>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-center text-sm text-zinc-400 font-medium">Opus Clip</th>
|
||||
<th className="py-3 px-4 text-center text-sm text-zinc-400 font-medium">Kapwing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ComparisonRow feature="Price" openshorts={<span className="text-green-400 font-semibold">$0 Free</span>} opusclip={xIcon} kapwing={xIcon} />
|
||||
<ComparisonRow feature="AI Viral Moment Detection" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
|
||||
<ComparisonRow feature="Smart Vertical Cropping" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
|
||||
<ComparisonRow feature="Auto Subtitles" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
|
||||
<ComparisonRow feature="AI Voice Dubbing (30+ langs)" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Limited</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
|
||||
<ComparisonRow feature="AI Video Effects" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={checkIcon} />
|
||||
<ComparisonRow feature="Hook Text Overlays" openshorts={checkIcon} opusclip={checkIcon} kapwing={checkIcon} />
|
||||
<ComparisonRow feature="Self-Hosted / Privacy" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Cloud only</span>} kapwing={<span className="text-zinc-500 text-sm">Cloud only</span>} />
|
||||
<ComparisonRow feature="No Watermark" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Free tier only</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
|
||||
<ComparisonRow feature="Open Source" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
|
||||
<ComparisonRow feature="AI YouTube Thumbnail Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
|
||||
<ComparisonRow feature="AI Title & Description Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">Limited</span>} kapwing={<span className="text-zinc-500 text-sm">Paid</span>} />
|
||||
<ComparisonRow feature="AI UGC Video Generator" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
|
||||
<ComparisonRow feature="AI Actors with Lip-Sync" openshorts={checkIcon} opusclip={<span className="text-zinc-500 text-sm">No</span>} kapwing={<span className="text-zinc-500 text-sm">No</span>} />
|
||||
<ComparisonRow feature="Usage Limits" openshorts={<span className="text-green-400 text-sm">Unlimited</span>} opusclip={<span className="text-zinc-500 text-sm">Per plan</span>} kapwing={<span className="text-zinc-500 text-sm">Per plan</span>} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Who Uses OpenShorts?</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">Content creators, marketers, and agencies use OpenShorts to scale their short-form video production. According to HubSpot's 2025 report, short-form video is the #1 content format with the highest ROI.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-5">
|
||||
{[
|
||||
{
|
||||
title: "Content Creators",
|
||||
description: "Repurpose your long-form videos into TikTok and Reels clips automatically. According to YouTube's Creator Insider data, channels that post Shorts alongside long-form videos see 20-30% more subscriber growth.",
|
||||
icon: Youtube
|
||||
},
|
||||
{
|
||||
title: "Social Media Managers",
|
||||
description: "Scale short-form content production for multiple clients. According to Sprout Social's 2025 Index, 66% of consumers find short-form video the most engaging content type. Process videos in batch and publish directly from one dashboard.",
|
||||
icon: Instagram
|
||||
},
|
||||
{
|
||||
title: "Podcasters & Educators",
|
||||
description: "Extract the most engaging moments from podcast episodes and educational content. Research by Headliner shows that podcast clips on social media increase episode downloads by 72% on average.",
|
||||
icon: FileVideo
|
||||
},
|
||||
{
|
||||
title: "Businesses & Brands",
|
||||
description: "Generate UGC-style marketing videos for any product or business with AI actors. No camera, no studio, no influencer budget. Just describe your product and get a viral-ready video with lip-synced AI avatar, voiceover, b-roll, and subtitles — from $0.65 per video.",
|
||||
icon: Sparkles
|
||||
}
|
||||
].map((useCase, i) => (
|
||||
<div key={i} className="bg-surface/50 border border-white/10 rounded-2xl p-6">
|
||||
<useCase.icon size={24} className="text-primary mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{useCase.title}</h3>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{useCase.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section id="faq" className="py-20 px-6 bg-surface/20">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Frequently Asked Questions</h2>
|
||||
<p className="text-zinc-400">Everything you need to know about OpenShorts, from setup to features.</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => (
|
||||
<FAQItem
|
||||
key={i}
|
||||
question={faq.question}
|
||||
answer={faq.answer}
|
||||
isOpen={openFaq === i}
|
||||
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Start Creating Viral Videos for Free</h2>
|
||||
<p className="text-zinc-400 mb-8 max-w-xl mx-auto">No sign-up, no credit card, no watermarks. Generate viral clips from long videos or create AI UGC marketing videos with AI actors for any business. Self-host with Docker.</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={onLaunchApp}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-8 py-3.5 rounded-xl font-medium transition-all active:scale-[0.98] shadow-lg shadow-primary/20 text-lg"
|
||||
>
|
||||
Launch OpenShorts
|
||||
<ArrowRight size={20} />
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/mutonby/openshorts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Github size={18} />
|
||||
Star on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-white/5 py-10 px-6">
|
||||
<div className="max-w-5xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/logo-openshorts.png" alt="OpenShorts" className="w-6 h-6" />
|
||||
<span className="text-sm text-zinc-400">OpenShorts — Free Open Source Clip Generator & AI UGC Video Creator</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-zinc-500">
|
||||
<a href="https://github.com/mutonby/openshorts" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">GitHub</a>
|
||||
<a href="#features" className="hover:text-white transition-colors">Features</a>
|
||||
<a href="#faq" className="hover:text-white transition-colors">FAQ</a>
|
||||
<a href="#legal" className="hover:text-white transition-colors">Terms & Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
const LAST_UPDATED = '2026-05-06';
|
||||
const ISSUES_URL = 'https://github.com/mutonby/openshorts/issues';
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<section className="mb-7">
|
||||
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
|
||||
<div className="text-zinc-300 leading-relaxed space-y-2 text-sm">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Legal() {
|
||||
const handleBack = () => {
|
||||
window.location.hash = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg text-white">
|
||||
<header className="border-b border-white/5 sticky top-0 bg-bg/95 backdrop-blur z-10">
|
||||
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="text-zinc-400 hover:text-white flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">Terms & Privacy</h1>
|
||||
<p className="text-zinc-500 text-sm mb-10">Last updated: {LAST_UPDATED}</p>
|
||||
|
||||
<Section title="The short version">
|
||||
<p>
|
||||
OpenShorts is a free, open-source AI clip generator. There are no accounts, no payments, and we
|
||||
do not persistently store the videos you upload or the clips we generate. By using the Service
|
||||
you agree to the points below.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Service is provided as-is">
|
||||
<p>
|
||||
The Service is offered for free, on a best-effort basis, with no warranties of any kind and no
|
||||
guarantee of uptime, accuracy, or fitness for any particular purpose. To the maximum extent
|
||||
permitted by law, we are not liable for any damages arising from your use of the Service.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="You are responsible for what you upload">
|
||||
<p>
|
||||
Before processing a video, you must affirmatively confirm — via the checkbox in the upload
|
||||
interface — that you own the content or have the rights to process it. By doing so you
|
||||
represent and warrant that:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>You own all rights to the content, or have a valid license or permission to process it;</li>
|
||||
<li>The content does not infringe any third-party copyright, trademark, privacy, or other right;</li>
|
||||
<li>The content is not unlawful, defamatory, or otherwise prohibited.</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you submit content you do not have rights to, that is your responsibility, not ours. You
|
||||
agree to indemnify OpenShorts and its contributors against any third-party claim arising from
|
||||
content you submitted.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="What we keep, and for how long">
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
<strong className="text-white">Uploaded videos and generated clips:</strong> deleted with
|
||||
their job, typically within 1 hour. Not backed up off-server in our hosted deployment.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white">Attestation record (IP, user-agent, timestamp, source):</strong>{' '}
|
||||
kept in memory with the job and discarded when the job is purged (≈1 hour). Used only to
|
||||
evidence the ownership confirmation in case of a takedown or dispute.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white">Standard server access logs:</strong> retained up to 30 days
|
||||
for debugging and abuse prevention.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-white">API keys (Gemini, ElevenLabs, Upload-Post):</strong> stored
|
||||
encrypted in your browser's <code className="text-zinc-200">localStorage</code>. They are
|
||||
sent as request headers when a feature needs them, used to call the relevant third party,
|
||||
and never written to our database or disk.
|
||||
</li>
|
||||
</ul>
|
||||
<p>We do not sell, rent, or share your data with third parties for advertising or any unrelated purpose.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Third-party APIs">
|
||||
<p>
|
||||
When you use a feature that requires it, OpenShorts forwards relevant data to the third-party
|
||||
API for which you provided a key — Google Gemini (AI analysis), ElevenLabs (optional dubbing),
|
||||
Upload-Post (optional social posting). Those services have their own terms and privacy policies
|
||||
which apply in addition to this notice.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Your rights (EU / EEA / UK)">
|
||||
<p>
|
||||
Under the GDPR / UK GDPR you have the right to access, rectify, erase, restrict, object to, or
|
||||
port your personal data. Because we do not hold accounts and job data is purged within an hour,
|
||||
most requests are auto-satisfied by the retention schedule. For anything else, file a request
|
||||
via{' '}
|
||||
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
|
||||
GitHub Issues
|
||||
</a>
|
||||
. You may also lodge a complaint with your local supervisory authority (in Spain: AEPD,{' '}
|
||||
<a className="text-primary underline" href="https://www.aepd.es" target="_blank" rel="noopener noreferrer">
|
||||
aepd.es
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Copyright takedowns">
|
||||
<p>
|
||||
If you believe content processed through the Service infringes your copyright, open an issue at{' '}
|
||||
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
|
||||
{ISSUES_URL}
|
||||
</a>{' '}
|
||||
with: identification of the work, identification of the allegedly infringing material (job ID,
|
||||
URL, or sufficient detail to locate it), your contact information, and a statement that you are
|
||||
authorized to act on behalf of the rights holder. Note that uploaded content is typically
|
||||
deleted within 1 hour, so most takedowns are auto-resolved by retention.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Self-hosted instances">
|
||||
<p>
|
||||
OpenShorts is open source and may be self-hosted. This notice applies only to the hosted
|
||||
version we operate. Self-hosted instances are operated by their respective administrators, and
|
||||
their data handling, retention, and policies are their responsibility, not ours.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Changes & contact">
|
||||
<p>
|
||||
We may update this notice from time to time; the "Last updated" date above reflects the most
|
||||
recent revision. Continued use after a change constitutes acceptance. For any other question,
|
||||
please use{' '}
|
||||
<a className="text-primary underline" href={ISSUES_URL} target="_blank" rel="noopener noreferrer">
|
||||
GitHub Issues
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>This notice is governed by the laws of Spain.</p>
|
||||
</Section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { LayoutGrid, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { getApiUrl } from '../config';
|
||||
import GalleryCard from './GalleryCard';
|
||||
|
||||
const CLIPS_PER_PAGE = 20;
|
||||
|
||||
export default function Gallery() {
|
||||
const [clips, setClips] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const loaderRef = useRef(null);
|
||||
|
||||
const fetchClips = useCallback(async (currentOffset = 0, append = false) => {
|
||||
try {
|
||||
if (currentOffset === 0) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
|
||||
const res = await fetch(
|
||||
getApiUrl(`/api/gallery/clips?limit=${CLIPS_PER_PAGE}&offset=${currentOffset}`)
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch clips');
|
||||
const data = await res.json();
|
||||
|
||||
const newClips = data.clips || [];
|
||||
|
||||
if (append) {
|
||||
setClips(prev => [...prev, ...newClips]);
|
||||
} else {
|
||||
setClips(newClips);
|
||||
}
|
||||
|
||||
setHasMore(data.has_more ?? newClips.length === CLIPS_PER_PAGE);
|
||||
setOffset(currentOffset + newClips.length);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchClips(0, false);
|
||||
}, [fetchClips]);
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
if (!hasMore || loadingMore || loading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) {
|
||||
fetchClips(offset, true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px', threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loaderRef.current) {
|
||||
observer.unobserve(loaderRef.current);
|
||||
}
|
||||
};
|
||||
}, [hasMore, loadingMore, loading, offset, fetchClips]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-zinc-500 animate-[fadeIn_0.5s_ease-out]">
|
||||
<Loader2 size={32} className="animate-spin mb-4 text-primary" />
|
||||
<p>Loading your viral history...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-red-400 p-6">
|
||||
<AlertCircle size={32} className="mb-4" />
|
||||
<p>Error loading gallery: {error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setOffset(0);
|
||||
fetchClips(0, false);
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 md:p-8 animate-[fadeIn_0.3s_ease-out]">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||
<LayoutGrid className="text-primary" /> Clip Gallery
|
||||
</h1>
|
||||
<span className="text-xs bg-white/10 text-white px-3 py-1 rounded-full border border-white/5">
|
||||
{clips.length} {clips.length === 1 ? 'Clip' : 'Clips'}{hasMore ? '+' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<div className="text-center py-20 text-zinc-500">
|
||||
<p className="text-lg mb-2">No clips found yet.</p>
|
||||
<p className="text-sm">Process some videos to populate your gallery!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6 pb-10">
|
||||
{clips.map((clip, i) => (
|
||||
<GalleryCard key={`${clip.job_id}-${clip.index}`} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll loader trigger */}
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={loaderRef}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
{loadingMore && (
|
||||
<div className="flex items-center gap-2 text-zinc-500">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span className="text-sm">Loading more clips...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Download, Youtube, Instagram, Video, Copy, Check, Play } from 'lucide-react';
|
||||
|
||||
export default function GalleryCard({ clip }) {
|
||||
const [copied, setCopied] = useState(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const cardRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
// Lazy loading with IntersectionObserver
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
// Once loaded, we don't need to observe anymore
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '200px', // Start loading 200px before entering viewport
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
if (cardRef.current) {
|
||||
observer.observe(cardRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cardRef.current) {
|
||||
observer.unobserve(cardRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = (text, field) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(field);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(clip.url);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `clip_${clip.job_id}_${clip.index + 1}.mp4`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Download error:', err);
|
||||
window.open(clip.url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="bg-surface border border-white/5 rounded-xl overflow-hidden flex flex-col hover:border-white/10 transition-all group animate-[fadeIn_0.5s_ease-out]"
|
||||
>
|
||||
{/* Video Player - Lazy loaded */}
|
||||
<div className="aspect-[9/16] bg-black relative group/video">
|
||||
{isVisible ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={clip.url}
|
||||
controls
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onLoadedData={() => setHasLoaded(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
|
||||
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<Play size={24} className="text-white/50 ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 tracking-wide">
|
||||
{new Date(clip.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content & Details */}
|
||||
<div className="flex-1 p-4 flex flex-col bg-[#121214] min-w-0">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.title}>
|
||||
{clip.title}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5">{clip.duration.toFixed(1)}s</span>
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 truncate max-w-[150px]" title={clip.job_id}>ID: {clip.job_id.substring(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar max-h-[150px] pr-1 mb-3">
|
||||
{/* YouTube Title */}
|
||||
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-red-400 mb-1 uppercase tracking-wider">
|
||||
<Youtube size={10} className="shrink-0" /> YouTube Title
|
||||
</div>
|
||||
<p className="text-xs text-zinc-300 select-all line-clamp-2 hover:line-clamp-none transition-all">{clip.title}</p>
|
||||
<button
|
||||
onClick={() => handleCopy(clip.title, 'yt')}
|
||||
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
|
||||
title="Copy Title"
|
||||
>
|
||||
{copied === 'yt' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TikTok / IG Caption */}
|
||||
<div className="bg-black/20 rounded-lg p-2 border border-white/5 relative group/item">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 mb-1 uppercase tracking-wider">
|
||||
<Video size={10} className="text-cyan-400 shrink-0" />
|
||||
<span className="text-zinc-600">/</span>
|
||||
<Instagram size={10} className="text-pink-400 shrink-0" /> Caption
|
||||
</div>
|
||||
<p className="text-xs text-zinc-300 select-all line-clamp-3 hover:line-clamp-none transition-all cursor-pointer">
|
||||
{clip.tiktok_desc || clip.insta_desc}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleCopy(clip.tiktok_desc || clip.insta_desc, 'caption')}
|
||||
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-white transition-colors opacity-0 group-hover/item:opacity-100"
|
||||
title="Copy Caption"
|
||||
>
|
||||
{copied === 'caption' ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Action */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="w-full py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5"
|
||||
>
|
||||
<Download size={14} className="shrink-0" /> Download Clip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Sparkles, Loader2, Maximize, MoveVertical, Zap } from 'lucide-react';
|
||||
import RemotionPreview from './RemotionPreview';
|
||||
|
||||
const ENTRANCE_OPTIONS = [
|
||||
{ value: 'spring', label: 'Bounce' },
|
||||
{ value: 'fade', label: 'Fade' },
|
||||
{ value: 'slide-up', label: 'Slide Up' },
|
||||
{ value: 'none', label: 'None' },
|
||||
];
|
||||
|
||||
export default function HookModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, initialText, durationInSeconds, existingSubtitles }) {
|
||||
const [text, setText] = useState(initialText || 'POV: You are using the viral hook feature');
|
||||
const [position, setPosition] = useState('top');
|
||||
const [size, setSize] = useState('M');
|
||||
const [entranceAnimation, setEntranceAnimation] = useState('spring');
|
||||
const [displayDuration, setDisplayDuration] = useState(5);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Build hook config for Remotion preview
|
||||
const hookConfig = {
|
||||
text: text || 'Enter your text...',
|
||||
position,
|
||||
size,
|
||||
entranceAnimation,
|
||||
displayDurationSec: displayDuration,
|
||||
};
|
||||
|
||||
const useRemotionPreview = !!videoUrl;
|
||||
|
||||
// Fallback preview logic (same as original)
|
||||
const getPositionClass = () => {
|
||||
switch (position) {
|
||||
case 'center': return 'items-center justify-center';
|
||||
case 'bottom': return 'items-center justify-end pb-[20%]';
|
||||
case 'top': default: return 'items-center justify-start pt-[20%]';
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeStyle = () => {
|
||||
switch (size) {
|
||||
case 'S': return { fontSize: '14px', maxWidth: '80%' };
|
||||
case 'L': return { fontSize: '24px', maxWidth: '95%' };
|
||||
case 'M': default: return { fontSize: '18px', maxWidth: '90%' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
|
||||
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-4xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Left: Preview */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
|
||||
{useRemotionPreview ? (
|
||||
<RemotionPreview
|
||||
videoUrl={videoUrl}
|
||||
durationInSeconds={durationInSeconds || 30}
|
||||
hook={hookConfig}
|
||||
subtitles={existingSubtitles || null}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
|
||||
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col h-full ${getPositionClass()}`}>
|
||||
<div
|
||||
className="text-black font-bold px-3 py-2 rounded-xl shadow-2xl text-center whitespace-pre-wrap transition-all duration-200"
|
||||
style={{
|
||||
...getSizeStyle(),
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.82)',
|
||||
fontFamily: 'Noto Serif, serif',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '10px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px'
|
||||
}}
|
||||
>
|
||||
{text || "Enter your text..."}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="w-full md:w-80 flex flex-col">
|
||||
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Sparkles className="text-yellow-400" /> Viral Hook
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
||||
{/* Text Input */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 block">Text</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl p-3 text-white placeholder-zinc-600 focus:outline-none focus:border-yellow-500/50 resize-none font-serif"
|
||||
placeholder="Enter text that will stop the scroll..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Position Control */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<MoveVertical size={12} /> Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['top', 'center', 'bottom'].map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => setPosition(pos)}
|
||||
className={`py-2 px-1 rounded-lg text-xs font-bold capitalize transition-all border ${position === pos
|
||||
? 'bg-white text-black border-white'
|
||||
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{pos}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Control */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Maximize size={12} /> Size
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['S', 'M', 'L'].map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
onClick={() => setSize(sz)}
|
||||
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${size === sz
|
||||
? 'bg-white text-black border-white'
|
||||
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{sz === 'S' ? 'Small' : sz === 'M' ? 'Medium' : 'Large'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entrance Animation (new) */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Zap size={12} /> Entrance
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ENTRANCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setEntranceAnimation(opt.value)}
|
||||
className={`py-2 px-1 rounded-lg text-xs font-bold transition-all border ${entranceAnimation === opt.value
|
||||
? 'bg-white text-black border-white'
|
||||
: 'bg-white/5 text-zinc-400 border-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Duration (new) */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Duration: {displayDuration}s</label>
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="15"
|
||||
value={displayDuration}
|
||||
onChange={(e) => setDisplayDuration(parseInt(e.target.value))}
|
||||
className="w-full accent-yellow-500"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-zinc-500">
|
||||
<span>2s</span>
|
||||
<span>15s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/5 rounded-lg border border-white/5 text-[11px] text-zinc-400">
|
||||
<strong>Tip:</strong> Keep it short and punchy. Using "POV:" or specific questions works best for retention.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onGenerate({
|
||||
text, position, size,
|
||||
// Remotion data
|
||||
remotion: hookConfig,
|
||||
})}
|
||||
disabled={isProcessing || !text.trim()}
|
||||
className="w-full py-4 mt-4 bg-gradient-to-r from-yellow-500 to-amber-600 hover:from-yellow-400 hover:to-amber-500 text-black font-bold rounded-xl shadow-lg shadow-amber-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Sparkles size={20} />}
|
||||
{isProcessing ? 'Generating...' : 'Add Hook'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Key, Eye, EyeOff, Check } from 'lucide-react';
|
||||
|
||||
export default function KeyInput({ onKeySet, savedKey }) {
|
||||
const [key, setKey] = useState(savedKey || '');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isSaved, setIsSaved] = useState(!!savedKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (savedKey) setKey(savedKey);
|
||||
}, [savedKey]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (key.trim().length > 0) {
|
||||
onKeySet(key);
|
||||
setIsSaved(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/5 rounded-2xl p-6 mb-8 animate-[fadeIn_0.5s_ease-out]">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-accent/20 rounded-lg text-accent">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Gemini API Key</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setIsSaved(false);
|
||||
}}
|
||||
placeholder="AIzaSy..."
|
||||
className="input-field pr-12 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
{isVisible ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!key || isSaved}
|
||||
className={`px-6 rounded-xl font-medium transition-all flex items-center gap-2 ${isSaved
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: 'bg-primary hover:bg-blue-600 text-white shadow-lg shadow-primary/20'
|
||||
}`}
|
||||
>
|
||||
{isSaved ? <><Check size={18} /> Ready</> : 'Set Key'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
Your key is stored locally in your browser for convenience.
|
||||
<br />
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline mt-1 inline-block"
|
||||
>
|
||||
Get your free Gemini API Key here →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Youtube, Upload, FileVideo, X } from 'lucide-react';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
export default function MediaInput({ onProcess, isProcessing }) {
|
||||
const [youtubeUrlEnabled, setYoutubeUrlEnabled] = useState(true);
|
||||
const [mode, setMode] = useState('url'); // 'url' | 'file'
|
||||
const [url, setUrl] = useState('');
|
||||
const [file, setFile] = useState(null);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(getApiUrl('/api/config'))
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((cfg) => {
|
||||
if (cfg && cfg.youtubeUrlEnabled === false) {
|
||||
setYoutubeUrlEnabled(false);
|
||||
setMode('file');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!acknowledged) return;
|
||||
if (mode === 'url' && url) {
|
||||
onProcess({ type: 'url', payload: url, acknowledged: true });
|
||||
} else if (mode === 'file' && file) {
|
||||
onProcess({ type: 'file', payload: file, acknowledged: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
setMode('file');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/5 rounded-2xl p-6 animate-[fadeIn_0.6s_ease-out]">
|
||||
<div className="flex gap-4 mb-6 border-b border-white/5 pb-4">
|
||||
{youtubeUrlEnabled && (
|
||||
<button
|
||||
onClick={() => setMode('url')}
|
||||
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'url'
|
||||
? 'text-primary border-b-2 border-primary -mb-[17px]'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Youtube size={18} />
|
||||
YouTube URL
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMode('file')}
|
||||
className={`flex items-center gap-2 pb-2 px-2 transition-all ${mode === 'file'
|
||||
? 'text-primary border-b-2 border-primary -mb-[17px]'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Upload size={18} />
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{mode === 'url' ? (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all ${file ? 'border-primary/50 bg-primary/5' : 'border-zinc-700 hover:border-zinc-500 bg-white/5'
|
||||
}`}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-3 text-white">
|
||||
<FileVideo className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="p-1 hover:bg-white/10 rounded-full"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="cursor-pointer block">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Upload className="mx-auto mb-3 text-zinc-500" size={24} />
|
||||
<p className="text-zinc-400">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">MP4, MOV up to 500MB</p>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-2 mt-5 text-xs text-zinc-400 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-0.5 accent-primary cursor-pointer"
|
||||
/>
|
||||
<span>
|
||||
I confirm I own this content or have the rights to process it. I am responsible for any content I submit. See our <a href="/#legal" target="_blank" rel="noopener noreferrer" className="text-primary underline" onClick={(e) => e.stopPropagation()}>Terms & Privacy</a>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing || !acknowledged || (mode === 'url' && !url) || (mode === 'file' && !file)}
|
||||
className="w-full btn-primary mt-4 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Processing Video...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Generate Clips
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Scan, Scissors, Activity, Radio, CheckCircle, Play } from 'lucide-react';
|
||||
|
||||
const ProcessingAnimation = ({ media, isComplete, syncedTime, isSyncedPlaying, syncTrigger }) => {
|
||||
const [videoSrc, setVideoSrc] = useState(null);
|
||||
const [isYouTube, setIsYouTube] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
const iframeRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return;
|
||||
|
||||
if (media.type === 'file') {
|
||||
const url = URL.createObjectURL(media.payload);
|
||||
setVideoSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else if (media.type === 'url') {
|
||||
setIsYouTube(true);
|
||||
const videoId = getYouTubeId(media.payload);
|
||||
setVideoSrc(videoId);
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
// Handle Sync Playback for Local Video
|
||||
useEffect(() => {
|
||||
if (!isYouTube && videoRef.current) {
|
||||
if (isSyncedPlaying) {
|
||||
// Sync Mode: Seek to time and Play
|
||||
videoRef.current.currentTime = syncedTime;
|
||||
videoRef.current.play().catch(e => console.log("Auto-play prevented", e));
|
||||
videoRef.current.loop = false;
|
||||
videoRef.current.muted = true; // Keep muted to avoid double audio with clip
|
||||
} else {
|
||||
// Stop Sync: Pause
|
||||
videoRef.current.pause();
|
||||
|
||||
// If Analysis Complete and we just stopped syncing (paused clip), we might want to return to ambient loop?
|
||||
// User issue: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica no se está reproduciendo el video en bucle como antes"
|
||||
// This means when NOT synced, it should loop.
|
||||
|
||||
// HOWEVER, previously user asked: "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
|
||||
// This implies:
|
||||
// 1. If I PAUSE the clip -> Left video should PAUSE (static image) so it can resume.
|
||||
// 2. If I STOP (or it finishes? or just idle state?) -> It should LOOP.
|
||||
|
||||
// The problem is we only have onPause from the clip.
|
||||
// Maybe we need to distinguish "Pause" vs "Idle/Stop".
|
||||
// But currently we just get `isSyncedPlaying = false`.
|
||||
|
||||
// If the user wants "resume from where left off", it MUST be static (paused).
|
||||
// If the user wants "loop when not playing", it MUST play.
|
||||
// These are contradictory for the "Paused" state.
|
||||
|
||||
// BUT, maybe the "loop original" refers to the initial state BEFORE any clip is played?
|
||||
// OR when the clip finishes?
|
||||
|
||||
// Let's look at the logic:
|
||||
// isSyncedPlaying is true ONLY when a clip is playing.
|
||||
// When clip pauses, isSyncedPlaying becomes false.
|
||||
|
||||
// If we want it to loop "como antes", we should set it to loop.
|
||||
// But then we lose the "resume" position because it starts looping.
|
||||
// Unless... we only loop if we haven't started syncing yet? Or if explicitly reset?
|
||||
|
||||
// Wait, the user said: "ahora el video loop original que sale oscurito ahora bien pero es una imagen estatica"
|
||||
// This likely refers to the state AFTER analysis is complete but BEFORE (or after) playing a clip.
|
||||
// If I haven't touched a clip yet, `isSyncedPlaying` is false.
|
||||
// In that case, it SHOULD be looping.
|
||||
|
||||
// My previous change removed the "else { play loop }" block entirely.
|
||||
// I need to restore the loop for the IDLE state, but keep the PAUSE for the "paused clip" state?
|
||||
// That requires knowing WHY `isSyncedPlaying` is false.
|
||||
|
||||
// Actually, if `isSyncedPlaying` is false, it means no clip is controlling it.
|
||||
// If I want it to loop in the background, I can just let it loop.
|
||||
// BUT if I play a clip later, it will jump to the sync time anyway (handled by the `if (isSyncedPlaying)` block).
|
||||
// The only issue is if I PAUSE the clip, `isSyncedPlaying` becomes false, and if I immediately start looping,
|
||||
// visually it might jump or start moving when it should be "paused".
|
||||
|
||||
// Let's try this:
|
||||
// If `syncedTime` is 0 (or we track if we ever started syncing?), we loop.
|
||||
// But `syncedTime` updates on play.
|
||||
|
||||
// Alternative interpretation: The user sees it static because I removed `videoRef.current.play()` in the else block.
|
||||
// If I put it back, it fixes the "loop" issue.
|
||||
// Does it break the "resume" issue?
|
||||
// "si pauso el video preview y lo reanudo el video original se vuelve al princpio en vez de contnuar igual"
|
||||
// If I pause the clip -> `isSyncedPlaying` = false.
|
||||
// If logic says -> Loop from 0.
|
||||
// Then I resume -> `isSyncedPlaying` = true -> Jump to `syncedTime`.
|
||||
// This actually SHOULD work fine for "resume", because `syncedTime` comes from the clip's current time.
|
||||
// The only visual glitch is that while paused, the left video is looping instead of frozen on the frame.
|
||||
|
||||
// If the user accepts that "Paused Clip" = "Background Loop", then we are good.
|
||||
// If the user wants "Paused Clip" = "Frozen Frame" AND "Idle" = "Background Loop", we need more state.
|
||||
// But typically "Idle" implies we aren't focusing on a clip.
|
||||
|
||||
// Let's restore the loop behavior because "video loop original... es una imagen estatica" sounds like a bug to them.
|
||||
|
||||
if (isComplete) {
|
||||
videoRef.current.loop = true;
|
||||
videoRef.current.play().catch(e => console.log("Ambient play prevented", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [syncedTime, isSyncedPlaying, isYouTube, isComplete, syncTrigger]);
|
||||
|
||||
// Handle Sync Playback for YouTube (Basic Iframe Control via PostMessage)
|
||||
useEffect(() => {
|
||||
if (isYouTube && iframeRef.current && videoSrc) {
|
||||
const iframeWindow = iframeRef.current.contentWindow;
|
||||
if (isSyncedPlaying) {
|
||||
// Seek and Play
|
||||
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'seekTo', args: [syncedTime, true] }), '*');
|
||||
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'playVideo', args: [] }), '*');
|
||||
} else {
|
||||
// Pause
|
||||
// iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); // Removed pause to allow loop if needed, but YT embeds are tricky with custom loops via API.
|
||||
// For now, let's just pause YouTube as complex looping is harder without state.
|
||||
iframeWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*');
|
||||
}
|
||||
}
|
||||
}, [syncedTime, isSyncedPlaying, isYouTube, videoSrc, syncTrigger]);
|
||||
|
||||
|
||||
const getYouTubeId = (url) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const containerClasses = `relative w-full aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-2xl mb-8 group animate-[fadeIn_0.5s_ease-out] transition-all duration-500
|
||||
${isComplete && !isSyncedPlaying ? 'grayscale brightness-50' : ''}
|
||||
${isSyncedPlaying ? 'ring-2 ring-primary ring-offset-2 ring-offset-black shadow-primary/20' : ''}`;
|
||||
|
||||
const getVideoOpacityClass = () => {
|
||||
if (isSyncedPlaying) return 'opacity-100'; // Playing: Full visibility
|
||||
if (isComplete) return 'opacity-30'; // Idle Result: Darker
|
||||
return 'opacity-40 grayscale group-hover:grayscale-0'; // Processing: Dark + Grayscale effect
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Video Layer */}
|
||||
<div className={`absolute inset-0 transition-all duration-700 ${getVideoOpacityClass()}`}>
|
||||
{isYouTube && videoSrc ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={`w-full h-full ${isSyncedPlaying ? '' : 'pointer-events-none scale-110'}`}
|
||||
// Add enablejsapi=1 for postMessage control
|
||||
src={`https://www.youtube.com/embed/${videoSrc}?autoplay=1&mute=1&controls=0&loop=1&playlist=${videoSrc}&modestbranding=1&showinfo=0&rel=0&enablejsapi=1`}
|
||||
title="Processing Video"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
/>
|
||||
) : videoSrc ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
className="w-full h-full object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-900">
|
||||
<div className="w-16 h-16 border-4 border-zinc-700 border-t-zinc-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlays - Hide when synced playing so user sees clean video */}
|
||||
{!isSyncedPlaying && !isComplete && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:40px_40px] z-10 pointer-events-none"></div>
|
||||
<div className="absolute left-0 w-full h-[2px] bg-primary shadow-[0_0_15px_2px_rgba(59,130,246,0.5)] animate-[scan_2.5s_linear_infinite] z-20 pointer-events-none"></div>
|
||||
<div className="absolute left-0 w-full h-[15%] bg-gradient-to-b from-primary/0 via-primary/5 to-primary/0 animate-[scan-overlay_2.5s_linear_infinite] z-10 pointer-events-none"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HUD Elements - Hide when synced playing */}
|
||||
{!isSyncedPlaying && (
|
||||
<div className={`absolute top-4 left-4 z-30 flex items-center gap-2 px-3 py-1.5 backdrop-blur-md rounded-lg border text-xs font-mono font-bold uppercase transition-all duration-500 ${isComplete ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-black/60 border-primary/30 text-primary animate-pulse'}`}>
|
||||
{isComplete ? (
|
||||
<>
|
||||
<CheckCircle size={14} /> Analysis Complete
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Scan size={14} /> Scanning Content...
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSyncedPlaying && !isComplete && (
|
||||
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 text-white/50 text-[10px] font-mono">
|
||||
AI_MODEL: GEMINI-2.5-PRO
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Flair */}
|
||||
{!isSyncedPlaying && !isComplete && (
|
||||
<div className="absolute inset-0 pointer-events-none z-20 overflow-hidden">
|
||||
<div className="absolute top-0 bottom-0 left-[35%] w-[1px] bg-yellow-500/20 border-r border-dashed border-yellow-500/40"></div>
|
||||
<div className="absolute top-0 bottom-0 right-[35%] w-[1px] bg-yellow-500/20 border-l border-dashed border-yellow-500/40"></div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 border border-white/20 rounded-full flex items-center justify-center">
|
||||
<div className="w-1 h-1 bg-red-500 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-2 opacity-60">
|
||||
<Scissors size={24} className="text-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Synced Playing Indicator */}
|
||||
{isSyncedPlaying && (
|
||||
<div className="absolute top-4 right-4 z-30 flex items-center gap-2 px-3 py-1.5 bg-red-600/90 backdrop-blur text-white rounded-lg shadow-lg animate-pulse font-bold text-[10px] uppercase tracking-wider border border-white/20">
|
||||
<Activity size={12} /> Live Sync
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Info Bar */}
|
||||
{!isSyncedPlaying && !isComplete && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent z-30 flex justify-between items-end border-t border-white/5">
|
||||
<div className="font-mono text-[10px] text-primary/80 space-y-1">
|
||||
<div className="flex items-center gap-2"><Activity size={10} className="animate-bounce" /> > ANALYSIS_THREAD_01: ACTIVE</div>
|
||||
<div className="flex items-center gap-2"><Radio size={10} /> > AUDIO_TRANSCRIPT: PROCESSING</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-3 bg-primary/40 animate-[pulse_0.5s_infinite]"></div>
|
||||
<div className="w-1 h-5 bg-primary/60 animate-[pulse_0.7s_infinite]"></div>
|
||||
<div className="w-1 h-2 bg-primary/30 animate-[pulse_0.4s_infinite]"></div>
|
||||
<div className="w-1 h-4 bg-primary/80 animate-[pulse_0.6s_infinite]"></div>
|
||||
<div className="w-1 h-3 bg-primary/50 animate-[pulse_0.5s_infinite]"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessingAnimation;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Player } from '@remotion/player';
|
||||
import { ShortVideo } from '../remotion/compositions/ShortVideo';
|
||||
|
||||
/**
|
||||
* Wraps Remotion's Player component for real-time preview in modals.
|
||||
* Accepts the same ShortVideoProps interface as the Remotion composition.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.videoUrl - URL to the base clip video
|
||||
* @param {number} props.durationInSeconds - Video duration in seconds
|
||||
* @param {object|null} props.subtitles - SubtitleConfig or null
|
||||
* @param {object|null} props.hook - HookConfig or null
|
||||
* @param {object|null} props.effects - EffectsConfig or null
|
||||
* @param {string} [props.className] - Additional CSS classes
|
||||
*/
|
||||
export default function RemotionPreview({
|
||||
videoUrl,
|
||||
durationInSeconds = 30,
|
||||
subtitles = null,
|
||||
hook = null,
|
||||
effects = null,
|
||||
className = '',
|
||||
}) {
|
||||
const fps = 30;
|
||||
const durationInFrames = Math.max(1, Math.round(durationInSeconds * fps));
|
||||
|
||||
const inputProps = useMemo(
|
||||
() => ({
|
||||
videoUrl,
|
||||
durationInFrames,
|
||||
fps,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
subtitles,
|
||||
hook,
|
||||
effects,
|
||||
}),
|
||||
[videoUrl, durationInFrames, subtitles, hook, effects]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<Player
|
||||
component={ShortVideo}
|
||||
inputProps={inputProps}
|
||||
durationInFrames={durationInFrames}
|
||||
fps={fps}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Share2, Instagram, Youtube, Video, CheckCircle, AlertCircle, X, Loader2, Copy, Wand2, Type, Calendar, Clock, Languages } from 'lucide-react';
|
||||
import { getApiUrl } from '../config';
|
||||
import SubtitleModal from './SubtitleModal';
|
||||
import HookModal from './HookModal';
|
||||
import TranslateModal from './TranslateModal';
|
||||
import { renderInBrowser } from '../lib/renderInBrowser';
|
||||
|
||||
export default function ResultCard({ clip, index, jobId, uploadPostKey, uploadUserId, geminiApiKey, elevenLabsKey, onPlay, onPause }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
|
||||
const videoRef = React.useRef(null);
|
||||
const originalVideoUrl = getApiUrl(clip.video_url); // Never changes — used for Remotion previews
|
||||
const [currentVideoUrl, setCurrentVideoUrl] = useState(originalVideoUrl);
|
||||
|
||||
const [platforms, setPlatforms] = useState({
|
||||
tiktok: true,
|
||||
instagram: true,
|
||||
youtube: true
|
||||
});
|
||||
const [postTitle, setPostTitle] = useState("");
|
||||
const [postDescription, setPostDescription] = useState("");
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [postResult, setPostResult] = useState(null);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSubtitling, setIsSubtitling] = useState(false);
|
||||
const [isHooking, setIsHooking] = useState(false);
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
const [showHookModal, setShowHookModal] = useState(false);
|
||||
const [showTranslateModal, setShowTranslateModal] = useState(false);
|
||||
const [editError, setEditError] = useState(null);
|
||||
|
||||
const [clipDuration, setClipDuration] = useState(clip.end && clip.start ? clip.end - clip.start : 30);
|
||||
|
||||
// Accumulate Remotion layers across operations
|
||||
const [activeLayers, setActiveLayers] = useState({ subtitles: null, hook: null, effects: null });
|
||||
|
||||
// Fetch clip duration from transcript endpoint
|
||||
useEffect(() => {
|
||||
if (!jobId || index === undefined) return;
|
||||
fetch(getApiUrl(`/api/clip/${jobId}/${index}/transcript`))
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data && data.durationSec) setClipDuration(data.durationSec);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [jobId, index]);
|
||||
|
||||
// Initialize/Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setPostTitle(clip.video_title_for_youtube_short || "Viral Short");
|
||||
setPostDescription(clip.video_description_for_instagram || clip.video_description_for_tiktok || "");
|
||||
setIsScheduling(false);
|
||||
setScheduleDate("");
|
||||
setPostResult(null);
|
||||
}
|
||||
}, [showModal, clip]);
|
||||
|
||||
const handleAutoEdit = async () => {
|
||||
setIsEditing(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const apiKey = geminiApiKey || localStorage.getItem('gemini_key');
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("Gemini API Key is missing. Please set it in Settings.");
|
||||
}
|
||||
|
||||
// Try Remotion effects endpoint first
|
||||
const effectsRes = await fetch(getApiUrl('/api/effects/generate'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gemini-Key': apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
input_filename: currentVideoUrl.split('/').pop()
|
||||
})
|
||||
});
|
||||
|
||||
if (effectsRes.ok) {
|
||||
const data = await effectsRes.json();
|
||||
if (data.effects && data.effects.segments) {
|
||||
const newLayers = { ...activeLayers, effects: data.effects };
|
||||
setActiveLayers(newLayers);
|
||||
const blobUrl = await renderInBrowser({
|
||||
videoUrl: originalVideoUrl,
|
||||
durationInSeconds: clipDuration,
|
||||
subtitles: newLayers.subtitles,
|
||||
hook: newLayers.hook,
|
||||
effects: newLayers.effects,
|
||||
});
|
||||
setCurrentVideoUrl(blobUrl);
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: legacy FFmpeg edit endpoint
|
||||
const res = await fetch(getApiUrl('/api/edit'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gemini-Key': apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
input_filename: currentVideoUrl.split('/').pop()
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
try {
|
||||
const jsonErr = JSON.parse(errText);
|
||||
throw new Error(jsonErr.detail || errText);
|
||||
} catch (e) {
|
||||
throw new Error(errText);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.new_video_url) {
|
||||
setCurrentVideoUrl(getApiUrl(data.new_video_url));
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setEditError(e.message);
|
||||
setTimeout(() => setEditError(null), 5000);
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubtitle = async (options) => {
|
||||
setIsSubtitling(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
if (options.remotion) {
|
||||
// Accumulate layer and render all layers together
|
||||
const newLayers = { ...activeLayers, subtitles: options.remotion };
|
||||
setActiveLayers(newLayers);
|
||||
const blobUrl = await renderInBrowser({
|
||||
videoUrl: originalVideoUrl,
|
||||
durationInSeconds: clipDuration,
|
||||
subtitles: newLayers.subtitles,
|
||||
hook: newLayers.hook,
|
||||
effects: newLayers.effects,
|
||||
});
|
||||
setCurrentVideoUrl(blobUrl);
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
setShowSubtitleModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: legacy FFmpeg
|
||||
const res = await fetch(getApiUrl('/api/subtitle'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
position: options.position,
|
||||
font_size: options.fontSize,
|
||||
font_name: options.fontName,
|
||||
font_color: options.fontColor,
|
||||
border_color: options.borderColor,
|
||||
border_width: options.borderWidth,
|
||||
bg_color: options.bgColor,
|
||||
bg_opacity: options.bgOpacity,
|
||||
input_filename: currentVideoUrl.split('/').pop()
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
if (data.new_video_url) {
|
||||
setCurrentVideoUrl(getApiUrl(data.new_video_url));
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
setShowSubtitleModal(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setEditError(e.message);
|
||||
setTimeout(() => setEditError(null), 5000);
|
||||
} finally {
|
||||
setIsSubtitling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHook = async (hookData) => {
|
||||
setIsHooking(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
if (hookData.remotion) {
|
||||
// Accumulate layer and render all layers together
|
||||
const newLayers = { ...activeLayers, hook: hookData.remotion };
|
||||
setActiveLayers(newLayers);
|
||||
const blobUrl = await renderInBrowser({
|
||||
videoUrl: originalVideoUrl,
|
||||
durationInSeconds: clipDuration,
|
||||
subtitles: newLayers.subtitles,
|
||||
hook: newLayers.hook,
|
||||
effects: newLayers.effects,
|
||||
});
|
||||
setCurrentVideoUrl(blobUrl);
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
setShowHookModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: legacy FFmpeg
|
||||
const payload = typeof hookData === 'string'
|
||||
? { text: hookData, position: 'top', size: 'M' }
|
||||
: hookData;
|
||||
|
||||
const res = await fetch(getApiUrl('/api/hook'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
text: payload.text,
|
||||
position: payload.position,
|
||||
size: payload.size,
|
||||
input_filename: currentVideoUrl.split('/').pop()
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
if (data.new_video_url) {
|
||||
setCurrentVideoUrl(getApiUrl(data.new_video_url));
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
setShowHookModal(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setEditError(e.message);
|
||||
setTimeout(() => setEditError(null), 5000);
|
||||
} finally {
|
||||
setIsHooking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslate = async (options) => {
|
||||
console.log('[Translate] Starting translation with options:', options);
|
||||
setIsTranslating(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const apiKey = elevenLabsKey;
|
||||
console.log('[Translate] API Key available:', !!apiKey);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("ElevenLabs API Key is missing. Please set it in Settings.");
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
target_language: options.targetLanguage,
|
||||
input_filename: currentVideoUrl.split('/').pop()
|
||||
};
|
||||
console.log('[Translate] Request body:', requestBody);
|
||||
console.log('[Translate] Sending request to /api/translate');
|
||||
|
||||
const res = await fetch(getApiUrl('/api/translate'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ElevenLabs-Key': apiKey
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('[Translate] Response status:', res.status);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
console.error('[Translate] Error response:', errText);
|
||||
try {
|
||||
const jsonErr = JSON.parse(errText);
|
||||
throw new Error(jsonErr.detail || errText);
|
||||
} catch (e) {
|
||||
if (e.message !== errText) throw e;
|
||||
throw new Error(errText);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log('[Translate] Success response:', data);
|
||||
if (data.new_video_url) {
|
||||
setCurrentVideoUrl(getApiUrl(data.new_video_url));
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
setShowTranslateModal(false);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Translate] Exception:', e);
|
||||
setEditError(e.message);
|
||||
setTimeout(() => setEditError(null), 5000);
|
||||
} finally {
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = async () => {
|
||||
if (!uploadPostKey || !uploadUserId) {
|
||||
setPostResult({ success: false, msg: "Missing API Key or User ID." });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
|
||||
if (selectedPlatforms.length === 0) {
|
||||
setPostResult({ success: false, msg: "Select at least one platform." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScheduling && !scheduleDate) {
|
||||
setPostResult({ success: false, msg: "Please select a date and time." });
|
||||
return;
|
||||
}
|
||||
|
||||
setPosting(true);
|
||||
setPostResult(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
api_key: uploadPostKey,
|
||||
user_id: uploadUserId,
|
||||
platforms: selectedPlatforms,
|
||||
title: postTitle,
|
||||
description: postDescription
|
||||
};
|
||||
|
||||
if (isScheduling && scheduleDate) {
|
||||
// Convert to ISO-8601
|
||||
payload.scheduled_date = new Date(scheduleDate).toISOString();
|
||||
// Optional: pass timezone if needed, backend defaults to UTC or we can send user's timezone
|
||||
payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
const res = await fetch(getApiUrl('/api/social/post'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
try {
|
||||
const jsonErr = JSON.parse(errText);
|
||||
throw new Error(jsonErr.detail || errText);
|
||||
} catch (e) {
|
||||
throw new Error(errText);
|
||||
}
|
||||
}
|
||||
|
||||
setPostResult({ success: true, msg: isScheduling ? "Scheduled successfully!" : "Posted successfully!" });
|
||||
setTimeout(() => {
|
||||
setShowModal(false);
|
||||
setPostResult(null);
|
||||
}, 3000);
|
||||
|
||||
} catch (e) {
|
||||
setPostResult({ success: false, msg: `Failed: ${e.message}` });
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/5 rounded-2xl overflow-hidden flex flex-col md:flex-row group hover:border-white/10 transition-all animate-[fadeIn_0.5s_ease-out] min-h-[300px] h-auto" style={{ animationDelay: `${index * 0.1}s` }}>
|
||||
{/* Left: Video Preview (Responsive Width) */}
|
||||
<div className="w-full md:w-[180px] lg:w-[200px] bg-black relative shrink-0 aspect-[9/16] md:aspect-auto group/video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentVideoUrl}
|
||||
controls
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
onPlay={() => {
|
||||
const currentTime = videoRef.current ? videoRef.current.currentTime : 0;
|
||||
onPlay && onPlay(clip.start + currentTime);
|
||||
}}
|
||||
onPause={() => onPause && onPause()}
|
||||
onEnded={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
videoRef.current.play();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-3 left-3 flex gap-2">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 uppercase tracking-wide">
|
||||
Clip {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Auto Edit Overlay if Processing */}
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center z-10 p-4 text-center">
|
||||
<Loader2 size={32} className="text-primary animate-spin mb-3" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">AI Magic in Progress...</span>
|
||||
<span className="text-[10px] text-zinc-400 mt-1">Applying viral edits & zooms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Content & Details */}
|
||||
<div className="flex-1 p-4 md:p-5 flex flex-col bg-[#121214] overflow-hidden min-w-0">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.video_title_for_youtube_short}>
|
||||
{clip.video_title_for_youtube_short || "Viral Clip Generated"}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">{Math.floor(clip.end - clip.start)}s</span>
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#shorts</span>
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#viral</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Descriptions Area */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2 mb-4">
|
||||
{/* YouTube */}
|
||||
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-red-400 mb-1.5 uppercase tracking-wider">
|
||||
<Youtube size={12} className="shrink-0" /> <span className="truncate">YouTube Title</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-300 select-all break-words">
|
||||
{clip.video_title_for_youtube_short || "Viral Short Video"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TikTok / IG */}
|
||||
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-zinc-400 mb-1.5 uppercase tracking-wider">
|
||||
<Video size={12} className="text-cyan-400 shrink-0" />
|
||||
<span className="text-zinc-500">/</span>
|
||||
<Instagram size={12} className="text-pink-400 shrink-0" />
|
||||
<span className="truncate">Caption</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-300 line-clamp-3 hover:line-clamp-none transition-all cursor-pointer select-all break-words">
|
||||
{clip.video_description_for_tiktok || clip.video_description_for_instagram}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{editError && (
|
||||
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] rounded-lg flex items-center gap-2">
|
||||
<AlertCircle size={12} className="shrink-0" />
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Footer */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-auto pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={handleAutoEdit}
|
||||
disabled={isEditing}
|
||||
className="col-span-1 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-purple-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
|
||||
>
|
||||
{isEditing ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
{isEditing ? 'Editing...' : 'Auto Edit'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSubtitleModal(true)}
|
||||
disabled={isSubtitling}
|
||||
className="col-span-1 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
|
||||
>
|
||||
{isSubtitling ? <Loader2 size={14} className="animate-spin" /> : <Type size={14} />}
|
||||
{isSubtitling ? 'Adding...' : 'Subtitles'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowHookModal(true)}
|
||||
disabled={isHooking}
|
||||
className="col-span-1 py-2 bg-gradient-to-r from-amber-400 to-yellow-500 hover:from-amber-300 hover:to-yellow-400 text-black rounded-lg text-xs font-bold shadow-lg shadow-yellow-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
|
||||
>
|
||||
{isHooking ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
{isHooking ? 'Adding...' : 'Viral Hook'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTranslateModal(true)}
|
||||
disabled={isTranslating}
|
||||
className="col-span-1 py-2 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-green-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
|
||||
>
|
||||
{isTranslating ? <Loader2 size={14} className="animate-spin" /> : <Languages size={14} />}
|
||||
{isTranslating ? 'Translating...' : 'Dub Voice'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="col-span-1 py-2 bg-primary hover:bg-blue-600 text-white rounded-lg text-xs font-bold shadow-lg shadow-primary/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 truncate px-2"
|
||||
>
|
||||
<Share2 size={14} className="shrink-0" /> Post
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(currentVideoUrl);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `clip-${index + 1}.mp4`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Download error:', err);
|
||||
window.open(currentVideoUrl, '_blank');
|
||||
}
|
||||
}}
|
||||
className="col-span-1 py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5 truncate px-2"
|
||||
>
|
||||
<Download size={14} className="shrink-0" /> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
|
||||
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="absolute top-4 right-4 text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-bold text-white mb-4">Post / Schedule</h3>
|
||||
|
||||
{!uploadPostKey && (
|
||||
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||
<div>Configure API Key in Settings first.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Title & Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-1">Video Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={postTitle}
|
||||
onChange={(e) => setPostTitle(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600"
|
||||
placeholder="Enter a catchy title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-1">Caption / Description</label>
|
||||
<textarea
|
||||
value={postDescription}
|
||||
onChange={(e) => setPostDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600 resize-none"
|
||||
placeholder="Write a caption for your post..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scheduling */}
|
||||
<div className="p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm text-white font-medium">
|
||||
<Calendar size={16} className="text-purple-400" /> Schedule Post
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" checked={isScheduling} onChange={(e) => setIsScheduling(e.target.checked)} className="sr-only peer" />
|
||||
<div className="w-9 h-5 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isScheduling && (
|
||||
<div className="mt-3 animate-[fadeIn_0.2s_ease-out]">
|
||||
<label className="block text-xs text-zinc-400 mb-1">Select Date & Time</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 pl-9 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
|
||||
/>
|
||||
<Clock size={14} className="absolute left-3 top-2.5 text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-2">Select Platforms</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
|
||||
<input type="checkbox" checked={platforms.tiktok} onChange={e => setPlatforms({ ...platforms, tiktok: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
|
||||
<div className="flex items-center gap-2 text-sm text-white"><Video size={16} className="text-cyan-400" /> TikTok</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
|
||||
<input type="checkbox" checked={platforms.instagram} onChange={e => setPlatforms({ ...platforms, instagram: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
|
||||
<div className="flex items-center gap-2 text-sm text-white"><Instagram size={16} className="text-pink-400" /> Instagram</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
|
||||
<input type="checkbox" checked={platforms.youtube} onChange={e => setPlatforms({ ...platforms, youtube: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
|
||||
<div className="flex items-center gap-2 text-sm text-white"><Youtube size={16} className="text-red-400" /> YouTube Shorts</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{postResult && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-xs flex items-start gap-2 ${postResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{postResult.success ? <CheckCircle size={14} className="mt-0.5 shrink-0" /> : <AlertCircle size={14} className="mt-0.5 shrink-0" />}
|
||||
<div>{postResult.msg}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePost}
|
||||
disabled={posting || !uploadPostKey}
|
||||
className="w-full py-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white font-bold transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{posting ? <><Loader2 size={16} className="animate-spin" /> {isScheduling ? 'Scheduling...' : 'Publishing...'}</> : <><Share2 size={16} /> {isScheduling ? 'Schedule Post' : 'Publish Now'}</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubtitleModal
|
||||
isOpen={showSubtitleModal}
|
||||
onClose={() => setShowSubtitleModal(false)}
|
||||
onGenerate={handleSubtitle}
|
||||
isProcessing={isSubtitling}
|
||||
videoUrl={originalVideoUrl}
|
||||
jobId={jobId}
|
||||
clipIndex={index}
|
||||
existingHook={activeLayers.hook}
|
||||
/>
|
||||
|
||||
<HookModal
|
||||
isOpen={showHookModal}
|
||||
onClose={() => setShowHookModal(false)}
|
||||
onGenerate={handleHook}
|
||||
isProcessing={isHooking}
|
||||
videoUrl={originalVideoUrl}
|
||||
initialText={clip.viral_hook_text}
|
||||
durationInSeconds={clip.end && clip.start ? clip.end - clip.start : 30}
|
||||
existingSubtitles={activeLayers.subtitles}
|
||||
/>
|
||||
|
||||
<TranslateModal
|
||||
isOpen={showTranslateModal}
|
||||
onClose={() => setShowTranslateModal(false)}
|
||||
onTranslate={handleTranslate}
|
||||
isProcessing={isTranslating}
|
||||
videoUrl={currentVideoUrl}
|
||||
hasApiKey={!!elevenLabsKey}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { X, Loader2, Calendar, Clock, CheckCircle, AlertCircle, Video, Instagram, Youtube, ChevronLeft, ChevronRight, Globe, ExternalLink } from 'lucide-react';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
const DAYS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
||||
const MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
const TIMEZONES = [
|
||||
{ value: 'Pacific/Midway', label: '(GMT-11:00) Midway' },
|
||||
{ value: 'Pacific/Honolulu', label: '(GMT-10:00) Honolulu' },
|
||||
{ value: 'America/Anchorage', label: '(GMT-09:00) Alaska' },
|
||||
{ value: 'America/Los_Angeles', label: '(GMT-08:00) Los Ángeles' },
|
||||
{ value: 'America/Denver', label: '(GMT-07:00) Denver' },
|
||||
{ value: 'America/Mexico_City', label: '(GMT-06:00) Ciudad de México' },
|
||||
{ value: 'America/Chicago', label: '(GMT-06:00) Chicago' },
|
||||
{ value: 'America/New_York', label: '(GMT-05:00) Nueva York' },
|
||||
{ value: 'America/Bogota', label: '(GMT-05:00) Bogotá' },
|
||||
{ value: 'America/Caracas', label: '(GMT-04:00) Caracas' },
|
||||
{ value: 'America/Santiago', label: '(GMT-04:00) Santiago' },
|
||||
{ value: 'America/Argentina/Buenos_Aires', label: '(GMT-03:00) Buenos Aires' },
|
||||
{ value: 'America/Sao_Paulo', label: '(GMT-03:00) São Paulo' },
|
||||
{ value: 'Atlantic/Azores', label: '(GMT-01:00) Azores' },
|
||||
{ value: 'UTC', label: '(GMT+00:00) UTC' },
|
||||
{ value: 'Europe/London', label: '(GMT+00:00) Londres' },
|
||||
{ value: 'Europe/Madrid', label: '(GMT+01:00) Madrid' },
|
||||
{ value: 'Europe/Paris', label: '(GMT+01:00) París' },
|
||||
{ value: 'Europe/Berlin', label: '(GMT+01:00) Berlín' },
|
||||
{ value: 'Europe/Rome', label: '(GMT+01:00) Roma' },
|
||||
{ value: 'Africa/Lagos', label: '(GMT+01:00) Lagos' },
|
||||
{ value: 'Europe/Istanbul', label: '(GMT+03:00) Estambul' },
|
||||
{ value: 'Asia/Dubai', label: '(GMT+04:00) Dubái' },
|
||||
{ value: 'Asia/Kolkata', label: '(GMT+05:30) India' },
|
||||
{ value: 'Asia/Bangkok', label: '(GMT+07:00) Bangkok' },
|
||||
{ value: 'Asia/Shanghai', label: '(GMT+08:00) Shanghái' },
|
||||
{ value: 'Asia/Tokyo', label: '(GMT+09:00) Tokio' },
|
||||
{ value: 'Australia/Sydney', label: '(GMT+10:00) Sídney' },
|
||||
{ value: 'Pacific/Auckland', label: '(GMT+12:00) Auckland' },
|
||||
];
|
||||
|
||||
function getDayLabel(date) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const target = new Date(date);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
if (target.getTime() === today.getTime()) return 'Hoy';
|
||||
if (target.getTime() === tomorrow.getTime()) return 'Mañana';
|
||||
return DAYS[target.getDay()];
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
||||
}
|
||||
|
||||
function detectTimezone() {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (TIMEZONES.find(t => t.value === tz)) return tz;
|
||||
return 'UTC';
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ScheduleWeekModal({ isOpen, onClose, clips, jobId, uploadPostKey, uploadUserId }) {
|
||||
const [time, setTime] = useState('12:00');
|
||||
const [timezone, setTimezone] = useState(detectTimezone);
|
||||
const [platforms, setPlatforms] = useState({
|
||||
tiktok: true,
|
||||
instagram: true,
|
||||
youtube: true
|
||||
});
|
||||
const [startOffset, setStartOffset] = useState(1);
|
||||
|
||||
const schedule = useMemo(() => {
|
||||
if (!clips) return [];
|
||||
return clips.map((clip, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + startOffset + i);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return { clip, index: i, date };
|
||||
});
|
||||
}, [clips, startOffset]);
|
||||
|
||||
const [scheduling, setScheduling] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, results: [] });
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Reset state when modal reopens
|
||||
const prevOpen = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (isOpen && !prevOpen.current) {
|
||||
setScheduling(false);
|
||||
setDone(false);
|
||||
setProgress({ current: 0, total: 0, results: [] });
|
||||
}
|
||||
prevOpen.current = isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
|
||||
|
||||
const handleScheduleAll = async () => {
|
||||
if (!uploadPostKey || !uploadUserId) return;
|
||||
if (selectedPlatforms.length === 0) return;
|
||||
|
||||
setScheduling(true);
|
||||
setDone(false);
|
||||
const total = schedule.length;
|
||||
setProgress({ current: 0, total, results: [] });
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < schedule.length; i++) {
|
||||
const { clip, index, date } = schedule[i];
|
||||
|
||||
// Build local datetime string: "2026-04-06T12:00:00"
|
||||
// Upload-Post accepts this + timezone IANA parameter
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const scheduledDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${time}:00`;
|
||||
|
||||
const payload = {
|
||||
job_id: jobId,
|
||||
clip_index: index,
|
||||
api_key: uploadPostKey,
|
||||
user_id: uploadUserId,
|
||||
platforms: selectedPlatforms,
|
||||
title: clip.video_title_for_youtube_short || 'Viral Short',
|
||||
description: clip.video_description_for_instagram || clip.video_description_for_tiktok || '',
|
||||
scheduled_date: scheduledDate,
|
||||
timezone
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(getApiUrl('/api/social/post'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(errText);
|
||||
}
|
||||
|
||||
results.push({ index: i, success: true });
|
||||
} catch (e) {
|
||||
results.push({ index: i, success: false, error: e.message });
|
||||
}
|
||||
|
||||
setProgress({ current: i + 1, total, results: [...results] });
|
||||
}
|
||||
|
||||
setDone(true);
|
||||
setScheduling(false);
|
||||
};
|
||||
|
||||
const successCount = progress.results.filter(r => r.success).length;
|
||||
const failCount = progress.results.filter(r => !r.success).length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
|
||||
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-lg shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={scheduling}
|
||||
className="absolute top-4 right-4 text-zinc-500 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
|
||||
<Calendar size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Programar Semana</h3>
|
||||
<p className="text-xs text-zinc-500">{clips?.length || 0} clips · 1 por día</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!uploadPostKey && (
|
||||
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||
<div>Configura tu API Key de Upload-Post en Settings primero.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time + Timezone */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
|
||||
<Clock size={14} className="text-purple-400" />
|
||||
Hora
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
disabled={scheduling}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-2 flex items-center gap-2">
|
||||
<Globe size={14} className="text-indigo-400" />
|
||||
Zona horaria
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
disabled={scheduling}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-indigo-500/50 appearance-none cursor-pointer"
|
||||
>
|
||||
{TIMEZONES.map(tz => (
|
||||
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start day offset */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-zinc-400">Empezar desde</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setStartOffset(Math.max(1, startOffset - 1))}
|
||||
disabled={startOffset <= 1 || scheduling}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm text-white font-medium min-w-[90px] text-center">
|
||||
{(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + startOffset);
|
||||
return `${getDayLabel(d)} ${formatDate(d)}`;
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setStartOffset(startOffset + 1)}
|
||||
disabled={scheduling}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="mb-5 space-y-2">
|
||||
{schedule.map(({ clip, index, date }) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-white/5 rounded-xl border border-white/5 hover:border-white/10 transition-colors">
|
||||
<div className="w-14 shrink-0 text-center">
|
||||
<div className="text-[10px] font-bold text-purple-400 uppercase">{getDayLabel(date)}</div>
|
||||
<div className="text-lg font-bold text-white leading-tight">{date.getDate()}</div>
|
||||
<div className="text-[10px] text-zinc-500">{MONTHS[date.getMonth()]}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-bold text-white truncate">
|
||||
Clip {index + 1}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 truncate">
|
||||
{clip.video_title_for_youtube_short || 'Viral Short'}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 mt-0.5">
|
||||
{time}h · {TIMEZONES.find(t => t.value === timezone)?.label || timezone}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{progress.results[index]?.success === true && (
|
||||
<CheckCircle size={18} className="text-green-400" />
|
||||
)}
|
||||
{progress.results[index]?.success === false && (
|
||||
<AlertCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
{scheduling && progress.current === index && (
|
||||
<Loader2 size={18} className="text-purple-400 animate-spin" />
|
||||
)}
|
||||
{!scheduling && progress.results[index] === undefined && (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-zinc-700" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-zinc-400 mb-2">Plataformas</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPlatforms(p => ({ ...p, tiktok: !p.tiktok }))}
|
||||
disabled={scheduling}
|
||||
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.tiktok ? 'bg-cyan-500/10 border-cyan-500/30 text-cyan-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
|
||||
>
|
||||
<Video size={14} /> TikTok
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlatforms(p => ({ ...p, instagram: !p.instagram }))}
|
||||
disabled={scheduling}
|
||||
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.instagram ? 'bg-pink-500/10 border-pink-500/30 text-pink-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
|
||||
>
|
||||
<Instagram size={14} /> Instagram
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlatforms(p => ({ ...p, youtube: !p.youtube }))}
|
||||
disabled={scheduling}
|
||||
className={`flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg text-xs font-bold border transition-all ${platforms.youtube ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-white/5 border-white/5 text-zinc-500'}`}
|
||||
>
|
||||
<Youtube size={14} /> YouTube
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{(scheduling || done) && (
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||
<span>{scheduling ? 'Programando...' : 'Completado'}</span>
|
||||
<span>{progress.current}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${done && failCount === 0 ? 'bg-green-500' : done && failCount > 0 ? 'bg-yellow-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{done && (
|
||||
<div className="mt-3 text-xs text-center">
|
||||
{failCount === 0 ? (
|
||||
<span className="text-green-400">Todos los clips programados correctamente</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">{successCount} programados, {failCount} fallidos</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={scheduling}
|
||||
className="flex-1 py-3 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-xl font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{done ? 'Cerrar' : 'Cancelar'}
|
||||
</button>
|
||||
{!done ? (
|
||||
<button
|
||||
onClick={handleScheduleAll}
|
||||
disabled={scheduling || !uploadPostKey || selectedPlatforms.length === 0}
|
||||
className="flex-1 py-3 bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-400 hover:to-indigo-500 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{scheduling ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Programando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar size={16} />
|
||||
Programar {clips?.length || 0} Clips
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://app.upload-post.com/calendar"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-3 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-400 hover:to-purple-500 text-white rounded-xl font-bold transition-all flex items-center justify-center gap-2 no-underline"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Ver Calendario
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Type, Loader2 } from 'lucide-react';
|
||||
import { getApiUrl } from '../config';
|
||||
import RemotionPreview from './RemotionPreview';
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: 'Verdana', label: 'Verdana' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Impact', label: 'Impact' },
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
{ value: 'Courier New', label: 'Courier New' },
|
||||
];
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
{ color: '#FFFFFF', label: 'White' },
|
||||
{ color: '#FFFF00', label: 'Yellow' },
|
||||
{ color: '#00FFFF', label: 'Cyan' },
|
||||
{ color: '#00FF00', label: 'Green' },
|
||||
{ color: '#FF0000', label: 'Red' },
|
||||
{ color: '#FF69B4', label: 'Pink' },
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: 'pop', label: 'Pop' },
|
||||
{ value: 'word-highlight', label: 'Glow' },
|
||||
{ value: 'karaoke', label: 'Karaoke' },
|
||||
{ value: 'none', label: 'None' },
|
||||
];
|
||||
|
||||
export default function SubtitleModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, jobId, clipIndex, existingHook }) {
|
||||
const [position, setPosition] = useState('bottom');
|
||||
const [fontSize, setFontSize] = useState(24);
|
||||
const [fontName, setFontName] = useState('Verdana');
|
||||
const [fontColor, setFontColor] = useState('#FFFFFF');
|
||||
const [highlightColor, setHighlightColor] = useState('#FFDD00');
|
||||
const [borderColor, setBorderColor] = useState('#000000');
|
||||
const [borderWidth, setBorderWidth] = useState(2);
|
||||
const [bgColor, setBgColor] = useState('#000000');
|
||||
const [bgOpacity, setBgOpacity] = useState(0.0);
|
||||
const [animation, setAnimation] = useState('pop');
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
|
||||
// Remotion preview state
|
||||
const [captions, setCaptions] = useState([]);
|
||||
const [originalCaptions, setOriginalCaptions] = useState([]);
|
||||
const [editableText, setEditableText] = useState('');
|
||||
const [durationSec, setDurationSec] = useState(30);
|
||||
const [captionsLoading, setCaptionsLoading] = useState(false);
|
||||
const [useRemotionPreview, setUseRemotionPreview] = useState(false);
|
||||
|
||||
// Fetch word-level captions when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen || !jobId || clipIndex === undefined) return;
|
||||
|
||||
setCaptionsLoading(true);
|
||||
fetch(getApiUrl(`/api/clip/${jobId}/${clipIndex}/transcript`))
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data) => {
|
||||
if (data && data.captions && data.captions.length > 0) {
|
||||
setCaptions(data.captions);
|
||||
setOriginalCaptions(data.captions);
|
||||
setEditableText(data.captions.map(c => c.text).join(' '));
|
||||
setDurationSec(data.durationSec || 30);
|
||||
setUseRemotionPreview(true);
|
||||
} else {
|
||||
setUseRemotionPreview(false);
|
||||
}
|
||||
})
|
||||
.catch(() => setUseRemotionPreview(false))
|
||||
.finally(() => setCaptionsLoading(false));
|
||||
}, [isOpen, jobId, clipIndex]);
|
||||
|
||||
// When user edits text, redistribute words across original timestamps
|
||||
const handleTextEdit = (newText) => {
|
||||
setEditableText(newText);
|
||||
const newWords = newText.split(/\s+/).filter(w => w.length > 0);
|
||||
if (newWords.length === 0 || originalCaptions.length === 0) {
|
||||
setCaptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Distribute new words across the time span of original captions
|
||||
const totalDurationMs = originalCaptions[originalCaptions.length - 1].endMs - originalCaptions[0].startMs;
|
||||
const startMs = originalCaptions[0].startMs;
|
||||
const wordDurationMs = totalDurationMs / newWords.length;
|
||||
|
||||
const newCaptions = newWords.map((word, i) => ({
|
||||
text: word,
|
||||
startMs: Math.round(startMs + i * wordDurationMs),
|
||||
endMs: Math.round(startMs + (i + 1) * wordDurationMs),
|
||||
}));
|
||||
setCaptions(newCaptions);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Build subtitle config for Remotion
|
||||
const subtitleConfig = {
|
||||
captions,
|
||||
position,
|
||||
style: {
|
||||
fontFamily: fontName,
|
||||
fontSize: fontSize * 2.2, // Scale up for 1080p (modal fontSize is for small preview)
|
||||
fontColor,
|
||||
highlightColor,
|
||||
borderColor,
|
||||
borderWidth: borderWidth * 1.5,
|
||||
bgColor,
|
||||
bgOpacity,
|
||||
animation,
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback: static CSS preview (same as original)
|
||||
const bw = Math.max(borderWidth, 0);
|
||||
const bc = borderColor;
|
||||
const outlineShadow = bw > 0 ? [
|
||||
`-${bw}px -${bw}px 0 ${bc}`, `${bw}px -${bw}px 0 ${bc}`,
|
||||
`-${bw}px ${bw}px 0 ${bc}`, `${bw}px ${bw}px 0 ${bc}`,
|
||||
`0 -${bw}px 0 ${bc}`, `0 ${bw}px 0 ${bc}`,
|
||||
`-${bw}px 0 0 ${bc}`, `${bw}px 0 0 ${bc}`,
|
||||
].join(', ') : 'none';
|
||||
|
||||
const fallbackPreviewStyle = {
|
||||
fontFamily: fontName,
|
||||
color: fontColor,
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
maxWidth: '85%',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.3',
|
||||
...(bgOpacity > 0
|
||||
? {
|
||||
backgroundColor: `${bgColor}${Math.round(bgOpacity * 255).toString(16).padStart(2, '0')}`,
|
||||
textShadow: 'none',
|
||||
}
|
||||
: { textShadow: outlineShadow }
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
|
||||
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-5xl shadow-2xl relative flex flex-col md:flex-row gap-6 max-h-[90vh]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-zinc-500 hover:text-white z-10"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Left: Preview */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-black rounded-lg border border-white/5 overflow-hidden relative aspect-[9/16] max-h-[600px]">
|
||||
{captionsLoading ? (
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-sm">Loading preview...</span>
|
||||
</div>
|
||||
) : useRemotionPreview ? (
|
||||
<RemotionPreview
|
||||
videoUrl={videoUrl}
|
||||
durationInSeconds={durationSec}
|
||||
subtitles={subtitleConfig}
|
||||
hook={existingHook || null}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<video src={videoUrl} className="w-full h-full object-contain opacity-50" muted playsInline />
|
||||
<div className={`absolute w-full px-8 text-center transition-all duration-300 pointer-events-none flex flex-col items-center justify-center
|
||||
${position === 'top' ? 'top-20' : ''}
|
||||
${position === 'middle' ? 'top-0 bottom-0' : ''}
|
||||
${position === 'bottom' ? 'bottom-20' : ''}
|
||||
`}>
|
||||
<span style={fallbackPreviewStyle}>
|
||||
This is how your subtitles<br/>will appear on the video
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="w-full md:w-80 flex flex-col">
|
||||
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2 shrink-0">
|
||||
<Type className="text-primary" /> Auto Subtitles
|
||||
</h3>
|
||||
|
||||
<div className="space-y-5 flex-1 overflow-y-auto custom-scrollbar pr-1">
|
||||
{/* Position Selector */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Position</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['top', 'middle', 'bottom'].map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => setPosition(pos)}
|
||||
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${position === pos ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
|
||||
>
|
||||
{pos.charAt(0).toUpperCase() + pos.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animation Style (new) */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Animation</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ANIMATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setAnimation(opt.value)}
|
||||
className={`p-2 rounded-lg border text-center text-xs font-medium transition-all ${animation === opt.value ? 'bg-primary/20 border-primary text-white' : 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Transcript (collapsible) */}
|
||||
{useRemotionPreview && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTextEditor(!showTextEditor)}
|
||||
className="w-full flex items-center justify-between text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2"
|
||||
>
|
||||
<span>Edit Text ({captions.length} words)</span>
|
||||
<span className={`transition-transform ${showTextEditor ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
{showTextEditor && (
|
||||
<textarea
|
||||
value={editableText}
|
||||
onChange={(e) => handleTextEdit(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-primary/50 resize-none leading-relaxed animate-[fadeIn_0.15s_ease-out]"
|
||||
placeholder="Edit subtitle text..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font Family */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Font</label>
|
||||
<select
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50"
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Text Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_PRESETS.map((c) => (
|
||||
<button
|
||||
key={c.color}
|
||||
onClick={() => setFontColor(c.color)}
|
||||
className={`w-7 h-7 rounded-full border-2 transition-all ${fontColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
|
||||
style={{ backgroundColor: c.color }}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
<label className="w-7 h-7 rounded-full border-2 border-dashed border-white/20 cursor-pointer flex items-center justify-center hover:border-white/50 transition-all overflow-hidden relative" title="Custom color">
|
||||
<span className="text-[10px] text-zinc-400">+</span>
|
||||
<input type="color" value={fontColor} onChange={(e) => setFontColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Highlight Color (new) */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Highlight Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[{ color: '#FFDD00', label: 'Gold' }, { color: '#FF4444', label: 'Red' }, { color: '#00FF88', label: 'Green' }, { color: '#00BBFF', label: 'Blue' }, { color: '#FF69B4', label: 'Pink' }].map((c) => (
|
||||
<button
|
||||
key={c.color}
|
||||
onClick={() => setHighlightColor(c.color)}
|
||||
className={`w-7 h-7 rounded-full border-2 transition-all ${highlightColor === c.color ? 'border-white scale-110' : 'border-white/20 hover:border-white/50'}`}
|
||||
style={{ backgroundColor: c.color }}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border / Outline */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2 block">Border</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Border color">
|
||||
<div className="w-full h-full" style={{ backgroundColor: borderColor }} />
|
||||
<input type="color" value={borderColor} onChange={(e) => setBorderColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
</label>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5"
|
||||
value={borderWidth}
|
||||
onChange={(e) => setBorderWidth(parseInt(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-zinc-500">
|
||||
<span>None</span>
|
||||
<span>Thick</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Background Box</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" checked={bgOpacity > 0} onChange={(e) => setBgOpacity(e.target.checked ? 0.5 : 0)} className="sr-only peer" />
|
||||
<div className="w-8 h-4 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[0px] after:left-[0px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
{bgOpacity > 0 && (
|
||||
<div className="space-y-3 animate-[fadeIn_0.2s_ease-out]">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative w-8 h-8 rounded-lg border border-white/10 cursor-pointer overflow-hidden shrink-0" title="Background color">
|
||||
<div className="w-full h-full" style={{ backgroundColor: bgColor }} />
|
||||
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
</label>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={Math.round(bgOpacity * 100)}
|
||||
onChange={(e) => setBgOpacity(parseInt(e.target.value) / 100)}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-zinc-500">
|
||||
<span>Transparent</span>
|
||||
<span>{Math.round(bgOpacity * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onGenerate({
|
||||
position, fontSize, fontName, fontColor, borderColor, borderWidth, bgColor, bgOpacity,
|
||||
// Remotion data
|
||||
remotion: useRemotionPreview ? subtitleConfig : null,
|
||||
})}
|
||||
disabled={isProcessing}
|
||||
className="w-full py-3 mt-4 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-400 hover:to-orange-400 text-black font-bold rounded-xl shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 shrink-0"
|
||||
>
|
||||
{isProcessing ? <Loader2 size={20} className="animate-spin" /> : <Type size={20} />}
|
||||
{isProcessing ? 'Generating...' : 'Generate Subtitles'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 328 KiB |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||