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