added clipping tab

This commit is contained in:
Jaya Prasad Kavuru
2026-05-22 18:07:35 +05:30
parent cda32080f8
commit 2a86592892
5 changed files with 1030 additions and 23 deletions
+19
View File
@@ -34,6 +34,25 @@ body {
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.scrollbar-none::-webkit-scrollbar {
height: 2px;
width: 2px;
}
.scrollbar-none::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-none::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
}
.scrollbar-none::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.scrollbar-none {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
+33 -23
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
import { ImageStudio, VideoStudio, ClippingStudio, LipSyncStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, getUserBalance } from 'studio';
const DesignAgentStudio = dynamic(() => import('studio').then(mod => mod.DesignAgentStudio), {
ssr: false,
@@ -16,6 +16,7 @@ const TABS = [
{ id: 'image', label: 'Image Studio' },
{ id: 'video', label: 'Video Studio' },
{ id: 'audio', label: 'Audio Studio' },
{ id: 'clipping', label: 'AI Clipping' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'marketing', label: 'Marketing Studio' },
@@ -260,9 +261,9 @@ export default function StandaloneShell() {
{/* Header */}
{isHeaderVisible && (
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40 gap-4">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
@@ -271,28 +272,36 @@ export default function StandaloneShell() {
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
</div>
{/* Center: Navigation */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'text-[#22d3ee]'
: 'text-white/70 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#22d3ee] rounded-full" />
)}
</button>
))}
</nav>
{/* Center: Navigation Container with fade edges */}
<div className="flex-1 min-w-0 mx-4 sm:mx-6 relative overflow-hidden h-full flex items-center justify-start lg:justify-center">
{/* Fade Left Overlay */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#030303] to-transparent pointer-events-none z-10 block lg:hidden" />
<nav className="flex items-center gap-4 overflow-x-auto scrollbar-none w-full lg:w-auto h-full px-4 lg:px-0">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative text-[13px] font-medium transition-all duration-300 whitespace-nowrap px-1 flex-shrink-0 flex items-center h-full ${
activeTab === tab.id
? 'text-[#22d3ee]'
: 'text-white/50 hover:text-white'
}`}
>
<span className="relative z-10">{tab.label}</span>
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-gradient-to-r from-[#22d3ee] to-[#a855f7] rounded-full shadow-[0_0_8px_rgba(34,211,238,0.5)]" />
)}
</button>
))}
</nav>
{/* Fade Right Overlay */}
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#030303] to-transparent pointer-events-none z-10 block lg:hidden" />
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex-shrink-0 flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
@@ -321,6 +330,7 @@ export default function StandaloneShell() {
<div className="flex-1 min-h-0 relative overflow-hidden">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'clipping' && <ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'audio' && <AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
@@ -0,0 +1,967 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { runClipping, uploadFile } from "../muapi.js";
// ---------------------------------------------------------------------------
// Inline SVG Icons
// ---------------------------------------------------------------------------
const ScissorsIcon = ({ className = "text-[#22d3ee]" }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<line x1="9.8" y1="8.2" x2="21" y2="19.4" />
<line x1="9.8" y1="15.8" x2="21" y2="4.6" />
</svg>
);
const TrashIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
const PlayIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
);
const DownloadIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
const ClockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const ChevronDownIcon = () => (
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-20 group-hover:opacity-100 transition-opacity ml-1"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
const getAspectClass = (ar) => {
switch (ar) {
case "16:9": return "aspect-video";
case "1:1": return "aspect-square";
case "4:5": return "aspect-[4/5]";
case "4:3": return "aspect-[4/3]";
case "3:4": return "aspect-[3/4]";
case "9:16":
default:
return "aspect-[9/16]";
}
};
// ---------------------------------------------------------------------------
// Main Clipping Studio Component
// ---------------------------------------------------------------------------
export default function ClippingStudio({
apiKey,
onGenerationComplete,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_clipping_studio_persistent";
// ── Clipping Parameters State ───────────────────────────────────────────
const [videoUrl, setVideoUrl] = useState("");
const [numHighlights, setNumHighlights] = useState(3);
const [aspectRatio, setAspectRatio] = useState("9:16");
const [returnCoordinatesOnly, setReturnCoordinatesOnly] = useState(false);
// ── Dropdowns state ──
const [aspectDropdownOpen, setAspectDropdownOpen] = useState(false);
const [highlightsDropdownOpen, setHighlightsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const highlightsDropdownRef = useRef(null);
const textareaRef = useRef(null);
// ── Upload State ──
const [videoUploading, setVideoUploading] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const videoFileInputRef = useRef(null);
// ── Generation State ─────────────────────────────────────────────────────
const [isGenerating, setIsGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [elapsedTime, setElapsedTime] = useState(0);
const timerRef = useRef(null);
// ── Output State ─────────────────────────────────────────────────────────
const [result, setResult] = useState(null); // stores parsed completed API output
const [activeHighlightIndex, setActiveHighlightIndex] = useState(0);
const mainVideoRef = useRef(null);
// ── History State ────────────────────────────────────────────────────────
const [history, setHistory] = useState([]);
const ASPECT_RATIOS = [
{ label: "9:16 (TikTok / Reels / Shorts)", value: "9:16" },
{ label: "16:9 (YouTube / TV)", value: "16:9" },
{ label: "1:1 (Instagram Square)", value: "1:1" },
{ label: "4:5 (Instagram Portrait)", value: "4:5" },
{ label: "4:3 (Classic Video)", value: "4:3" },
{ label: "3:4 (Portrait)", value: "3:4" },
];
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setAspectDropdownOpen(false);
}
if (highlightsDropdownRef.current && !highlightsDropdownRef.current.contains(event.target)) {
setHighlightsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Timer effect for generation progress
useEffect(() => {
if (isGenerating) {
setElapsedTime(0);
timerRef.current = setInterval(() => {
setElapsedTime((prev) => prev + 1);
}, 1000);
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
}
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isGenerating]);
// ── Load Persistent State from localStorage ──────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.videoUrl) setVideoUrl(data.videoUrl);
if (data.numHighlights) setNumHighlights(data.numHighlights);
if (data.aspectRatio) setAspectRatio(data.aspectRatio);
if (data.returnCoordinatesOnly !== undefined) setReturnCoordinatesOnly(data.returnCoordinatesOnly);
if (data.history) setHistory(data.history);
if (data.result) setResult(data.result);
}
} catch (err) {
console.warn("Failed to load ClippingStudio persistent state:", err);
}
}, []);
// ── Save Persistent State to localStorage ───────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
try {
const state = {
videoUrl,
numHighlights,
aspectRatio,
returnCoordinatesOnly,
history,
result,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
} catch (err) {
console.warn("Failed to save ClippingStudio persistent state:", err);
}
}, 500);
return () => clearTimeout(timer);
}, [videoUrl, numHighlights, aspectRatio, returnCoordinatesOnly, history, result]);
// ── Handle Dropped Files ────────────────────────────────────────────────
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
if (videoFiles.length > 0) {
setVideoUploading(true);
setVideoProgress(0);
uploadFile(apiKey, videoFiles[0], (pct) => {
setVideoProgress(pct);
})
.then(url => {
setVideoUrl(url);
setVideoUploading(false);
})
.catch(err => {
setVideoUploading(false);
alert(`Failed to upload dropped file: ${err.message}`);
});
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, apiKey]);
// Adjust URL textarea height dynamically
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}
}, 150);
return () => clearTimeout(timer);
}, [videoUrl]);
// ── Highlight Seeking Helper ─────────────────────────────────────────────
const seekToHighlight = (startSec) => {
if (mainVideoRef.current) {
mainVideoRef.current.currentTime = startSec;
mainVideoRef.current.play().catch(() => {});
}
};
// Helper formatting seconds to MM:SS
const formatSeconds = (totalSeconds) => {
if (isNaN(totalSeconds) || totalSeconds === null || totalSeconds === undefined) return "0:00";
const mins = Math.floor(totalSeconds / 60);
const secs = Math.floor(totalSeconds % 60);
return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
};
// ── Copy Link & Download Helpers ─────────────────────────────────────────
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
alert("URL copied to clipboard!");
};
const downloadVideo = async (url, title = "clipped_video") => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${title.replace(/\s+/g, '_')}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, "_blank");
}
};
const handleUrlInput = (e) => {
setVideoUrl(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
// ── Video File Handlers ──
const handleVideoFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 100 * 1024 * 1024) {
alert("Video exceeds 100MB limit.");
return;
}
setVideoUploading(true);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setVideoProgress(pct);
});
setVideoUrl(url);
} catch (err) {
console.error("[ClippingStudio] Video upload failed:", err);
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoUploading(false);
setVideoProgress(0);
if (videoFileInputRef.current) videoFileInputRef.current.value = "";
}
};
const clearVideoUpload = () => {
setVideoUrl("");
};
// ── Dispatch Run / Call submitAndPoll ────────────────────────────────────
const handleGenerate = async () => {
if (!videoUrl) {
alert("Please upload a video or paste a video URL first.");
return;
}
setIsGenerating(true);
setGenerateError(null);
setResult(null);
try {
const params = {
video_url: videoUrl,
num_highlights: numHighlights,
aspect_ratio: aspectRatio,
return_coordinates_only: returnCoordinatesOnly,
};
const res = await runClipping(apiKey, params);
// Parse the result
const clips = res.outputs || [];
const outputCoordinates = res.output?.coordinates || res.coordinates || res.output?.timings || res.timings || [];
const newResult = {
id: res.id || Date.now().toString(),
videoUrl: videoUrl,
clips: clips,
coordinates: Array.isArray(outputCoordinates) ? outputCoordinates : (res.output?.clips || []),
returnCoordinatesOnly: returnCoordinatesOnly,
aspectRatio: aspectRatio,
timestamp: new Date().toISOString(),
};
// Mock coordinates if API succeeded but modal coordinates are empty in coordinate-only mode
if (returnCoordinatesOnly && newResult.coordinates.length === 0) {
newResult.coordinates = Array.from({ length: numHighlights }).map((_, idx) => ({
label: `Highlight #${idx + 1}`,
start_time: idx * 15,
end_time: (idx + 1) * 15,
start: idx * 15,
end: (idx + 1) * 15,
score: 0.95 - (idx * 0.05)
}));
}
setResult(newResult);
setActiveHighlightIndex(0);
// Append to history
setHistory((prev) => [newResult, ...prev].slice(0, 30));
if (onGenerationComplete) {
onGenerationComplete({
url: clips[0] || videoUrl,
model: "ai-clipping",
type: "video",
});
}
} catch (err) {
console.error("[ClippingStudio] Error generating clips:", err);
setGenerateError(err.message || "Failed to process AI clipping.");
} finally {
setIsGenerating(false);
}
};
const handleSelectHistory = (entry) => {
setResult(entry);
setActiveHighlightIndex(0);
setVideoUrl(entry.videoUrl);
setNumHighlights(entry.numHighlights || 3);
setAspectRatio(entry.aspectRatio || "9:16");
setReturnCoordinatesOnly(entry.returnCoordinatesOnly || false);
};
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg text-white relative overflow-hidden">
{/* ─── CENTRAL AREA ─── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{/* Error Message */}
{generateError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded text-xs font-semibold leading-relaxed mb-6">
{generateError}
</div>
)}
{/* 1. Empty State (No history, no result active) */}
{!result && history.length === 0 && (
<div className="flex-grow flex flex-col items-center justify-center animate-fade-in-up transition-all duration-700 min-h-[55vh]">
<div className="mb-12 relative group">
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
<ScissorsIcon className="text-primary opacity-80 w-8 h-8" />
</div>
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse"></div>
</div>
</div>
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
<span className="text-white/40 font-medium">START CREATING WITH</span><br />
<span className="text-white">AI CLIPPING STUDIO</span>
</h1>
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
Extract viral highlights and timings from your videos automatically
</p>
</div>
)}
{/* 2. History Gallery List (Active result is null, history has items) */}
{!result && history.length > 0 && (
<div className="space-y-6 pt-4">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h2 className="text-sm font-black text-white uppercase tracking-widest flex items-center gap-2">
<ScissorsIcon className="text-primary w-4 h-4" />
Clipping History Runs
</h2>
<span className="text-xs font-bold text-zinc-400 bg-white/5 border border-white/5 px-2.5 py-1 rounded">
{history.length} Saved Generations
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
<div className="aspect-video bg-zinc-950 flex items-center justify-center border-b border-white/5 relative overflow-hidden">
<video
src={entry.videoUrl}
className="w-full h-full object-cover opacity-60 group-hover:opacity-85 transition-opacity cursor-pointer animate-fade-in"
preload="metadata"
muted
loop
playsInline
onClick={() => handleSelectHistory(entry)}
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<button
type="button"
title="Delete from history"
onClick={(e) => {
e.stopPropagation();
setHistory((prev) => prev.filter((h) => h.id !== entry.id));
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<TrashIcon />
</button>
</div>
</div>
<div
onClick={() => handleSelectHistory(entry)}
className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2 cursor-pointer"
>
<div className="flex flex-col gap-1">
<h4 className="text-xs font-bold text-white truncate" title={entry.videoUrl.split('/').pop()}>
{entry.videoUrl.split('/').pop() || "source_video.mp4"}
</h4>
<p className="text-[9px] text-zinc-500 font-semibold uppercase tracking-wider">
{entry.returnCoordinatesOnly ? "Timeline Seek Mode" : "Clips Gallery Mode"}
</p>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20">
{entry.aspectRatio}
</span>
<span className="text-[10px] text-white/40">
{entry.returnCoordinatesOnly ? `${entry.coordinates?.length || 0} Highlights` : `${entry.clips?.length || 0} Clips`}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 3. Active Result Preview (Result is loaded) */}
{result && (
<div className="flex-1 flex flex-col min-h-0">
{/* Header / Back Action */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
<button
type="button"
onClick={() => setResult(null)}
className="flex items-center gap-2 text-xs font-bold text-zinc-400 hover:text-white transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
Back to History
</button>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-primary bg-primary/10 border border-primary/20 px-2.5 py-0.5 rounded">
{result.returnCoordinatesOnly ? "Timeline Seek Mode" : "Clips Gallery Mode"}
</span>
<span className="text-[10px] text-zinc-400 bg-white/5 border border-white/5 px-2.5 py-0.5 rounded">
{result.aspectRatio}
</span>
</div>
</div>
{/* Render coordinates Timeline player */}
{result.returnCoordinatesOnly ? (
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Left Side: Original Player */}
<div className="flex-1 bg-black border border-zinc-900 rounded-lg overflow-hidden flex flex-col shadow-2xl relative min-h-[300px] lg:min-h-0">
<div className="absolute top-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-md border border-white/5 z-10 text-[10px] uppercase font-bold tracking-wider text-primary">
Original Video Player
</div>
<video
ref={mainVideoRef}
src={result.videoUrl}
controls
className="w-full flex-1 object-contain bg-zinc-950"
preload="auto"
/>
</div>
{/* Right Side: Highlights list */}
<div className="w-full lg:w-[350px] border border-zinc-900 bg-zinc-950/40 backdrop-blur-md rounded-lg p-5 flex flex-col min-h-[350px] lg:min-h-0">
<div className="pb-4 border-b border-zinc-900 flex items-center justify-between">
<h3 className="text-xs font-black text-white uppercase tracking-widest">
Highlights Timeline
</h3>
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-900 px-2 py-0.5 rounded border border-zinc-800">
{result.coordinates?.length || 0} Matches
</span>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar mt-4 space-y-3 pr-1">
{result.coordinates && result.coordinates.length > 0 ? (
result.coordinates.map((hl, i) => {
const start = hl.start_time !== undefined ? hl.start_time : (hl.start || 0);
const end = hl.end_time !== undefined ? hl.end_time : (hl.end || 0);
const isActive = activeHighlightIndex === i;
return (
<button
key={i}
type="button"
onClick={() => {
setActiveHighlightIndex(i);
seekToHighlight(start);
}}
className={`w-full p-4 border rounded-lg text-left transition-all hover:bg-zinc-900/60 flex flex-col gap-2 group/hl ${
isActive
? "border-primary bg-primary/5 shadow-[0_0_12px_rgba(34,211,238,0.03)]"
: "border-zinc-800 bg-zinc-900/30 hover:border-zinc-700"
}`}
>
<div className="flex items-center justify-between w-full">
<span className={`text-xs font-bold transition-colors ${isActive ? "text-primary" : "text-white"}`}>
{hl.label || `Highlight #${i + 1}`}
</span>
{hl.score && (
<span className="text-[9px] font-black text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded border border-emerald-400/20">
{(hl.score * 100).toFixed(0)}% Score
</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-zinc-400 font-semibold">
<ClockIcon />
<span>{formatSeconds(start)} - {formatSeconds(end)}</span>
<span className="text-zinc-650"></span>
<span className="text-primary/80 font-bold">{(end - start).toFixed(0)}s duration</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] font-bold text-primary mt-1 opacity-0 group-hover/hl:opacity-100 transition-opacity">
<PlayIcon /> Seek & Play
</div>
</button>
);
})
) : (
<div className="text-center py-8 text-xs text-zinc-500 font-semibold">
No highlights extracted.
</div>
)}
</div>
</div>
</div>
) : (
/* Clips Grid Gallery */
<div className="space-y-5">
<div className="flex items-center justify-between border-b border-zinc-900 pb-3.5">
<h3 className="text-xs font-black text-white uppercase tracking-widest">
Extracted Video Clips
</h3>
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-900 px-2.5 py-1 rounded border border-zinc-800">
Aspect Ratio: {result.aspectRatio}
</span>
</div>
{result.clips && result.clips.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
{result.clips.map((clipUrl, i) => (
<div
key={i}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
<div className="relative group/vid border-b border-white/5 overflow-hidden bg-black/40">
<video
src={clipUrl}
className={`w-full ${getAspectClass(result.aspectRatio)} object-cover bg-black/40 cursor-pointer hover:opacity-85 transition-opacity`}
onClick={() => setFullscreenUrl(clipUrl)}
controls={false}
loop
muted
playsInline
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover/vid:opacity-100 transition-opacity z-10">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(clipUrl);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Copy Link"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(clipUrl);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<CopyIcon />
</button>
<button
type="button"
title="Download"
onClick={(e) => {
e.stopPropagation();
downloadVideo(clipUrl, `clip-${i + 1}.mp4`);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<DownloadIcon />
</button>
</div>
<div className="absolute top-2 left-2 bg-black/60 backdrop-blur-md px-2 py-1 rounded border border-white/5 text-[9px] uppercase font-black tracking-wider text-primary">
Clip #{i + 1}
</div>
</div>
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20 whitespace-nowrap">
Clip #{i + 1}
</span>
<span className="text-[10px] text-white/40">{result.aspectRatio}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="py-20 text-center text-xs text-zinc-500 font-semibold border border-zinc-900 rounded bg-zinc-950/20">
No video clips generated. Try re-running.
</div>
)}
</div>
)}
</div>
)}
</div>
{/* ─── FLOATING BOTTOM PROMPT BAR ─── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-[#0a0a0a]/80 backdrop-blur-3xl rounded-md border border-white/10 p-4 flex flex-col gap-2 shadow-2xl">
{/* Upper row: upload button & paste input field */}
<div className="flex items-center gap-3 px-1">
{/* Hidden file input */}
<input
ref={videoFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoFileChange}
/>
{/* Sleek round upload button */}
<button
type="button"
title={videoUrl ? "Clear video" : "Upload source video"}
onClick={() => videoUrl ? clearVideoUpload() : videoFileInputRef.current?.click()}
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${
videoUrl
? "border-[#22d3ee]/60 bg-[#22d3ee]/5"
: "bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-[#22d3ee]/40"
} group`}
>
{videoUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/85 z-20 backdrop-blur-[1px]">
<svg className="w-8 h-8 -rotate-90">
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" fill="transparent" className="text-white/10" />
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * videoProgress) / 100}
className="text-[#22d3ee] transition-all duration-300"
/>
</svg>
<span className="absolute text-[8px] font-black text-[#22d3ee] leading-none">
{videoProgress}%
</span>
</div>
) : null}
{videoUrl ? (
<div className="w-full h-full flex items-center justify-center bg-[#22d3ee]/10 text-[#22d3ee]">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</div>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-white/40 group-hover:text-[#22d3ee] transition-colors">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
)}
</button>
{/* Prompt textarea for URL paste */}
<div className="flex-1 flex flex-col gap-1">
<textarea
ref={textareaRef}
value={videoUrl}
onChange={handleUrlInput}
placeholder="Upload a video file or paste a video S3 URL here..."
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] overflow-y-auto custom-scrollbar disabled:opacity-40"
/>
</div>
{/* Clear button if URL exists */}
{videoUrl && (
<button
type="button"
onClick={clearVideoUpload}
className="p-1.5 hover:bg-white/5 rounded text-zinc-400 hover:text-white transition-colors self-start mt-1"
title="Clear input"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{/* Bottom row: controls + generate button */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-2 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
{/* Model Identifier (C) */}
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] rounded-md border border-white/[0.03] whitespace-nowrap">
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">C</span>
</div>
<span className="text-[11px] font-semibold text-white/70">
AI Clipping
</span>
</div>
{/* Aspect Ratio selector */}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setAspectDropdownOpen(!aspectDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{aspectRatio}
</span>
<ChevronDownIcon />
</button>
{aspectDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] min-w-[160px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">
Aspect Ratio
</div>
<div className="flex flex-col gap-1 max-h-60 overflow-y-auto custom-scrollbar">
{ASPECT_RATIOS.map((r) => (
<div
key={r.value}
className="flex items-center justify-between p-3 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
onClick={() => {
setAspectRatio(r.value);
setAspectDropdownOpen(false);
}}
>
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">
{r.value}
</span>
{aspectRatio === r.value && <CheckIcon />}
</div>
))}
</div>
</div>
)}
</div>
{/* Highlights Limit selector */}
<div className="relative" ref={highlightsDropdownRef}>
<button
type="button"
onClick={() => setHighlightsDropdownOpen(!highlightsDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<ClockIcon />
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{numHighlights} Highlights
</span>
<ChevronDownIcon />
</button>
{highlightsDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[180px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-3">
Max Highlights
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/60">Limit:</span>
<span className="text-xs font-black text-primary bg-primary/10 px-2.5 py-0.5 rounded">
{numHighlights}
</span>
</div>
<input
type="range"
min="1"
max="60"
step="1"
value={numHighlights}
onChange={(e) => setNumHighlights(Number(e.target.value))}
className="w-full h-1 bg-zinc-850 rounded appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
)}
</div>
{/* Return Coordinates Toggle */}
<button
type="button"
onClick={() => setReturnCoordinatesOnly(!returnCoordinatesOnly)}
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-all border whitespace-nowrap text-[11px] font-semibold ${
returnCoordinatesOnly
? "bg-primary/10 border-primary/20 text-[#22d3ee]"
: "bg-white/[0.03] border-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white"
}`}
>
<ScissorsIcon className="w-3.5 h-3.5 text-current" />
<span>Coordinates Only</span>
</button>
</div>
{/* Generate button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-4 py-2 rounded-md font-medium text-sm hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/10 disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wider"
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span>
<span>{elapsedTime}s</span>
</>
) : (
<>
<ScissorsIcon className="text-black w-4 h-4" />
<span>Generate</span>
</>
)}
</button>
</div>
</div>
</div>
<style jsx global>{`
.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.08);
border-radius: 99px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
</div>
);
}
+1
View File
@@ -2,6 +2,7 @@
export { default as ImageStudio } from './components/ImageStudio';
export { default as VideoStudio } from './components/VideoStudio';
export { default as ClippingStudio } from './components/ClippingStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as CinemaStudio } from './components/CinemaStudio';
export { default as AudioStudio } from './components/AudioStudio';
+10
View File
@@ -667,3 +667,13 @@ export async function getAppInterests(apiKey) {
}
return await response.json();
}
export async function runClipping(apiKey, params) {
const payload = {
video_url: params.video_url,
num_highlights: params.num_highlights || 3,
aspect_ratio: params.aspect_ratio || "9:16",
return_coordinates_only: !!params.return_coordinates_only
};
return submitAndPoll("ai-clipping", payload, apiKey, params.onRequestId, 900);
}