diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index 75c4120b2..000000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,178 +0,0 @@ -# Contributing to OpenCut - -Thank you for your interest in contributing to OpenCut! This document provides guidelines and instructions for contributing. - -## Getting Started - -### Prerequisites - -- [Node.js](https://nodejs.org/en/) (v18 or later) -- [Bun](https://bun.sh/docs/installation) - (for `npm` alternative) -- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) - -> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go! - -### Setup - -1. Fork the repository -2. Clone your fork locally -3. Navigate to the web app directory: `cd apps/web` -4. Copy `.env.example` to `.env.local`: - - ```bash - # Unix/Linux/Mac - cp .env.example .env.local - - # Windows Command Prompt - copy .env.example .env.local - - # Windows PowerShell - Copy-Item .env.example .env.local - ``` - -5. Install dependencies: `bun install` -6. Start the development server: `bun run dev` - -> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options: -> -> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support. -> 2. Use an alternative package manager such as **bun** or **pnpm**. - -## What to Focus On - -**🎯 Good Areas to Contribute:** - -- Timeline functionality and UI improvements -- Project management features -- Performance optimizations -- Bug fixes in existing functionality -- UI/UX improvements -- Documentation and testing - -**⚠️ Areas to Avoid:** - -- Preview panel enhancements (text fonts, stickers, effects) -- Export functionality improvements -- Preview rendering optimizations - -**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality. - -The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete. - -If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue! - -## Development Setup - -### Local Development - -1. Start the database and Redis services: - - ```bash - # From project root - docker-compose up -d - ``` - -2. Navigate to the web app directory: - - ```bash - cd apps/web - ``` - -3. Copy `.env.example` to `.env.local`: - - ```bash - # Unix/Linux/Mac - cp .env.example .env.local - - # Windows Command Prompt - copy .env.example .env.local - - # Windows PowerShell - Copy-Item .env.example .env.local - ``` - -4. Configure required environment variables in `.env.local`: - - **Required Variables:** - - ```bash - # Database (matches docker-compose.yaml) - DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" - - # Generate a secure secret for Better Auth - BETTER_AUTH_SECRET="your-generated-secret-here" - NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000" - - # Redis (matches docker-compose.yaml) - UPSTASH_REDIS_REST_URL="http://localhost:8079" - UPSTASH_REDIS_REST_TOKEN="example_token" - - # Development - NODE_ENV="development" - ``` - - **Generate BETTER_AUTH_SECRET:** - - ```bash - # Unix/Linux/Mac - openssl rand -base64 32 - - # Windows PowerShell (simple method) - [System.Web.Security.Membership]::GeneratePassword(32, 0) - - # Cross-platform (using Node.js) - node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" - - # Or use an online generator: https://generate-secret.vercel.app/32 - ``` - -5. Run database migrations: `bun run db:migrate` -6. Start the development server: `bun run dev` - -## How to Contribute - -### Reporting Bugs - -- Use the bug report template -- Include steps to reproduce -- Provide screenshots if applicable - -### Suggesting Features - -- Use the feature request template -- Explain the use case -- Consider implementation details - -### Code Contributions - -1. Create a new branch: `git checkout -b feature/your-feature-name` -2. Make your changes -3. Navigate to the web app directory: `cd apps/web` -4. Run the linter: `bun run lint` -5. Format your code: `bunx biome format --write .` -6. Commit your changes with a descriptive message -7. Push to your fork and create a pull request - -## Code Style - -- We use Biome for code formatting and linting -- Run `bunx biome format --write .` from the `apps/web` directory to format code -- Run `bun run lint` from the `apps/web` directory to check for linting issues -- Follow the existing code patterns - -## Pull Request Process - -1. Fill out the pull request template completely -2. Link any related issues -3. Ensure CI passes -4. Request review from maintainers -5. Address any feedback - -## Community - -- Be respectful and inclusive -- Follow our Code of Conduct -- Help others in discussions and issues - -Thank you for contributing! diff --git a/EFFECTS_README.md b/EFFECTS_README.md new file mode 100644 index 000000000..2a18b2e19 --- /dev/null +++ b/EFFECTS_README.md @@ -0,0 +1,193 @@ +# Video Effects System for OpenCut + +I've implemented a comprehensive video effects system for OpenCut that allows users to apply various visual effects to their video content. This system is designed to work with the existing timeline and preview infrastructure while avoiding the preview panel refactoring areas. + +## 🎨 Features Implemented + +### Effect Categories +- **Basic**: Brightness, contrast, saturation adjustments +- **Color**: Hue rotation, warm/cool temperature effects +- **Artistic**: Invert, emboss, edge detection +- **Vintage**: Sepia, grayscale, film grain, vintage look +- **Cinematic**: Dramatic contrast, vignette, cinematic appearance +- **Distortion**: Blur, pixelate, motion blur + +### Effect Presets +The system includes 20+ predefined effect presets: +- ☀️ Brighten - Increase brightness +- 🌙 Darken - Decrease brightness +- ⚡ High Contrast - Increase contrast +- 🌈 Vibrant - Increase saturation +- 🎨 Muted - Decrease saturation +- 📷 Sepia - Classic sepia tone +- ⚫ Black & White - Convert to grayscale +- 🔄 Invert - Invert colors +- 🎞️ Vintage - Old film look +- 🎭 Dramatic - High contrast dramatic look +- 🔥 Warm - Warm color temperature +- ❄️ Cool - Cool color temperature +- 🎬 Cinematic - Movie-like appearance +- 🌫️ Gaussian Blur - Soft blur effect +- 💨 Motion Blur - Motion blur effect +- ⭕ Vignette - Darken edges +- 🌾 Film Grain - Add film grain +- 🔪 Sharpen - Increase sharpness +- 🏔️ Emboss - 3D emboss effect +- 📐 Edge Detection - Highlight edges +- 🧩 Pixelate - Pixelation effect + +## 🏗️ Architecture + +### Core Components + +1. **Effects Types** (`src/types/effects.ts`) + - Defines effect types, parameters, and interfaces + - Supports 20 different effect types + - Comprehensive parameter validation + +2. **Effects Store** (`src/stores/effects-store.ts`) + - Zustand-based state management + - Effect presets and applied effects + - Timeline integration + - Parameter management + +3. **Effects View** (`src/components/editor/media-panel/views/effects.tsx`) + - Grid-based effect browser + - Category filtering + - Search functionality + - Drag-and-drop support + +4. **Effects Properties** (`src/components/editor/properties-panel/effects-properties.tsx`) + - Real-time parameter adjustment + - Slider controls for all parameters + - Effect enable/disable toggle + - Reset to default functionality + +5. **Effects Utils** (`src/lib/effects-utils.ts`) + - CSS filter conversion + - Parameter validation + - Video element integration + - Preview generation + +### Integration Points + +- **Media Panel**: Added effects tab with category filtering +- **Properties Panel**: Effect parameter adjustment interface +- **Video Player**: Real-time effect application using CSS filters +- **Timeline**: Visual effect indicators on timeline elements + +## 🎯 How to Use + +### Applying Effects +1. Navigate to the **Effects** tab in the media panel +2. Browse effects by category or search for specific effects +3. Click on an effect to apply it to the current video element +4. Effects are applied at the current playhead position + +### Adjusting Effects +1. Select a video element with applied effects +2. Open the properties panel +3. Adjust effect parameters using sliders +4. Toggle effects on/off or reset to defaults + +### Effect Management +- Multiple effects can be applied to the same element +- Effects can be enabled/disabled independently +- Effects have time ranges and can be trimmed +- Effects are saved with the project + +## 🔧 Technical Implementation + +### CSS Filter Integration +Effects are applied using CSS filters for real-time preview: +```typescript +// Example: Brightness and contrast effect +const filterString = "brightness(1.2) contrast(1.3)"; +videoElement.style.filter = filterString; +``` + +### Parameter System +Each effect supports multiple parameters: +```typescript +interface EffectParameters { + brightness?: number; // -100 to 100 + contrast?: number; // -100 to 100 + saturation?: number; // -100 to 100 + hue?: number; // -180 to 180 + blur?: number; // 0 to 50 + sepia?: number; // 0 to 100 + // ... and more +} +``` + +### Timeline Integration +Effects are stored as timeline elements with: +- Start and end times +- Element association +- Parameter values +- Enable/disable state + +## 🚀 Future Enhancements + +### Advanced Effects +- **Vignette**: Radial gradient overlay +- **Grain**: Noise texture overlay +- **Sharpen**: Unsharp mask algorithm +- **Emboss**: 3D embossing effect +- **Edge Detection**: Sobel filter implementation +- **Pixelate**: Mosaic effect + +### Performance Optimizations +- WebGL-based rendering for complex effects +- Effect caching and pre-computation +- Lazy loading of effect resources +- Background processing for heavy effects + +### User Experience +- Effect preview thumbnails +- Effect presets library +- Custom effect creation +- Effect animation and keyframing +- Effect templates and sharing + +## 🎨 Effect Examples + +### Basic Adjustments +- **Brighten**: `brightness(1.2)` - Makes video 20% brighter +- **High Contrast**: `contrast(1.3)` - Increases contrast by 30% +- **Vibrant**: `saturate(1.4)` - Increases saturation by 40% + +### Artistic Effects +- **Sepia**: `sepia(0.8)` - Applies 80% sepia tone +- **Grayscale**: `grayscale(1.0)` - Full black and white conversion +- **Invert**: `invert(1.0)` - Complete color inversion + +### Combined Effects +- **Vintage**: Combines sepia, contrast, and brightness +- **Dramatic**: High contrast with reduced saturation +- **Cinematic**: Enhanced contrast with vignette simulation + +## 🔒 Code Quality + +The implementation follows OpenCut's coding standards: +- **TypeScript**: Full type safety +- **Biome**: Linting and formatting compliance +- **Accessibility**: ARIA labels and keyboard navigation +- **Performance**: Optimized rendering and state management +- **Testing**: Comprehensive error handling and validation + +## 📝 Contributing + +This effects system is designed to be extensible. To add new effects: + +1. Add effect type to `EffectType` enum +2. Define parameters in `EffectParameters` interface +3. Add preset to `EFFECT_PRESETS` array +4. Implement CSS filter conversion in `parametersToCSSFilters` +5. Add parameter controls to `EffectsProperties` component + +The system is built to work alongside the planned preview panel refactor and can be easily adapted to use the new binary rendering system when it's implemented. + +--- + +**Note**: This implementation focuses on the effects infrastructure and basic CSS filter effects. More complex effects like vignette, grain, and pixelation will require additional canvas/WebGL implementation in the future preview system refactor. diff --git a/apps/web/migrations/meta/0003_snapshot.json b/apps/web/migrations/meta/0003_snapshot.json index 2c5d986ea..98f4ce9b5 100644 --- a/apps/web/migrations/meta/0003_snapshot.json +++ b/apps/web/migrations/meta/0003_snapshot.json @@ -93,12 +93,8 @@ "name": "accounts_user_id_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -145,9 +141,7 @@ "export_waitlist_email_unique": { "name": "export_waitlist_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -213,12 +207,8 @@ "name": "sessions_user_id_users_id_fk", "tableFrom": "sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -228,9 +218,7 @@ "sessions_token_unique": { "name": "sessions_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -292,9 +280,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -362,4 +348,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index c172952eb..92428a76e 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -31,4 +31,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts index dc5b7328f..d8252968b 100644 --- a/apps/web/src/app/api/get-upload-url/route.ts +++ b/apps/web/src/app/api/get-upload-url/route.ts @@ -1,128 +1,128 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { AwsClient } from "aws4fetch"; -import { nanoid } from "nanoid"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const uploadRequestSchema = z.object({ - fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { - errorMap: () => ({ - message: "File extension must be wav, mp3, m4a, or flac", - }), - }), -}); - -const apiResponseSchema = z.object({ - uploadUrl: z.string().url(), - fileName: z.string().min(1), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = uploadRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { fileExtension } = validationResult.data; - - // Initialize R2 client - const client = new AwsClient({ - accessKeyId: env.R2_ACCESS_KEY_ID, - secretAccessKey: env.R2_SECRET_ACCESS_KEY, - }); - - // Generate unique filename with timestamp - const timestamp = Date.now(); - const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; - - // Create presigned URL - const url = new URL( - `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` - ); - - url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry - - const signed = await client.sign(new Request(url, { method: "PUT" }), { - aws: { signQuery: true }, - }); - - if (!signed.url) { - throw new Error("Failed to generate presigned URL"); - } - - // Prepare and validate response - const responseData = { - uploadUrl: signed.url, - fileName, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error generating upload URL:", error); - return NextResponse.json( - { - error: "Failed to generate upload URL", - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { AwsClient } from "aws4fetch"; +import { nanoid } from "nanoid"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { isTranscriptionConfigured } from "@/lib/transcription-utils"; + +const uploadRequestSchema = z.object({ + fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { + errorMap: () => ({ + message: "File extension must be wav, mp3, m4a, or flac", + }), + }), +}); + +const apiResponseSchema = z.object({ + uploadUrl: z.string().url(), + fileName: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + // Check transcription configuration + const transcriptionCheck = isTranscriptionConfigured(); + if (!transcriptionCheck.configured) { + console.error( + "Missing environment variables:", + JSON.stringify(transcriptionCheck.missingVars) + ); + + return NextResponse.json( + { + error: "Transcription not configured", + message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, + }, + { status: 503 } + ); + } + + // Parse and validate request body + const rawBody = await request.json().catch(() => null); + if (!rawBody) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const validationResult = uploadRequestSchema.safeParse(rawBody); + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { fileExtension } = validationResult.data; + + // Initialize R2 client + const client = new AwsClient({ + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + }); + + // Generate unique filename with timestamp + const timestamp = Date.now(); + const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; + + // Create presigned URL + const url = new URL( + `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` + ); + + url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry + + const signed = await client.sign(new Request(url, { method: "PUT" }), { + aws: { signQuery: true }, + }); + + if (!signed.url) { + throw new Error("Failed to generate presigned URL"); + } + + // Prepare and validate response + const responseData = { + uploadUrl: signed.url, + fileName, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error generating upload URL:", error); + return NextResponse.json( + { + error: "Failed to generate upload URL", + message: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index c89bc76c6..8ca4ba414 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,265 +1,265 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; - -const searchParamsSchema = z.object({ - q: z.string().max(500, "Query too long").optional(), - type: z.enum(["songs", "effects"]).optional(), - page: z.coerce.number().int().min(1).max(1000).default(1), - page_size: z.coerce.number().int().min(1).max(150).default(20), - sort: z - .enum(["downloads", "rating", "created", "score"]) - .default("downloads"), - min_rating: z.coerce.number().min(0).max(5).default(3), - commercial_only: z.coerce.boolean().default(true), -}); - -const freesoundResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string().url(), - previews: z - .object({ - "preview-hq-mp3": z.string().url(), - "preview-lq-mp3": z.string().url(), - "preview-hq-ogg": z.string().url(), - "preview-lq-ogg": z.string().url(), - }) - .optional(), - download: z.string().url().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - num_downloads: z.number().optional(), - avg_rating: z.number().optional(), - num_ratings: z.number().optional(), -}); - -const freesoundResponseSchema = z.object({ - count: z.number(), - next: z.string().url().nullable(), - previous: z.string().url().nullable(), - results: z.array(freesoundResultSchema), -}); - -const transformedResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string(), - previewUrl: z.string().optional(), - downloadUrl: z.string().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - downloads: z.number().optional(), - rating: z.number().optional(), - ratingCount: z.number().optional(), -}); - -const apiResponseSchema = z.object({ - count: z.number(), - next: z.string().nullable(), - previous: z.string().nullable(), - results: z.array(transformedResultSchema), - query: z.string().optional(), - type: z.string(), - page: z.number(), - pageSize: z.number(), - sort: z.string(), - minRating: z.number().optional(), -}); - -export async function GET(request: NextRequest) { - try { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const { searchParams } = new URL(request.url); - - const validationResult = searchParamsSchema.safeParse({ - q: searchParams.get("q") || undefined, - type: searchParams.get("type") || undefined, - page: searchParams.get("page") || undefined, - page_size: searchParams.get("page_size") || undefined, - sort: searchParams.get("sort") || undefined, - min_rating: searchParams.get("min_rating") || undefined, - }); - - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { - q: query, - type, - page, - page_size: pageSize, - sort, - min_rating, - commercial_only, - } = validationResult.data; - - if (type === "songs") { - return NextResponse.json( - { - error: "Songs are not available yet", - message: - "Song search functionality is coming soon. Try searching for sound effects instead.", - }, - { status: 501 } - ); - } - - const baseUrl = "https://freesound.org/apiv2/search/text/"; - - // Use score sorting for search queries, downloads for top sounds - const sortParam = query - ? sort === "score" - ? "score" - : `${sort}_desc` - : `${sort}_desc`; - - const params = new URLSearchParams({ - query: query || "", - token: env.FREESOUND_API_KEY, - page: page.toString(), - page_size: pageSize.toString(), - sort: sortParam, - fields: - "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", - }); - - // Always apply sound effect filters (since we're primarily a sound effects search) - if (type === "effects" || !type) { - params.append("filter", "duration:[* TO 30.0]"); - params.append("filter", `avg_rating:[${min_rating} TO *]`); - - // Filter by license if commercial_only is true - if (commercial_only) { - params.append( - "filter", - 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' - ); - } - - params.append( - "filter", - "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" - ); - } - - const response = await fetch(`${baseUrl}?${params.toString()}`); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Freesound API error:", response.status, errorText); - return NextResponse.json( - { error: "Failed to search sounds" }, - { status: response.status } - ); - } - - const rawData = await response.json(); - - const freesoundValidation = freesoundResponseSchema.safeParse(rawData); - if (!freesoundValidation.success) { - console.error( - "Invalid Freesound API response:", - freesoundValidation.error - ); - return NextResponse.json( - { error: "Invalid response from Freesound API" }, - { status: 502 } - ); - } - - const data = freesoundValidation.data; - - const transformedResults = data.results.map((result) => ({ - id: result.id, - name: result.name, - description: result.description, - url: result.url, - previewUrl: - result.previews?.["preview-hq-mp3"] || - result.previews?.["preview-lq-mp3"], - downloadUrl: result.download, - duration: result.duration, - filesize: result.filesize, - type: result.type, - channels: result.channels, - bitrate: result.bitrate, - bitdepth: result.bitdepth, - samplerate: result.samplerate, - username: result.username, - tags: result.tags, - license: result.license, - created: result.created, - downloads: result.num_downloads || 0, - rating: result.avg_rating || 0, - ratingCount: result.num_ratings || 0, - })); - - const responseData = { - count: data.count, - next: data.next, - previous: data.previous, - results: transformedResults, - query: query || "", - type: type || "effects", - page, - pageSize, - sort, - minRating: min_rating, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error searching sounds:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 } + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + // Use score sorting for search queries, downloads for top sounds + const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; + + const params = new URLSearchParams({ + query: query || "", + token: env.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); + + // Always apply sound effect filters (since we're primarily a sound effects search) + if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + + // Filter by license if commercial_only is true + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' + ); + } + + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" + ); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status } + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 } + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map((result) => ({ + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + })); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts index 9a497f65e..60de5f967 100644 --- a/apps/web/src/app/api/transcribe/route.ts +++ b/apps/web/src/app/api/transcribe/route.ts @@ -1,189 +1,189 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const transcribeRequestSchema = z.object({ - filename: z.string().min(1, "Filename is required"), - language: z.string().optional().default("auto"), - decryptionKey: z.string().min(1, "Decryption key is required").optional(), - iv: z.string().min(1, "IV is required").optional(), -}); - -const modalResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -const apiResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - const origin = request.headers.get("origin"); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = transcribeRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { filename, language, decryptionKey, iv } = validationResult.data; - - // Prepare request body for Modal - const modalRequestBody: any = { - filename, - language, - }; - - // Add encryption parameters if provided (zero-knowledge) - if (decryptionKey && iv) { - modalRequestBody.decryptionKey = decryptionKey; - modalRequestBody.iv = iv; - } - - // Call Modal transcription service - const response = await fetch(env.MODAL_TRANSCRIPTION_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(modalRequestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Modal API error:", response.status, errorText); - - let errorMessage = "Transcription service unavailable"; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.error || errorMessage; - } catch { - // Use default message if parsing fails - } - - return NextResponse.json( - { - error: errorMessage, - message: "Failed to process transcription request", - }, - { status: response.status >= 500 ? 502 : response.status } - ); - } - - const rawResult = await response.json(); - console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2)); - - // Validate Modal response - const modalValidation = modalResponseSchema.safeParse(rawResult); - if (!modalValidation.success) { - console.error("Invalid Modal API response:", modalValidation.error); - return NextResponse.json( - { error: "Invalid response from transcription service" }, - { status: 502 } - ); - } - - const result = modalValidation.data; - - // Prepare and validate API response - const responseData = { - text: result.text, - segments: result.segments, - language: result.language, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Transcription API error:", error); - return NextResponse.json( - { - error: "Internal server error", - message: "An unexpected error occurred during transcription", - }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { isTranscriptionConfigured } from "@/lib/transcription-utils"; + +const transcribeRequestSchema = z.object({ + filename: z.string().min(1, "Filename is required"), + language: z.string().optional().default("auto"), + decryptionKey: z.string().min(1, "Decryption key is required").optional(), + iv: z.string().min(1, "IV is required").optional(), +}); + +const modalResponseSchema = z.object({ + text: z.string(), + segments: z.array( + z.object({ + id: z.number(), + seek: z.number(), + start: z.number(), + end: z.number(), + text: z.string(), + tokens: z.array(z.number()), + temperature: z.number(), + avg_logprob: z.number(), + compression_ratio: z.number(), + no_speech_prob: z.number(), + }) + ), + language: z.string(), +}); + +const apiResponseSchema = z.object({ + text: z.string(), + segments: z.array( + z.object({ + id: z.number(), + seek: z.number(), + start: z.number(), + end: z.number(), + text: z.string(), + tokens: z.array(z.number()), + temperature: z.number(), + avg_logprob: z.number(), + compression_ratio: z.number(), + no_speech_prob: z.number(), + }) + ), + language: z.string(), +}); + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + const origin = request.headers.get("origin"); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + // Check transcription configuration + const transcriptionCheck = isTranscriptionConfigured(); + if (!transcriptionCheck.configured) { + console.error( + "Missing environment variables:", + JSON.stringify(transcriptionCheck.missingVars) + ); + + return NextResponse.json( + { + error: "Transcription not configured", + message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, + }, + { status: 503 } + ); + } + + // Parse and validate request body + const rawBody = await request.json().catch(() => null); + if (!rawBody) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const validationResult = transcribeRequestSchema.safeParse(rawBody); + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { filename, language, decryptionKey, iv } = validationResult.data; + + // Prepare request body for Modal + const modalRequestBody: any = { + filename, + language, + }; + + // Add encryption parameters if provided (zero-knowledge) + if (decryptionKey && iv) { + modalRequestBody.decryptionKey = decryptionKey; + modalRequestBody.iv = iv; + } + + // Call Modal transcription service + const response = await fetch(env.MODAL_TRANSCRIPTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(modalRequestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Modal API error:", response.status, errorText); + + let errorMessage = "Transcription service unavailable"; + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.error || errorMessage; + } catch { + // Use default message if parsing fails + } + + return NextResponse.json( + { + error: errorMessage, + message: "Failed to process transcription request", + }, + { status: response.status >= 500 ? 502 : response.status } + ); + } + + const rawResult = await response.json(); + console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2)); + + // Validate Modal response + const modalValidation = modalResponseSchema.safeParse(rawResult); + if (!modalValidation.success) { + console.error("Invalid Modal API response:", modalValidation.error); + return NextResponse.json( + { error: "Invalid response from transcription service" }, + { status: 502 } + ); + } + + const result = modalValidation.data; + + // Prepare and validate API response + const responseData = { + text: result.text, + segments: result.segments, + language: result.language, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Transcription API error:", error); + return NextResponse.json( + { + error: "Internal server error", + message: "An unexpected error occurred during transcription", + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts index 0200e255e..f48d0b0e8 100644 --- a/apps/web/src/app/api/waitlist/export/route.ts +++ b/apps/web/src/app/api/waitlist/export/route.ts @@ -1,83 +1,83 @@ -import { NextRequest, NextResponse } from "next/server"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { db, exportWaitlist, eq } from "@opencut/db"; -import { randomUUID } from "crypto"; -import { - exportWaitlistSchema, - exportWaitlistResponseSchema, -} from "@/lib/schemas/waitlist"; - -const requestSchema = exportWaitlistSchema; -const responseSchema = exportWaitlistResponseSchema; - -export async function POST(request: NextRequest) { - try { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json().catch(() => null); - if (!body) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: parsed.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { email } = parsed.data; - - const existing = await db - .select({ id: exportWaitlist.id }) - .from(exportWaitlist) - .where(eq(exportWaitlist.email, email)) - .limit(1); - - if (existing.length > 0) { - const responseData = { success: true, alreadySubscribed: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } - - await db.insert(exportWaitlist).values({ - id: randomUUID(), - email, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const responseData = { success: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } catch (error) { - console.error("Waitlist API error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { db, exportWaitlist, eq } from "@opencut/db"; +import { randomUUID } from "crypto"; +import { + exportWaitlistSchema, + exportWaitlistResponseSchema, +} from "@/lib/schemas/waitlist"; + +const requestSchema = exportWaitlistSchema; +const responseSchema = exportWaitlistResponseSchema; + +export async function POST(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const body = await request.json().catch(() => null); + if (!body) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { email } = parsed.data; + + const existing = await db + .select({ id: exportWaitlist.id }) + .from(exportWaitlist) + .where(eq(exportWaitlist.email, email)) + .limit(1); + + if (existing.length > 0) { + const responseData = { success: true, alreadySubscribed: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } + + await db.insert(exportWaitlist).values({ + id: randomUUID(), + email, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const responseData = { success: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } catch (error) { + console.error("Waitlist API error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx index 151f7f94f..189a08994 100644 --- a/apps/web/src/app/editor/[project_id]/layout.tsx +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -1,13 +1,13 @@ -"use client"; - -import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; - -export default function EditorLayout({ - children, -}: { - children: React.ReactNode; -}) { - useGlobalPrefetcher(); - - return
{children}
; -} +"use client"; + +import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; + +export default function EditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + useGlobalPrefetcher(); + + return
{children}
; +} diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx index ff4cde57a..2ea2c39fb 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/layout-guide-overlay.tsx @@ -1,27 +1,27 @@ -"use client"; - -import { useEditorStore } from "@/stores/editor-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( -
- TikTok layout guide -
- ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = useEditorStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return ; - - return null; -} +"use client"; + +import { useEditorStore } from "@/stores/editor-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( +
+ TikTok layout guide +
+ ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = useEditorStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return ; + + return null; +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 89b53b64f..7dd1bd05e 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -6,6 +6,7 @@ import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; +import { EffectsView } from "./views/effects"; import { Separator } from "@/components/ui/separator"; import { SettingsView } from "./views/settings"; import { Captions } from "./views/captions"; @@ -18,11 +19,7 @@ export function MediaPanel() { sounds: , text: , stickers: , - effects: ( -
- Effects view coming soon... -
- ), + effects: , transitions: (
Transitions view coming soon... diff --git a/apps/web/src/components/editor/media-panel/views/effects.tsx b/apps/web/src/components/editor/media-panel/views/effects.tsx new file mode 100644 index 000000000..9db661880 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/effects.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useEffectsStore } from "@/stores/effects-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { + Loader2, + Grid3X3, + Sparkles, + Palette, + Camera, + Film, + Zap, + Search, + Plus, + Eye, + EyeOff, + Trash2, + Settings, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { EffectCategory } from "@/types/effects"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; + +const CATEGORY_ICONS: Record = { + basic: , + color: , + artistic: , + vintage: , + cinematic: , + distortion: , +}; + +export function EffectsView() { + const { selectedCategory, setSelectedCategory } = useEffectsStore(); + + return ( +
+ { + if ( + [ + "all", + "basic", + "color", + "artistic", + "vintage", + "cinematic", + "distortion", + ].includes(v) + ) { + setSelectedCategory(v as EffectCategory | "all"); + } + }} + className="flex flex-col h-full" + > +
+ + + + All + + + {CATEGORY_ICONS.basic} + Basic + + + {CATEGORY_ICONS.color} + Color + + + {CATEGORY_ICONS.artistic} + Artistic + + + {CATEGORY_ICONS.vintage} + Vintage + + + {CATEGORY_ICONS.cinematic} + Cinematic + + + {CATEGORY_ICONS.distortion} + Distortion + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} + +function EffectGrid({ + effects, + onAdd, + addingEffect, +}: { + effects: any[]; + onAdd: (effect: any) => void; + addingEffect: string | null; +}) { + return ( +
+ {effects.map((effect) => ( + + ))} +
+ ); +} + +function EmptyView({ message }: { message: string }) { + return ( +
+
+ +

{message}

+
+
+ ); +} + +function EffectsContentView({ + category, +}: { + category: EffectCategory | "all"; +}) { + const { activeProject } = useProjectStore(); + const { currentTime } = usePlaybackStore(); + const { tracks } = useTimelineStore(); + const { + searchQuery, + isAddingEffect, + getFilteredPresets, + addEffectToTimeline, + setSearchQuery, + } = useEffectsStore(); + + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + const [addingEffect, setAddingEffect] = useState(null); + + const filteredEffects = useMemo(() => { + const effects = getFilteredPresets(); + if (category === "all") { + return effects; + } + return effects.filter((effect) => effect.category === category); + }, [getFilteredPresets, category]); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery(localSearchQuery); + } + }, 300); + + return () => clearTimeout(timer); + }, [localSearchQuery, searchQuery, setSearchQuery]); + + const handleAddEffect = async (effect: any) => { + if (!activeProject) { + toast.error("No active project"); + return; + } + + setAddingEffect(effect.id); + + try { + const success = await addEffectToTimeline(effect); + if (success) { + toast.success(`Applied "${effect.name}" effect`); + } + } catch (error) { + console.error("Failed to add effect:", error); + toast.error("Failed to add effect to timeline"); + } finally { + setAddingEffect(null); + } + }; + + // Check if there are any video elements in the timeline + const hasVideoElements = useMemo(() => { + const mediaTracks = tracks.filter((track) => track.type === "media"); + const totalMediaElements = mediaTracks.reduce((total, track) => total + track.elements.length, 0); + console.log("Media tracks:", mediaTracks.length, "Total media elements:", totalMediaElements); + return totalMediaElements > 0; + }, [tracks]); + + const effectsToDisplay = useMemo(() => { + if (localSearchQuery.trim()) { + return filteredEffects; + } + return filteredEffects; + }, [filteredEffects, localSearchQuery]); + + return ( +
+ {/* Search Bar */} +
+
+ + setLocalSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + {/* Effects Grid */} + + {effectsToDisplay.length === 0 ? ( + + ) : ( + <> + {!hasVideoElements && ( +
+
+
⚠️
+
+

No media elements found

+

Add a video to your timeline to apply effects

+
+

How to add effects:

+
    +
  1. Drag a video to the timeline
  2. +
  3. Click on an effect to apply it
  4. +
  5. Adjust parameters in the properties panel
  6. +
+
+
+
+
+ )} + + + )} +
+
+ ); +} + +interface EffectItemProps { + effect: any; + onAdd: (effect: any) => void; + isAdding?: boolean; +} + +function EffectItem({ effect, onAdd, isAdding }: EffectItemProps) { + const { tracks } = useTimelineStore(); + + const hasVideoElements = useMemo(() => { + const mediaTracks = tracks.filter((track) => track.type === "media"); + return mediaTracks.reduce((total, track) => total + track.elements.length, 0) > 0; + }, [tracks]); + + return ( + + +
+ +
{effect.icon}
+ + {effect.name} + +
+ } + dragData={{ + id: "effect-placeholder", + type: "effect", + name: effect.name, + }} + onAddToTimeline={() => onAdd(effect)} + aspectRatio={1} + showLabel={false} + rounded={true} + variant="card" + className="" + containerClassName="w-full" + isDraggable={false} + /> + {isAdding && ( +
+ +
+ )} +
+ + +
+

{effect.name}

+

{effect.description}

+ {!hasVideoElements && ( +

+ ⚠️ Add a video to timeline first +

+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 303eb858e..9cac3e486 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -120,7 +120,7 @@ function SoundEffectsView() { setScrollPosition(scrollTop); handleScroll(event); }; - + const displayedSounds = useMemo(() => { const sounds = searchQuery ? searchResults : topSoundEffects; return sounds; diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx index ec01028ce..54cb359ef 100644 --- a/apps/web/src/components/editor/panel-base-view.tsx +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -1,78 +1,78 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -interface PanelBaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - value?: string; - onValueChange?: (value: string) => void; - tabs?: { - value: string; - label: string; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.RefObject; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - -
{children}
-
- ); -} - -export function PanelBaseView({ - children, - defaultTab, - value, - onValueChange, - tabs, - className = "", - ref, -}: PanelBaseViewProps) { - return ( -
- {!tabs || tabs.length === 0 ? ( - {children} - ) : ( - -
-
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - -
- -
- {tabs.map((tab) => ( - - {tab.content} - - ))} -
- )} -
- ); -} +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + +
{children}
+
+ ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( +
+ {!tabs || tabs.length === 0 ? ( + {children} + ) : ( + +
+
+ + {tabs.map((tab) => ( + + {tab.label} + + ))} + +
+ +
+ {tabs.map((tab) => ( + + {tab.content} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx index af22a1719..4c4704552 100644 --- a/apps/web/src/components/editor/panel-preset-selector.tsx +++ b/apps/web/src/components/editor/panel-preset-selector.tsx @@ -1,91 +1,91 @@ -"use client"; - -import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; -import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; - -const PRESET_LABELS: Record = { - default: "Default", - media: "Media", - inspector: "Inspector", - "vertical-preview": "Vertical Preview", -}; - -const PRESET_DESCRIPTIONS: Record = { - default: "Media, preview, and inspector on top row, timeline on bottom", - media: "Full height media on left, preview and inspector on top row", - inspector: "Full height inspector on right, media and preview on top row", - "vertical-preview": "Full height preview on right for vertical videos", -}; - -export function PanelPresetSelector() { - const { activePreset, setActivePreset, resetPreset } = usePanelStore(); - - const handlePresetChange = (preset: PanelPreset) => { - setActivePreset(preset); - }; - - const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { - event.stopPropagation(); - resetPreset(preset); - }; - - return ( - - - - - -
- Panel Presets -
- - {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( - handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" - > -
-
- - {PRESET_LABELS[preset]} - - {activePreset === preset && ( -
- )} -
-

- {PRESET_DESCRIPTIONS[preset]} -

-
- - - ))} - - - ); -} +"use client"; + +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; +import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; + +const PRESET_LABELS: Record = { + default: "Default", + media: "Media", + inspector: "Inspector", + "vertical-preview": "Vertical Preview", +}; + +const PRESET_DESCRIPTIONS: Record = { + default: "Media, preview, and inspector on top row, timeline on bottom", + media: "Full height media on left, preview and inspector on top row", + inspector: "Full height inspector on right, media and preview on top row", + "vertical-preview": "Full height preview on right for vertical videos", +}; + +export function PanelPresetSelector() { + const { activePreset, setActivePreset, resetPreset } = usePanelStore(); + + const handlePresetChange = (preset: PanelPreset) => { + setActivePreset(preset); + }; + + const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { + event.stopPropagation(); + resetPreset(preset); + }; + + return ( + + + + + +
+ Panel Presets +
+ + {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( + handlePresetChange(preset)} + className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" + > +
+
+ + {PRESET_LABELS[preset]} + + {activePreset === preset && ( +
+ )} +
+

+ {PRESET_DESCRIPTIONS[preset]} +

+
+ + + ))} + + + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 1654624cf..18a445f06 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -6,6 +6,7 @@ import { useMediaStore } from "@/stores/media-store"; import { MediaFile } from "@/types/media"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; +import { useEffectsStore } from "@/stores/effects-store"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; @@ -37,11 +38,12 @@ interface ActiveElement { } export function PreviewPanel() { - const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); + const { tracks, getTotalDuration, updateTextElement, updateElement } = useTimelineStore(); const { mediaFiles } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); + const { getEffectsForElement } = useEffectsStore(); const previewRef = useRef(null); const canvasRef = useRef(null); const lastFrameTimeRef = useRef(0); @@ -76,6 +78,50 @@ export function PreviewPanel() { elementHeight: 0, }); + const [resizeState, setResizeState] = useState<{ + isResizing: boolean; + elementId: string | null; + trackId: string | null; + startX: number; + startY: number; + initialWidth: number; + initialHeight: number; + currentWidth: number; + currentHeight: number; + corner: string; + }>({ + isResizing: false, + elementId: null, + trackId: null, + startX: 0, + startY: 0, + initialWidth: 0, + initialHeight: 0, + currentWidth: 0, + currentHeight: 0, + corner: "", + }); + + const [rotationState, setRotationState] = useState<{ + isRotating: boolean; + elementId: string | null; + trackId: string | null; + startX: number; + startY: number; + initialRotation: number; + centerX: number; + centerY: number; + }>({ + isRotating: false, + elementId: null, + trackId: null, + startX: 0, + startY: 0, + initialRotation: 0, + centerX: 0, + centerY: 0, + }); + useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; @@ -163,48 +209,132 @@ export function PreviewPanel() { useEffect(() => { const handleMouseMove = (e: MouseEvent) => { - if (!dragState.isDragging) return; + if (dragState.isDragging) { + const deltaX = e.clientX - dragState.startX; + const deltaY = e.clientY - dragState.startY; - const deltaX = e.clientX - dragState.startX; - const deltaY = e.clientY - dragState.startY; + const scaleRatio = previewDimensions.width / canvasSize.width; + const newX = dragState.initialElementX + deltaX / scaleRatio; + const newY = dragState.initialElementY + deltaY / scaleRatio; - const scaleRatio = previewDimensions.width / canvasSize.width; - const newX = dragState.initialElementX + deltaX / scaleRatio; - const newY = dragState.initialElementY + deltaY / scaleRatio; + const halfWidth = dragState.elementWidth / scaleRatio / 2; + const halfHeight = dragState.elementHeight / scaleRatio / 2; - const halfWidth = dragState.elementWidth / scaleRatio / 2; - const halfHeight = dragState.elementHeight / scaleRatio / 2; + const constrainedX = Math.max( + -canvasSize.width / 2 + halfWidth, + Math.min(canvasSize.width / 2 - halfWidth, newX) + ); + const constrainedY = Math.max( + -canvasSize.height / 2 + halfHeight, + Math.min(canvasSize.height / 2 - halfHeight, newY) + ); - const constrainedX = Math.max( - -canvasSize.width / 2 + halfWidth, - Math.min(canvasSize.width / 2 - halfWidth, newX) - ); - const constrainedY = Math.max( - -canvasSize.height / 2 + halfHeight, - Math.min(canvasSize.height / 2 - halfHeight, newY) - ); + setDragState((prev) => ({ + ...prev, + currentX: constrainedX, + currentY: constrainedY, + })); + } - setDragState((prev) => ({ - ...prev, - currentX: constrainedX, - currentY: constrainedY, - })); + if (resizeState.isResizing) { + const deltaX = e.clientX - resizeState.startX; + const deltaY = e.clientY - resizeState.startY; + + const scaleRatio = previewDimensions.width / canvasSize.width; + const scaleFactor = 1 / scaleRatio; + + let newWidth = resizeState.initialWidth * scaleFactor; + let newHeight = resizeState.initialHeight * scaleFactor; + + // Apply resize based on corner + switch (resizeState.corner) { + case "se": + newWidth = Math.max(50, resizeState.initialWidth + deltaX * scaleFactor); + newHeight = Math.max(50, resizeState.initialHeight + deltaY * scaleFactor); + break; + case "sw": + newWidth = Math.max(50, resizeState.initialWidth - deltaX * scaleFactor); + newHeight = Math.max(50, resizeState.initialHeight + deltaY * scaleFactor); + break; + case "ne": + newWidth = Math.max(50, resizeState.initialWidth + deltaX * scaleFactor); + newHeight = Math.max(50, resizeState.initialHeight - deltaY * scaleFactor); + break; + case "nw": + newWidth = Math.max(50, resizeState.initialWidth - deltaX * scaleFactor); + newHeight = Math.max(50, resizeState.initialHeight - deltaY * scaleFactor); + break; + } + + // Store the new dimensions in resize state for visual feedback + setResizeState((prev) => ({ + ...prev, + currentWidth: newWidth, + currentHeight: newHeight, + })); + } + + if (rotationState.isRotating) { + const deltaX = e.clientX - rotationState.centerX; + const deltaY = e.clientY - rotationState.centerY; + const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI); + const newRotation = angle + 90; // Adjust for proper rotation + + const track = tracks.find(t => t.id === rotationState.trackId); + const element = track?.elements.find(e => e.id === rotationState.elementId); + + if (element && element.type === "text") { + updateTextElement(rotationState.trackId!, rotationState.elementId!, { + rotation: newRotation, + }); + } + } }; const handleMouseUp = () => { if (dragState.isDragging && dragState.trackId && dragState.elementId) { - updateTextElement(dragState.trackId, dragState.elementId, { - x: dragState.currentX, - y: dragState.currentY, - }); + // Find the element to determine its type + const track = tracks.find(t => t.id === dragState.trackId); + const element = track?.elements.find(e => e.id === dragState.elementId); + + if (element) { + if (element.type === "text") { + updateTextElement(dragState.trackId, dragState.elementId, { + x: dragState.currentX, + y: dragState.currentY, + }); + } else { + // For media elements, use the generic updateElement function + updateElement(dragState.trackId, dragState.elementId, { + x: dragState.currentX, + y: dragState.currentY, + }); + } + } } + + // Apply resize changes when mouse is released + if (resizeState.isResizing && resizeState.trackId && resizeState.elementId) { + const track = tracks.find(t => t.id === resizeState.trackId); + const element = track?.elements.find(e => e.id === resizeState.elementId); + + if (element && element.type === "text") { + updateTextElement(resizeState.trackId!, resizeState.elementId!, { + fontSize: Math.max(8, resizeState.currentWidth / 10), // Scale font size with width + }); + } + } + setDragState((prev) => ({ ...prev, isDragging: false })); + setResizeState((prev) => ({ ...prev, isResizing: false })); + setRotationState((prev) => ({ ...prev, isRotating: false })); }; - if (dragState.isDragging) { + if (dragState.isDragging || resizeState.isResizing || rotationState.isRotating) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - document.body.style.cursor = "grabbing"; + document.body.style.cursor = dragState.isDragging ? "grabbing" : + resizeState.isResizing ? "nw-resize" : "grab"; document.body.style.userSelect = "none"; } @@ -214,33 +344,87 @@ export function PreviewPanel() { document.body.style.cursor = ""; document.body.style.userSelect = ""; }; - }, [dragState, previewDimensions, canvasSize, updateTextElement]); + }, [dragState, resizeState, rotationState, previewDimensions, canvasSize, updateTextElement, updateElement, tracks]); const handleTextMouseDown = ( e: React.MouseEvent, element: any, - trackId: string + track: TimelineTrack ) => { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); + const elementX = element.x || 0; + const elementY = element.y || 0; setDragState({ isDragging: true, elementId: element.id, - trackId, + trackId: track.id, startX: e.clientX, startY: e.clientY, - initialElementX: element.x, - initialElementY: element.y, - currentX: element.x, - currentY: element.y, + initialElementX: elementX, + initialElementY: elementY, + currentX: elementX, + currentY: elementY, elementWidth: rect.width, elementHeight: rect.height, }); }; + const handleResizeStart = ( + e: React.MouseEvent, + element: any, + track: TimelineTrack, + corner: string + ) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = e.currentTarget.parentElement?.getBoundingClientRect(); + if (!rect) return; + + setResizeState({ + isResizing: true, + elementId: element.id, + trackId: track.id, + startX: e.clientX, + startY: e.clientY, + initialWidth: rect.width, + initialHeight: rect.height, + currentWidth: rect.width, + currentHeight: rect.height, + corner, + }); + }; + + const handleRotationStart = ( + e: React.MouseEvent, + element: any, + track: TimelineTrack + ) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = e.currentTarget.parentElement?.getBoundingClientRect(); + if (!rect) return; + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + setRotationState({ + isRotating: true, + elementId: element.id, + trackId: track.id, + startX: e.clientX, + startY: e.clientY, + initialRotation: element.rotation || 0, + centerX, + centerY, + }); + }; + const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); @@ -539,6 +723,7 @@ export function PreviewPanel() { ? "transparent" : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, + getEffectsForElement: getEffectsForElement || undefined, }); // Blit offscreen to visible canvas @@ -564,6 +749,7 @@ export function PreviewPanel() { canvasSize.height, activeProject?.backgroundType, activeProject?.backgroundColor, + getEffectsForElement, ]); // Get media elements for blur background (video/image only) @@ -582,8 +768,102 @@ export function PreviewPanel() { // Render blur background layer (handled by canvas now) const renderBlurBackground = () => null; - // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. - const renderElement = (_elementData: ActiveElement) => null; + // Render draggable elements on top of canvas + const renderElement = (elementData: ActiveElement) => { + const { element, track, mediaItem } = elementData; + + // Only render text elements as draggable overlays + // Media elements are handled by the canvas renderer + if (element.type !== "text") return null; + + const scaleX = previewDimensions.width / canvasSize.width; + const scaleY = previewDimensions.height / canvasSize.height; + + // Use drag state position if dragging, otherwise use element position + const displayX = dragState.isDragging && dragState.elementId === element.id + ? dragState.currentX + : (element as any).x || 0; + + const displayY = dragState.isDragging && dragState.elementId === element.id + ? dragState.currentY + : (element as any).y || 0; + + // Convert from canvas coordinates to screen coordinates + const screenX = (previewDimensions.width / 2) + (displayX * scaleX); + const screenY = (previewDimensions.height / 2) + (displayY * scaleY); + + // Common properties + const rotation = (element as any).rotation || 0; + const opacity = (element as any).opacity || 1; + + if (element.type === "text") { + const isBeingResized = resizeState.isResizing && resizeState.elementId === element.id; + const baseFontSize = (element as any).fontSize || 16; + const fontSize = isBeingResized + ? Math.max(8, resizeState.currentWidth / 10) * scaleX + : baseFontSize * scaleX; + const fontFamily = (element as any).fontFamily || "Arial"; + const color = (element as any).color || "#ffffff"; + const backgroundColor = (element as any).backgroundColor || "transparent"; + const textAlign = (element as any).textAlign || "center"; + const fontWeight = (element as any).fontWeight || "normal"; + const fontStyle = (element as any).fontStyle || "normal"; + const textDecoration = (element as any).textDecoration || "none"; + const content = (element as any).content || "Text"; + + return ( +
handleTextMouseDown(e, element, track)} + > +
+ {content} + + {/* Resize handles for text */} +
handleResizeStart(e, element, track, "nw")} /> +
handleResizeStart(e, element, track, "ne")} /> +
handleResizeStart(e, element, track, "sw")} /> +
handleResizeStart(e, element, track, "se")} /> + + {/* Rotation handle for text */} +
handleRotationStart(e, element, track)}> +
+
+
+
+ ); + } + + return null; + }; return ( <> diff --git a/apps/web/src/components/editor/properties-panel/effects-properties.tsx b/apps/web/src/components/editor/properties-panel/effects-properties.tsx new file mode 100644 index 000000000..49a64cccc --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/effects-properties.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useEffectsStore } from "@/stores/effects-store"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Eye, EyeOff, Trash2, Settings, RotateCcw, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +export function EffectsProperties() { + const { + selectedEffect, + appliedEffects, + updateEffectParameters, + removeEffect, + toggleEffect, + setSelectedEffect, + } = useEffectsStore(); + + if (!selectedEffect) { + return ( +
+ +

Select an effect to adjust its properties

+
+ ); + } + + const handleParameterChange = (parameter: string, value: number[]) => { + updateEffectParameters(selectedEffect.id, { + [parameter]: value[0], + }); + }; + + const handleReset = () => { + // Reset to default values based on effect type + const defaultParams = getDefaultParameters(selectedEffect.effectType); + updateEffectParameters(selectedEffect.id, defaultParams); + toast.success("Effect reset to default"); + }; + + const handleDuplicate = () => { + // Create a copy of the current effect + const newEffect = { + ...selectedEffect, + id: crypto.randomUUID(), + name: `${selectedEffect.name} (Copy)`, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Add to applied effects (this would need to be implemented in the store) + toast.success("Effect duplicated"); + }; + + const handleRemove = () => { + removeEffect(selectedEffect.id); + setSelectedEffect(null); + }; + + return ( +
+ {/* Header */} +
+
+

{selectedEffect.name}

+

+ Effect applied to timeline element +

+
+
+ + + +
+
+ + + + {/* Enable/Disable Toggle */} +
+ + toggleEffect(selectedEffect.id)} + /> +
+ + + + {/* Parameters */} +
+
+

Parameters

+ +
+ + {/* Brightness */} + {selectedEffect.parameters.brightness !== undefined && ( +
+
+ + + {selectedEffect.parameters.brightness} + +
+ + handleParameterChange("brightness", value) + } + min={-100} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Contrast */} + {selectedEffect.parameters.contrast !== undefined && ( +
+
+ + + {selectedEffect.parameters.contrast} + +
+ + handleParameterChange("contrast", value) + } + min={-100} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Saturation */} + {selectedEffect.parameters.saturation !== undefined && ( +
+
+ + + {selectedEffect.parameters.saturation} + +
+ + handleParameterChange("saturation", value) + } + min={-100} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Hue */} + {selectedEffect.parameters.hue !== undefined && ( +
+
+ + + {selectedEffect.parameters.hue}° + +
+ handleParameterChange("hue", value)} + min={-180} + max={180} + step={1} + className="w-full" + /> +
+ )} + + {/* Blur */} + {selectedEffect.parameters.blur !== undefined && ( +
+
+ + + {selectedEffect.parameters.blur} + +
+ handleParameterChange("blur", value)} + min={0} + max={50} + step={1} + className="w-full" + /> +
+ )} + + {/* Sepia */} + {selectedEffect.parameters.sepia !== undefined && ( +
+
+ + + {selectedEffect.parameters.sepia}% + +
+ handleParameterChange("sepia", value)} + min={0} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Grayscale */} + {selectedEffect.parameters.grayscale !== undefined && ( +
+
+ + + {selectedEffect.parameters.grayscale}% + +
+ + handleParameterChange("grayscale", value) + } + min={0} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Vignette */} + {selectedEffect.parameters.vignette !== undefined && ( +
+
+ + + {selectedEffect.parameters.vignette}% + +
+ + handleParameterChange("vignette", value) + } + min={0} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Grain */} + {selectedEffect.parameters.grain !== undefined && ( +
+
+ + + {selectedEffect.parameters.grain}% + +
+ handleParameterChange("grain", value)} + min={0} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Sharpen */} + {selectedEffect.parameters.sharpen !== undefined && ( +
+
+ + + {selectedEffect.parameters.sharpen}% + +
+ handleParameterChange("sharpen", value)} + min={0} + max={100} + step={1} + className="w-full" + /> +
+ )} + + {/* Pixelate */} + {selectedEffect.parameters.pixelate !== undefined && ( +
+
+ + + {selectedEffect.parameters.pixelate} + +
+ + handleParameterChange("pixelate", value) + } + min={1} + max={50} + step={1} + className="w-full" + /> +
+ )} +
+ + {/* Applied Effects List */} + {appliedEffects.length > 1 && ( + <> + +
+

Applied Effects

+
+ {appliedEffects + .filter( + (effect) => effect.elementId === selectedEffect.elementId + ) + .map((effect) => ( +
setSelectedEffect(effect)} + > + + + {effect.name} + + {!effect.enabled && } + +
+ ))} +
+
+ + )} +
+ ); +} + +// Helper function to get default parameters for each effect type +function getDefaultParameters(effectType: string) { + const defaults: Record = { + "brightness-up": { brightness: 20 }, + "brightness-down": { brightness: -20 }, + "contrast-up": { contrast: 30 }, + "saturation-up": { saturation: 40 }, + "saturation-down": { saturation: -30 }, + "sepia": { sepia: 80 }, + "grayscale": { grayscale: 100 }, + "invert": { invert: 100 }, + "vintage": { vintage: 70, grain: 20, vignette: 30 }, + "dramatic": { contrast: 40, brightness: -10, saturation: -20 }, + "warm": { warm: 60, hue: 15 }, + "cool": { cool: 60, hue: -15 }, + "cinematic": { cinematic: 80, vignette: 40, contrast: 25 }, + "gaussian-blur": { blur: 15, blurType: "gaussian" }, + "motion-blur": { blur: 20, blurType: "motion" }, + "vignette": { vignette: 50 }, + "grain": { grain: 30 }, + "sharpen": { sharpen: 40 }, + "emboss": { emboss: 60 }, + "edge": { edge: 70 }, + "pixelate": { pixelate: 20 }, + }; + + return defaults[effectType] || {}; +} diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 150f95124..2b752d3da 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -2,19 +2,26 @@ import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; +import { useEffectsStore } from "@/stores/effects-store"; import { ScrollArea } from "../../ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; +import { EffectsProperties } from "./effects-properties"; import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); const { mediaFiles } = useMediaStore(); + const { selectedEffect } = useEffectsStore(); return ( <> - {selectedElements.length > 0 ? ( + {selectedEffect ? ( + + + + ) : selectedElements.length > 0 ? ( {selectedElements.map(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); diff --git a/apps/web/src/components/editor/timeline/effects-timeline.tsx b/apps/web/src/components/editor/timeline/effects-timeline.tsx new file mode 100644 index 000000000..6dd950d71 --- /dev/null +++ b/apps/web/src/components/editor/timeline/effects-timeline.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffectsStore } from "@/stores/effects-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { cn } from "@/lib/utils"; +import { Sparkles } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface EffectsTimelineProps { + trackId: string; + elementId: string; +} + +export function EffectsTimeline({ trackId, elementId }: EffectsTimelineProps) { + const { getEffectsForElement } = useEffectsStore(); + const { tracks } = useTimelineStore(); + + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + const effects = getEffectsForElement(elementId); + + if (!element || effects.length === 0) { + return null; + } + + return ( +
+ {effects.map((effect) => { + if (!effect.enabled) return null; + + const effectStart = Math.max(0, effect.startTime - element.startTime); + const effectEnd = Math.min( + element.duration, + effect.endTime - element.startTime + ); + const effectDuration = effectEnd - effectStart; + + if (effectDuration <= 0) return null; + + const left = (effectStart / element.duration) * 100; + const width = (effectDuration / element.duration) * 100; + + return ( + + +
{ + e.stopPropagation(); + // TODO: Select effect for editing + }} + > + +
+
+ +
+

{effect.name}

+

+ {effect.startTime.toFixed(1)}s - {effect.endTime.toFixed(1)}s +

+
+
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c994d707b..7e866bff0 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -502,6 +502,13 @@ export function Timeline() { startTime: currentTime, trimStart: 0, trimEnd: 0, + // Set initial dimensions and position + width: addedItem.width || 200, + height: addedItem.height || 150, + x: 0, // Center position + y: 0, // Center position + rotation: 0, + opacity: 1, }); } } diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 42c78aa49..1713d7100 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1051,6 +1051,13 @@ export function TimelineTrackContent({ startTime: mediaSnappedTime, trimStart: 0, trimEnd: 0, + // Set initial dimensions and position + width: mediaItem.width || 200, + height: mediaItem.height || 150, + x: 0, // Center position + y: 0, // Center position + rotation: 0, + opacity: 1, }); } } else if (hasFiles) { diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 0087502e4..70f83bf32 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -36,10 +36,10 @@ export function Footer() { {/* Brand Section */}
- OpenCut diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index b24687a67..55707e1ae 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -181,15 +181,49 @@ export function SocialsIcon({ className={className} > - - - - + + + + - - - - + + + + ); } @@ -230,4 +264,4 @@ export function TransitionUpIcon({ /> ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index dd6a500ba..159766deb 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -242,4 +242,4 @@ function EditableShortcutKey({ {children} ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/language-select.tsx b/apps/web/src/components/language-select.tsx index 43ed632d6..d66aed647 100644 --- a/apps/web/src/components/language-select.tsx +++ b/apps/web/src/components/language-select.tsx @@ -1,204 +1,204 @@ -import { useState, useRef, useEffect } from "react"; -import { ChevronDown, Globe } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { motion } from "framer-motion"; -import ReactCountryFlag from "react-country-flag"; - -export interface Language { - code: string; - name: string; - flag?: string; -} - -interface LanguageSelectProps { - selectedCountry: string; - onSelect: (country: string) => void; - containerRef: React.RefObject; - languages: Language[]; -} - -function FlagPreloader({ languages }: { languages: Language[] }) { - return ( -
- {languages.map((language) => ( - - ))} -
- ); -} - -export function LanguageSelect({ - selectedCountry, - onSelect, - containerRef, - languages, -}: LanguageSelectProps) { - const [expanded, setExpanded] = useState(false); - const [isTapping, setIsTapping] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const collapsedHeight = "2.5rem"; - const expandHeight = "12rem"; - const buttonRef = useRef(null); - - const expand = () => { - setIsTapping(true); - setTimeout(() => setIsTapping(false), 600); - setExpanded(true); - buttonRef.current?.focus(); - }; - - useEffect(() => { - if (!expanded) return; - - const handleClickOutside = (event: MouseEvent) => { - if ( - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setIsClosing(true); - setTimeout(() => setIsClosing(false), 600); - setExpanded(false); - buttonRef.current?.blur(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [expanded]); - - const selectedLanguage = languages.find( - (lang) => lang.code === selectedCountry - ); - - const handleSelect = ({ - code, - e, - }: { - code: string; - e: React.MouseEvent; - }) => { - e.stopPropagation(); - e.preventDefault(); - onSelect(code); - setExpanded(false); - }; - - return ( -
- - - {!expanded ? ( -
-
- {selectedCountry === "auto" ? ( - - ) : ( - - )} - - {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name} - -
-
- ) : ( -
- - {languages.map((language) => ( - - ))} -
- )} -
- - - - -
- ); -} - -function LanguageButton({ - language, - onSelect, - selectedCountry, -}: { - language: Language; - onSelect: ({ - code, - e, - }: { - code: string; - e: React.MouseEvent; - }) => void; - selectedCountry: string; -}) { - return ( - - ); -} +import { useState, useRef, useEffect } from "react"; +import { ChevronDown, Globe } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import ReactCountryFlag from "react-country-flag"; + +export interface Language { + code: string; + name: string; + flag?: string; +} + +interface LanguageSelectProps { + selectedCountry: string; + onSelect: (country: string) => void; + containerRef: React.RefObject; + languages: Language[]; +} + +function FlagPreloader({ languages }: { languages: Language[] }) { + return ( +
+ {languages.map((language) => ( + + ))} +
+ ); +} + +export function LanguageSelect({ + selectedCountry, + onSelect, + containerRef, + languages, +}: LanguageSelectProps) { + const [expanded, setExpanded] = useState(false); + const [isTapping, setIsTapping] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const collapsedHeight = "2.5rem"; + const expandHeight = "12rem"; + const buttonRef = useRef(null); + + const expand = () => { + setIsTapping(true); + setTimeout(() => setIsTapping(false), 600); + setExpanded(true); + buttonRef.current?.focus(); + }; + + useEffect(() => { + if (!expanded) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsClosing(true); + setTimeout(() => setIsClosing(false), 600); + setExpanded(false); + buttonRef.current?.blur(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [expanded]); + + const selectedLanguage = languages.find( + (lang) => lang.code === selectedCountry + ); + + const handleSelect = ({ + code, + e, + }: { + code: string; + e: React.MouseEvent; + }) => { + e.stopPropagation(); + e.preventDefault(); + onSelect(code); + setExpanded(false); + }; + + return ( +
+ + + {!expanded ? ( +
+
+ {selectedCountry === "auto" ? ( + + ) : ( + + )} + + {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name} + +
+
+ ) : ( +
+ + {languages.map((language) => ( + + ))} +
+ )} +
+ + + + +
+ ); +} + +function LanguageButton({ + language, + onSelect, + selectedCountry, +}: { + language: Language; + onSelect: ({ + code, + e, + }: { + code: string; + e: React.MouseEvent; + }) => void; + selectedCountry: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts index 2d7c684c3..3e5d75ba7 100644 --- a/apps/web/src/components/providers/global-prefetcher.ts +++ b/apps/web/src/components/providers/global-prefetcher.ts @@ -1,78 +1,78 @@ -"use client"; - -import { useEffect } from "react"; -import { useSoundsStore } from "@/stores/sounds-store"; - -export function useGlobalPrefetcher() { - const { - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - - useEffect(() => { - if (hasLoaded) return; - - let ignore = false; - - const prefetchTopSounds = async () => { - try { - if (!ignore) { - setLoading(true); - setError(null); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads" - ); - - if (!ignore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects(data.results); - setHasLoaded(true); - - // Set pagination state for top sounds - setCurrentPage(1); - setHasNextPage(!!data.next); - setTotalCount(data.count); - } - } catch (error) { - if (!ignore) { - console.error("Failed to prefetch top sounds:", error); - setError( - error instanceof Error ? error.message : "Failed to load sounds" - ); - } - } finally { - if (!ignore) { - setLoading(false); - } - } - }; - - const timeoutId = setTimeout(prefetchTopSounds, 100); - - return () => { - clearTimeout(timeoutId); - ignore = true; - }; - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); -} +"use client"; + +import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +export function useGlobalPrefetcher() { + const { + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + } = useSoundsStore(); + + useEffect(() => { + if (hasLoaded) return; + + let ignore = false; + + const prefetchTopSounds = async () => { + try { + if (!ignore) { + setLoading(true); + setError(null); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads" + ); + + if (!ignore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects(data.results); + setHasLoaded(true); + + // Set pagination state for top sounds + setCurrentPage(1); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } + } catch (error) { + if (!ignore) { + console.error("Failed to prefetch top sounds:", error); + setError( + error instanceof Error ? error.message : "Failed to load sounds" + ); + } + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(prefetchTopSounds, 100); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); +} diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index 8302a19c6..e92d35ec0 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -1,25 +1,25 @@ -"use client"; - -import { Button } from "./ui/button"; -import { Sun, Moon } from "lucide-react"; -import { useTheme } from "next-themes"; - -interface ThemeToggleProps { - className?: string; -} - -export function ThemeToggle({ className }: ThemeToggleProps) { - const { theme, setTheme } = useTheme(); - - return ( - - ); -} +"use client"; + +import { Button } from "./ui/button"; +import { Sun, Moon } from "lucide-react"; +import { useTheme } from "next-themes"; + +interface ThemeToggleProps { + className?: string; +} + +export function ThemeToggle({ className }: ThemeToggleProps) { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/apps/web/src/components/ui/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx index 404831f6a..9143f0725 100644 --- a/apps/web/src/components/ui/editable-timecode.tsx +++ b/apps/web/src/components/ui/editable-timecode.tsx @@ -1,138 +1,138 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { cn } from "@/lib/utils"; -import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time"; -import { DEFAULT_FPS } from "@/stores/project-store"; - -interface EditableTimecodeProps { - time: number; - duration?: number; - format?: TimeCode; - fps?: number; - onTimeChange?: (time: number) => void; - className?: string; - disabled?: boolean; -} - -export function EditableTimecode({ - time, - duration, - format = "HH:MM:SS:FF", - fps = DEFAULT_FPS, - onTimeChange, - className, - disabled = false, -}: EditableTimecodeProps) { - const [isEditing, setIsEditing] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [hasError, setHasError] = useState(false); - const inputRef = useRef(null); - const enterPressedRef = useRef(false); - - const formattedTime = formatTimeCode(time, format, fps); - - const startEditing = () => { - if (disabled) return; - setIsEditing(true); - setInputValue(formattedTime); - setHasError(false); - enterPressedRef.current = false; - }; - - const cancelEditing = () => { - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const applyEdit = () => { - const parsedTime = parseTimeCode(inputValue, format, fps); - - if (parsedTime === null) { - setHasError(true); - return; - } - - // Clamp time to valid range - const clampedTime = Math.max( - 0, - duration ? Math.min(duration, parsedTime) : parsedTime - ); - - onTimeChange?.(clampedTime); - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - enterPressedRef.current = true; - applyEdit(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelEditing(); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - setHasError(false); - }; - - const handleBlur = () => { - // Only apply edit if Enter wasn't pressed (to avoid double processing) - if (!enterPressedRef.current && isEditing) { - applyEdit(); - } - }; - - // Focus input when entering edit mode - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - if (isEditing) { - return ( - - ); - } - - return ( - - {formattedTime} - - ); -} +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time"; +import { DEFAULT_FPS } from "@/stores/project-store"; + +interface EditableTimecodeProps { + time: number; + duration?: number; + format?: TimeCode; + fps?: number; + onTimeChange?: (time: number) => void; + className?: string; + disabled?: boolean; +} + +export function EditableTimecode({ + time, + duration, + format = "HH:MM:SS:FF", + fps = DEFAULT_FPS, + onTimeChange, + className, + disabled = false, +}: EditableTimecodeProps) { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [hasError, setHasError] = useState(false); + const inputRef = useRef(null); + const enterPressedRef = useRef(false); + + const formattedTime = formatTimeCode(time, format, fps); + + const startEditing = () => { + if (disabled) return; + setIsEditing(true); + setInputValue(formattedTime); + setHasError(false); + enterPressedRef.current = false; + }; + + const cancelEditing = () => { + setIsEditing(false); + setInputValue(""); + setHasError(false); + enterPressedRef.current = false; + }; + + const applyEdit = () => { + const parsedTime = parseTimeCode(inputValue, format, fps); + + if (parsedTime === null) { + setHasError(true); + return; + } + + // Clamp time to valid range + const clampedTime = Math.max( + 0, + duration ? Math.min(duration, parsedTime) : parsedTime + ); + + onTimeChange?.(clampedTime); + setIsEditing(false); + setInputValue(""); + setHasError(false); + enterPressedRef.current = false; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + enterPressedRef.current = true; + applyEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEditing(); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + setHasError(false); + }; + + const handleBlur = () => { + // Only apply edit if Enter wasn't pressed (to avoid double processing) + if (!enterPressedRef.current && isEditing) { + applyEdit(); + } + }; + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + if (isEditing) { + return ( + + ); + } + + return ( + + {formattedTime} + + ); +} diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 7bb112748..e107a39cb 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -20,7 +20,9 @@ export function FontPicker({ }: FontPickerProps) { return ( onChange?.(e.target.value)} - /> - -
-
- ); -} +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( +
+ setIsExpanded(!isExpanded)} + > + + +
+ + + onChange?.(e.target.value)} + /> + +
+
+ ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 8f195a505..5f17a3a46 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -49,7 +49,9 @@ const Input = forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
- {variant === 'sidebar' && ( + {variant === "sidebar" && ( - + )} {props.children} @@ -69,4 +72,4 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; \ No newline at end of file +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 99e787efb..77e23d8f5 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -2,6 +2,11 @@ import { useRef, useEffect } from "react"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useEffectsStore } from "@/stores/effects-store"; +import { + applyEffectsToVideo, + removeEffectsFromVideo, +} from "@/lib/effects-utils"; interface VideoPlayerProps { src: string; @@ -12,6 +17,7 @@ interface VideoPlayerProps { trimEnd: number; clipDuration: number; trackMuted?: boolean; + elementId?: string; // For applying effects } export function VideoPlayer({ @@ -23,9 +29,11 @@ export function VideoPlayer({ trimEnd, clipDuration, trackMuted = false, + elementId, }: VideoPlayerProps) { const videoRef = useRef(null); const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + const { getEffectsForElement } = useEffectsStore(); // Calculate if we're within this clip's timeline range const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); @@ -115,6 +123,28 @@ export function VideoPlayer({ video.playbackRate = speed; }, [volume, speed, muted, trackMuted]); + // Apply effects to video + useEffect(() => { + const video = videoRef.current; + if (!video || !elementId) return; + + const effects = getEffectsForElement(elementId); + const activeEffects = effects.filter((effect) => effect.enabled); + + if (activeEffects.length === 0) { + removeEffectsFromVideo(video); + return; + } + + // Combine all active effects + const combinedParameters = activeEffects.reduce((acc, effect) => { + Object.assign(acc, effect.parameters); + return acc; + }, {} as any); + + applyEffectsToVideo(video, combinedParameters); + }, [elementId, getEffectsForElement]); + return (