summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/favicon.icobin4286 -> 0 bytes
-rw-r--r--apps/web/src/app/globals.css2
-rw-r--r--apps/web/src/app/layout.tsx37
-rw-r--r--apps/web/src/app/metadata.ts66
-rw-r--r--apps/web/src/app/page.tsx8
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx115
-rw-r--r--apps/web/src/components/editor/media-panel.tsx26
-rw-r--r--apps/web/src/components/editor/timeline.tsx52
-rw-r--r--apps/web/src/components/header.tsx13
-rw-r--r--apps/web/src/components/landing/hero.tsx27
-rw-r--r--apps/web/src/lib/media-processing.ts15
-rw-r--r--apps/web/src/stores/playback-store.ts6
12 files changed, 289 insertions, 78 deletions
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico
deleted file mode 100644
index cb20127b..00000000
--- a/apps/web/src/app/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 2169dbbc..1c8a7887 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -39,7 +39,7 @@
--sidebar-ring: 0 0% 3.9%;
}
.dark {
- --background: 0 0% 8%;
+ --background: 0 0% 6%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 5d33100d..ee045647 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,4 +1,3 @@
-import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
@@ -6,46 +5,14 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
+import { baseMetaData } from "./metadata";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
-export const metadata: Metadata = {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- openGraph: {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- url: "https://opencut.app",
- siteName: "OpenCut",
- locale: "en_US",
- type: "website",
- images: [
- {
- url: "https://opencut.app/opengraph-image.jpg",
- width: 1200,
- height: 630,
- alt: "OpenCut",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- creator: "@opencutapp",
- images: ["/opengraph-image.jpg"],
- },
- robots: {
- index: true,
- follow: true,
- },
-};
+export const metadata = baseMetaData;
export default function RootLayout({
children,
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
new file mode 100644
index 00000000..4e387014
--- /dev/null
+++ b/apps/web/src/app/metadata.ts
@@ -0,0 +1,66 @@
+import { Metadata } from "next";
+
+const title = "OpenCut";
+const description = "A simple but powerful video editor that gets the job done. In your browser.";
+const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
+const twitterImageUrl = "/opengraph-image.jpg";
+
+export const baseMetaData: Metadata = {
+ title: title,
+ description: description,
+ openGraph: {
+ title: title,
+ description: description,
+ url: "https://opencut.app",
+ siteName: "OpenCut",
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: openGraphImageUrl,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: title,
+ description: description,
+ creator: "@opencutapp",
+ images: [twitterImageUrl],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+ icons: {
+ icon: [
+ { url: "/favicon.ico" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ ],
+ apple: [
+ { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
+ { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
+ { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
+ { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
+ { url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
+ { url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
+ { url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
+ { url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
+ { url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
+ ],
+ shortcut: ["/favicon.ico"]
+ },
+ appleWebApp: {
+ capable: true,
+ title: title,
+ },
+ manifest: "/manifest.json",
+ other: {
+ "msapplication-config": "/browserconfig.xml"
+ }
+};
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index dcedab32..e9957e1b 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,6 +1,7 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { getWaitlistCount } from "@/lib/waitlist";
+import Image from "next/image";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
@@ -10,6 +11,13 @@ export default async function Home() {
return (
<div>
+ <Image
+ className="fixed top-0 left-0 -z-50 size-full object-cover"
+ src="/landing-page-bg.png"
+ height={1903.5}
+ width={1269}
+ alt="landing-page.bg"
+ />
<Header />
<Hero signupCount={signupCount} />
</div>
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
new file mode 100644
index 00000000..75a5ae06
--- /dev/null
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+interface AudioWaveformProps {
+ audioUrl: string;
+ height?: number;
+ className?: string;
+}
+
+const AudioWaveform: React.FC<AudioWaveformProps> = ({
+ audioUrl,
+ height = 32,
+ className = ''
+}) => {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || !audioUrl) return;
+
+ try {
+ // Clean up any existing instance
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+
+ wavesurfer.current = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: 'rgba(255, 255, 255, 0.6)',
+ progressColor: 'rgba(255, 255, 255, 0.9)',
+ cursorColor: 'transparent',
+ barWidth: 2,
+ barGap: 1,
+ height: height,
+ normalize: true,
+ interact: false,
+ });
+
+ // Event listeners
+ wavesurfer.current.on('ready', () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ wavesurfer.current.on('error', (err) => {
+ console.error('WaveSurfer error:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ await wavesurfer.current.load(audioUrl);
+
+ } catch (err) {
+ console.error('Failed to initialize WaveSurfer:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initWaveSurfer();
+
+ return () => {
+ mounted = false;
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+ };
+ }, [audioUrl, height]);
+
+ if (error) {
+ return (
+ <div className={`flex items-center justify-center ${className}`} style={{ height }}>
+ <span className="text-xs text-foreground/60">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-xs text-foreground/60">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
+ style={{ height }}
+ />
+ </div>
+ );
+};
+
+export default AudioWaveform; \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index f2b3f657..cdd67a86 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -17,27 +17,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
- // If no files, do nothing
- if (!files?.length) return;
-
+ if (!files || files.length === 0) return;
setIsProcessing(true);
+ setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
- const items = await processMediaFiles(files);
+ const processedItems = await processMediaFiles(files, (p) =>
+ setProgress(p)
+ );
// Add each processed media item to the store
- items.forEach((item) => {
- addMediaItem(item);
- });
+ processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
- // Show error if processing fails
- console.error("File processing failed:", error);
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
};
@@ -241,15 +242,12 @@ export function MediaPanel() {
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">Processing...</span>
+ <span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
- <span
- className="hidden sm:inline ml-2"
- aria-label="Add file"
- >
+ <span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ce0ffa30..1ecbe545 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -68,6 +69,7 @@ export function Timeline() {
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -333,8 +335,12 @@ export function Timeline() {
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -362,6 +368,7 @@ export function Timeline() {
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -566,15 +573,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Show overlay when dragging media over the timeline */}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- Drop media here to add to timeline
- </div>
- </div>
- )}
-
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@@ -602,10 +600,11 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
@@ -953,6 +952,15 @@ export function Timeline() {
)}
</>
)}
+ {isDragOver && (
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1626,7 +1634,21 @@ function TimelineTrackContent({
);
}
- // Fallback for audio or videos without thumbnails
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 27503cd0..74ba9359 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -29,8 +29,7 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
- <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
- <span className="font-medium tracking-tight">OpenCut</span>
+ <Image src="/logo.svg" alt="OpenCut Logo" width={142} height={32} />
</Link>
);
@@ -51,7 +50,7 @@ export function Header() {
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
- GitHub
+ GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
@@ -59,5 +58,11 @@ export function Header() {
</nav>
);
- return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
+ return (
+ <HeaderBase
+ className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ leftContent={leftContent}
+ rightContent={rightContent}
+ />
+ );
}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 7b4588c8..fadc043a 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
+import Image from "next/image";
interface HeroProps {
signupCount: number;
@@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
- <div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100vh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -93,14 +94,24 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
- className="inline-block"
+ className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
- The open source
- </h1>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- video editor
- </h1>
+ <h1>The First Open Source</h1>
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <span className="md:pl-24 ">Online</span>
+ <div className="relative -rotate-[3.76deg] max-w-[250px] md:max-w-[454px]">
+ <Image
+ className="mt-2.5 md:mt-0"
+ src="/frame.svg"
+ height={79}
+ width={459}
+ alt="frame"
+ />
+ <span className="absolute inset-0 flex items-center justify-center ">
+ Video Editor
+ </span>
+ </div>
+ </div>
</motion.div>
<motion.p
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index d241f411..daf22b34 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -11,11 +11,15 @@ import {
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
- files: FileList | File[]
+ files: FileList | File[],
+ onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
+ const total = fileArray.length;
+ let completed = 0;
+
for (const file of fileArray) {
const fileType = getFileType(file);
@@ -57,6 +61,15 @@ export async function processMediaFiles(
duration,
aspectRatio,
});
+
+ // Yield back to the event loop to keep the UI responsive
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress(percent);
+ }
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index 0127edff..0cc01cc1 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -21,7 +21,13 @@ const startTimer = (store: any) => {
const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
+ // When video completes, pause and reset playhead to start
state.pause();
+ state.setCurrentTime(0);
+ // Notify video elements to sync with reset
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", { detail: { time: 0 } })
+ );
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync