diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 98495535..392683c8 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -18,300 +18,56 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c 2. Consider edge cases and error scenarios 3. Follow the rules below strictly 4. Validate accessibility requirements +5. Avoid code duplication ## Rules ### Accessibility (a11y) -- Don't use `accessKey` attribute on any HTML element. -- Don't set `aria-hidden="true"` on focusable elements. -- Don't add ARIA roles, states, and properties to elements that don't support them. -- Don't use distracting elements like `` or ``. -- Only use the `scope` prop on `` elements. -- Don't assign non-interactive ARIA roles to interactive HTML elements. -- Make sure label elements have text content and are associated with an input. -- Don't assign interactive ARIA roles to non-interactive HTML elements. -- Don't assign `tabIndex` to non-interactive HTML elements. -- Don't use positive integers for `tabIndex` property. -- Don't include "image", "picture", or "photo" in img alt prop. -- Don't use explicit role property that's the same as the implicit/default role. -- Make static elements with click handlers use a valid role attribute. -- Always include a `title` element for SVG elements. -- Give all elements requiring alt text meaningful information for screen readers. -- Make sure anchors have content that's accessible to screen readers. -- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. -- Include all required ARIA attributes for elements with ARIA roles. -- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `title` element for icons unless there's text beside the icon. - Always include a `type` attribute for button elements. -- Make elements with interactive roles and handlers focusable. -- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). -- Always include a `lang` attribute on the html element. -- Always include a `title` attribute for iframe elements. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. -- Include caption tracks for audio and video elements. -- Use semantic elements instead of role attributes in JSX. -- Make sure all anchors are valid and navigable. -- Ensure all ARIA properties (`aria-*`) are valid. -- Use valid, non-abstract ARIA roles for elements with ARIA roles. -- Use valid ARIA state and property values. -- Use valid values for the `autocomplete` attribute on input elements. -- Use correct ISO language/country codes for the `lang` attribute. ### Code Complexity and Quality -- Don't use consecutive spaces in regular expression literals. -- Don't use the `arguments` object. - Don't use primitive type aliases or misleading types. - Don't use the comma operator. -- Don't use empty type parameters in type aliases and interfaces. -- Don't write functions that exceed a given Cognitive Complexity score. -- Don't nest describe() blocks too deeply in test files. -- Don't use unnecessary boolean casts. -- Don't use unnecessary callbacks with flatMap. - Use for...of statements instead of Array.forEach. -- Don't create classes that only have static members (like a static namespace). -- Don't use this and super in static contexts. -- Don't use unnecessary catch clauses. -- Don't use unnecessary constructors. -- Don't use unnecessary continue statements. -- Don't export empty modules that don't change anything. -- Don't use unnecessary escape sequences in regular expression literals. -- Don't use unnecessary fragments. -- Don't use unnecessary labels. -- Don't use unnecessary nested block statements. -- Don't rename imports, exports, and destructured assignments to the same name. -- Don't use unnecessary string or template literal concatenation. -- Don't use String.raw in template literals when there are no escape sequences. -- Don't use useless case statements in switch statements. -- Don't use ternary operators when simpler alternatives exist. -- Don't use useless `this` aliasing. -- Don't use any or unknown as type constraints. - Don't initialize variables to undefined. -- Don't use the void operators (they're not familiar). -- Use arrow functions instead of function expressions. -- Use Date.now() to get milliseconds since the Unix Epoch. - Use .flatMap() instead of map().flat() when possible. -- Use literal property access instead of computed property access. -- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. -- Use concise optional chaining instead of chained logical expressions. -- Use regular expression literals instead of the RegExp constructor when possible. -- Don't use number literal object member names that aren't base 10 or use underscore separators. -- Remove redundant terms from logical expressions. -- Use while loops instead of for loops when you don't need initializer and update expressions. -- Don't pass children as props. -- Don't reassign const variables. -- Don't use constant expressions in conditions. -- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. -- Don't return a value from a constructor. -- Don't use empty character classes in regular expression literals. -- Don't use empty destructuring patterns. -- Don't call global object properties as functions. -- Don't declare functions and vars that are accessible outside their block. -- Make sure builtins are correctly instantiated. -- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. -- Don't use variables and function parameters before they're declared. -- Don't use 8 and 9 escape sequences in string literals. -- Don't use literal numbers that lose precision. ### React and JSX Best Practices -- Don't use the return value of React.render. -- Make sure all dependencies are correctly specified in React hooks. -- Make sure all React hooks are called from the top level of component functions. -- Don't forget key props in iterators and collection literals. -- Don't destructure props inside JSX components in Solid projects. +- Don't import `React` itself. - Don't define React components inside other components. -- Don't use event handlers on non-interactive elements. -- Don't assign to React component props. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. -- Don't use dangerous JSX props. -- Don't use Array index in keys. - Don't insert comments as text nodes. -- Don't assign JSX properties multiple times. -- Don't add extra closing tags for components without children. - Use `<>...` instead of `...`. -- Watch out for possible "wrong" semicolons inside JSX elements. ### Correctness and Safety - Don't assign a value to itself. -- Don't return a value from a setter. -- Don't compare expressions that modify string case with non-compliant values. -- Don't use lexical declarations in switch clauses. -- Don't use variables that haven't been declared in the document. -- Don't write unreachable code. -- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. -- Don't use control flow statements in finally blocks. -- Don't use optional chaining where undefined values aren't allowed. -- Don't have unused function parameters. -- Don't have unused imports. -- Don't have unused labels. -- Don't have unused private class members. -- Don't have unused variables. -- Make sure void (self-closing) elements don't have children. -- Don't return a value from a function with the return type 'void' -- Use isNaN() when checking for NaN. -- Make sure "for" loop update clauses move the counter in the right direction. -- Make sure typeof expressions are compared to valid values. -- Make sure generator functions contain yield. +- Use props instead of parameters in all functions. +- Avoid unused imports and variables. - Don't use await inside loops. -- Don't use bitwise operators. -- Don't use expressions where the operation doesn't change the value. -- Make sure Promise-like statements are handled appropriately. -- Don't use __dirname and __filename in the global scope. -- Prevent import cycles. -- Don't use configured elements. - Don't hardcode sensitive data like API keys and tokens. -- Don't let variable declarations shadow variables from outer scopes. - Don't use the TypeScript directive @ts-ignore. -- Prevent duplicate polyfills from Polyfill.io. -- Don't use useless backreferences in regular expressions that always match empty strings. -- Don't use unnecessary escapes in string literals. -- Don't use useless undefined. -- Make sure getters and setters for the same property are next to each other in class and object definitions. -- Make sure object literals are declared consistently (defaults to explicit definitions). -- Use static Response methods instead of new Response() constructor when possible. -- Make sure switch-case statements are exhaustive. - Make sure the `preconnect` attribute is used when using Google Fonts. -- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. -- Make sure iterable callbacks return consistent values. -- Use `with { type: "json" }` for JSON module imports. -- Use numeric separators in numeric literals. -- Use object spread instead of `Object.assign()` when constructing new objects. -- Always use the radix argument when using `parseInt()`. -- Make sure JSDoc comment lines start with a single asterisk, except for the first one. -- Include a description parameter for `Symbol()`. -- Don't use spread (`...`) syntax on accumulators. - Don't use the `delete` operator. -- Don't access namespace imports dynamically. -- Don't use namespace imports. -- Declare regex literals at the top level. -- Don't use `target="_blank"` without `rel="noopener"`. ### TypeScript Best Practices - Don't use TypeScript enums. -- Don't export imported variables. -- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. -- Don't use TypeScript namespaces. -- Don't use non-null assertions with the `!` postfix operator. -- Don't use parameter properties in class constructors. -- Don't use user-defined types. -- Use `as const` instead of literal types and type annotations. - Use either `T[]` or `Array` consistently. -- Initialize each enum member value explicitly. -- Use `export type` for types. -- Use `import type` for types. -- Make sure all enum members are literal values. -- Don't use TypeScript const enum. -- Don't declare empty interfaces. -- Don't let variables evolve into any type through reassignments. -- Don't use the any type. -- Don't misuse the non-null assertion operator (!) in TypeScript files. -- Don't use implicit any type on variable declarations. -- Don't merge interfaces and classes unsafely. -- Don't use overload signatures that aren't next to each other. -- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. +- Don't use the `any` type. ### Style and Consistency - Don't use global `eval()`. -- Don't use callbacks in asynchronous tests and hooks. -- Don't use negation in `if` statements that have `else` clauses. -- Don't use nested ternary expressions. -- Don't reassign function parameters. -- This rule lets you specify global variable names you don't want to use in your application. -- Don't use specified modules when loaded by import or require. -- Don't use constants whose value is the upper-case version of their name. - Use `String.slice()` instead of `String.substr()` and `String.substring()`. -- Don't use template literals if you don't need interpolation or special-character handling. - Don't use `else` blocks when the `if` block breaks early. -- Don't use yoda expressions. -- Don't use Array constructors. -- Use `at()` instead of integer index access. -- Follow curly brace conventions. -- Use `else if` instead of nested `if` statements in `else` clauses. -- Use single `if` statements instead of nested `if` clauses. -- Use `new` for all builtins except `String`, `Number`, and `Boolean`. -- Use consistent accessibility modifiers on class properties and methods. -- Use `const` declarations for variables that are only assigned once. - Put default function parameters and optional function parameters last. -- Include a `default` clause in switch statements. -- Use the `**` operator instead of `Math.pow`. -- Use `for-of` loops when you need the index to extract an item from the iterated array. -- Use `node:assert/strict` over `node:assert`. -- Use the `node:` protocol for Node.js builtin modules. -- Use Number properties instead of global ones. -- Use assignment operator shorthand where possible. -- Use function types instead of object types with call signatures. -- Use template literals over string concatenation. - Use `new` when throwing an error. -- Don't throw non-Error values. - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. -- Use standard constants instead of approximated literals. -- Don't assign values in expressions. -- Don't use async functions as Promise executors. -- Don't reassign exceptions in catch clauses. -- Don't reassign class members. -- Don't compare against -0. -- Don't use labeled statements that aren't loops. -- Don't use void type outside of generic or return types. -- Don't use console. -- Don't use control characters and escape sequences that match control characters in regular expression literals. -- Don't use debugger. -- Don't assign directly to document.cookie. -- Use `===` and `!==`. -- Don't use duplicate case labels. -- Don't use duplicate class members. -- Don't use duplicate conditions in if-else-if chains. -- Don't use two keys with the same name inside objects. -- Don't use duplicate function parameter names. -- Don't have duplicate hooks in describe blocks. -- Don't use empty block statements and static blocks. -- Don't let switch clauses fall through. -- Don't reassign function declarations. -- Don't allow assignments to native objects and read-only global variables. -- Use Number.isFinite instead of global isFinite. -- Use Number.isNaN instead of global isNaN. -- Don't assign to imported bindings. -- Don't use irregular whitespace characters. -- Don't use labels that share a name with a variable. -- Don't use characters made with multiple code points in character class syntax. -- Make sure to use new and constructor properly. -- Don't use shorthand assign when the variable appears on both sides. -- Don't use octal escape sequences in string literals. -- Don't use Object.prototype builtins directly. -- Don't redeclare variables, functions, classes, and types in the same scope. -- Don't have redundant "use strict". -- Don't compare things where both sides are exactly the same. -- Don't let identifiers shadow restricted names. -- Don't use sparse arrays (arrays with holes). -- Don't use template literal placeholder syntax in regular strings. -- Don't use the then property. -- Don't use unsafe negation. -- Don't use var. -- Don't use with statements in non-strict contexts. -- Make sure async functions actually use await. -- Make sure default clauses in switch statements come last. -- Make sure to pass a message value when creating a built-in error. -- Make sure get methods always return a value. -- Use a recommended display strategy with Google Fonts. -- Make sure for-in loops include an if statement. -- Use Array.isArray() instead of instanceof Array. -- Make sure to use the digits argument with Number#toFixed(). -- Make sure to use the "use strict" directive in script files. ### Next.js Specific Rules - Don't use `` elements in Next.js projects. - Don't use `` elements in Next.js projects. -- Don't import next/document outside of pages/_document.jsx in Next.js projects. -- Don't use the next/head module in pages/_document.js on Next.js projects. - -### Testing Best Practices -- Don't use export or module.exports in test files. -- Don't use focused tests. -- Make sure the assertion function, like expect, is placed inside an it() function call. -- Don't use disabled tests. - -## Common Tasks -- `npx ultracite init` - Initialize Ultracite in your project -- `npx ultracite format` - Format and fix code automatically -- `npx ultracite lint` - Check for issues without fixing ## Example: Error Handling ```typescript diff --git a/.gitignore b/.gitignore index 030fd134..b4756346 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ node_modules *.env # cursor -bun.lockb \ No newline at end of file +bun.lockb +apps/transcription/__pycache__ +apps/transcription/env \ No newline at end of file diff --git a/apps/transcription/README.md b/apps/transcription/README.md new file mode 100644 index 00000000..07be6790 --- /dev/null +++ b/apps/transcription/README.md @@ -0,0 +1,86 @@ +Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup"). + +Open your terminal and make sure you're in the `apps/transcription` directory. + +1. Create virtual environment + +```bash +python -m venv env +``` + +2. Activate it + +**Windows:** + +```bash +env\Scripts\activate +``` + +**macOS/Linux:** + +```bash +source env/bin/activate +``` + +> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found, +> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe + +3. Install libraries/packages/whatever you wanna call them + +```bash +pip install -r requirements.txt +``` + +4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/) + +> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable. + +5. Once you've got a Modal accoumt, run this: + +```bash +python -m modal setup +``` + +It's gonna open a browser so you can authenticate. + +6. Test it if you want to make sure it actually works: + +```bash +modal run transcription.py +``` + +6. Deploy the function! + +```bash +modal deploy transcription.py +``` + +7. Set the required secrets in Modal + +So the script we just deployed interacts with Cloudflare to do two things: + +- Download the audio (so it can be transcribed with Whisper) +- Delete the file after processing (privacy) + +To do those things, the script needs access to these environment variables: +```bash +CLOUDFLARE_ACCOUNT_ID=your-account-id +R2_ACCESS_KEY_ID=your-access-key-id +R2_SECRET_ACCESS_KEY=your-secret-access-key +R2_BUCKET_NAME=opencut-transcription +``` + +Remember, we set these earlier in `.env.local`. + +So let's do it: + + - Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main) + - Click "Custom" and enter "opencut-r2-secrets" for the name. + - Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only: + ```bash + CLOUDFLARE_ACCOUNT_ID=your-account-id + R2_ACCESS_KEY_ID=your-access-key-id + R2_SECRET_ACCESS_KEY=your-secret-access-key + R2_BUCKET_NAME=opencut-transcription + ``` + - Click "Done" and you should see some cool particles! \ No newline at end of file diff --git a/apps/transcription/requirements.txt b/apps/transcription/requirements.txt new file mode 100644 index 00000000..c002e683 --- /dev/null +++ b/apps/transcription/requirements.txt @@ -0,0 +1,5 @@ +modal +openai-whisper +boto3 +pydantic +cryptography \ No newline at end of file diff --git a/apps/transcription/transcription.py b/apps/transcription/transcription.py new file mode 100644 index 00000000..ad55f6d4 --- /dev/null +++ b/apps/transcription/transcription.py @@ -0,0 +1,143 @@ +import modal +from pydantic import BaseModel + +app = modal.App("opencut-transcription") + +class TranscribeRequest(BaseModel): + filename: str + language: str = "auto" + decryptionKey: str = None + iv: str = None + +@app.function( + image=modal.Image.debian_slim() + .apt_install(["ffmpeg"]) + .pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]), + gpu="A10G", + timeout=300, # 5m + secrets=[modal.Secret.from_name("opencut-r2-secrets")] +) +@modal.fastapi_endpoint(method="POST") +def transcribe_audio(request: TranscribeRequest): + import whisper + import boto3 + import tempfile + import os + import json + + try: + filename = request.filename + language = request.language + decryption_key = request.decryptionKey + iv = request.iv + + if not filename: + return { + "error": "Missing filename parameter" + } + + # Initialize R2 client + s3_client = boto3.client( + 's3', + endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com', + aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"], + region_name='auto' + ) + + # Create temporary file for audio + with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file: + temp_path = temp_file.name + + try: + # Download audio from R2 + s3_client.download_file( + os.environ["R2_BUCKET_NAME"], + filename, + temp_path + ) + + # If decryption key provided, decrypt the file directly (zero-knowledge) + if decryption_key and iv: + import base64 + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.backends import default_backend + + # Read the encrypted file + with open(temp_path, 'rb') as f: + encrypted_data = f.read() + + # Decode the key and IV from base64 + key_bytes = base64.b64decode(decryption_key) + iv_bytes = base64.b64decode(iv) + + # Decrypt the data using AES-GCM + # Extract the tag (last 16 bytes) and ciphertext + tag = encrypted_data[-16:] + ciphertext = encrypted_data[:-16] + + cipher = Cipher( + algorithms.AES(key_bytes), + modes.GCM(iv_bytes, tag), + backend=default_backend() + ) + decryptor = cipher.decryptor() + decrypted_data = decryptor.update(ciphertext) + decryptor.finalize() + + # Write decrypted audio back to temp file + with open(temp_path, 'wb') as f: + f.write(decrypted_data) + + # Load Whisper model + model = whisper.load_model("base") + + # Transcribe audio + if language == "auto": + result = model.transcribe(temp_path) + else: + result = model.transcribe(temp_path, language=language.lower()) + + # Delete audio file from R2 (cleanup) + s3_client.delete_object( + Bucket=os.environ["R2_BUCKET_NAME"], + Key=filename + ) + + # Adjust segment timing - Whisper is consistently late by ~500ms + adjusted_segments = [] + for segment in result["segments"]: + adjusted_segment = segment.copy() + # Shift start/end times earlier by 500ms, don't go below 0 + adjusted_segment["start"] = max(0, segment["start"] - 0.5) + adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s + adjusted_segments.append(adjusted_segment) + + return { + "text": result["text"], + "segments": adjusted_segments, + "language": result["language"] + } + + finally: + # Clean up temporary file + if os.path.exists(temp_path): + os.unlink(temp_path) + + except Exception as e: + import traceback + print(f"Transcription error: {str(e)}") + print(f"Traceback: {traceback.format_exc()}") + + # Return error response that matches expected format + return { + "error": str(e), + "text": "", + "segments": [], + "language": "unknown" + } + +@app.local_entrypoint() +def main(): + # Test function - you can call this with modal run transcription.py + print("Transcription service is ready to deploy!") + print("Deploy with: modal deploy transcription.py") \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example index af84fd77..e77d3c5a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -20,4 +20,14 @@ NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com # Freesound (generate at https://freesound.org/apiv2/apply/) FREESOUND_CLIENT_ID=... -FREESOUND_API_KEY=... \ No newline at end of file +FREESOUND_API_KEY=... + +# Cloudflare R2 (for auto-captions/transcription) +# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens +CLOUDFLARE_ACCOUNT_ID=your-account-id +R2_ACCESS_KEY_ID=your-access-key-id +R2_SECRET_ACCESS_KEY=your-secret-access-key +R2_BUCKET_NAME=opencut-transcription + +# Modal transcription endpoint (from modal deploy transcription.py) +MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index d1a60846..6c42e708 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -5,6 +5,9 @@ FROM base AS builder WORKDIR /app +ARG FREESOUND_CLIENT_ID +ARG FREESOUND_API_KEY + COPY package.json package.json COPY bun.lock bun.lock COPY turbo.json turbo.json @@ -28,6 +31,9 @@ ENV UPSTASH_REDIS_REST_URL="http://localhost:8079" ENV UPSTASH_REDIS_REST_TOKEN="example_token" ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000" +ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID +ENV FREESOUND_API_KEY=$FREESOUND_API_KEY + WORKDIR /app/apps/web RUN bun run build @@ -54,4 +60,6 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["bun", "apps/web/server.js"] \ No newline at end of file + +CMD ["bun", "apps/web/server.js"] + diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 980c5827..5142905b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -30,6 +30,10 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "avatars.githubusercontent.com", }, + { + protocol: "https", + hostname: "res.cloudinary.com", + }, ], }, }; diff --git a/apps/web/package.json b/apps/web/package.json index df7ca525..fe97c66a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "bun@1.2.18", "scripts": { - "dev": "next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "test": "vitest", @@ -31,6 +31,7 @@ "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.4.1", + "aws4fetch": "^1.0.20", "better-auth": "^1.2.7", "botid": "^1.4.2", "class-variance-authority": "^0.7.1", @@ -45,6 +46,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", + "nanoid": "^5.1.5", "next": "^15.4.5", "next-themes": "^0.4.4", "pg": "^8.16.2", @@ -78,7 +80,7 @@ "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", "@types/bun": "latest", - "@types/node": "^24.1.0", + "@types/node": "^24.2.1", "@types/pg": "^8.15.4", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/apps/web/public/landing-page-bg.png b/apps/web/public/landing-page-bg.png deleted file mode 100644 index 4f873945..00000000 Binary files a/apps/web/public/landing-page-bg.png and /dev/null differ diff --git a/apps/web/public/platform-guides/tiktok-blueprint.png b/apps/web/public/platform-guides/tiktok-blueprint.png new file mode 100644 index 00000000..e87eb36d Binary files /dev/null and b/apps/web/public/platform-guides/tiktok-blueprint.png differ diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts new file mode 100644 index 00000000..dc5b7328 --- /dev/null +++ b/apps/web/src/app/api/get-upload-url/route.ts @@ -0,0 +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 } + ); + } +} diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts new file mode 100644 index 00000000..9a497f65 --- /dev/null +++ b/apps/web/src/app/api/transcribe/route.ts @@ -0,0 +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 } + ); + } +} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index cae8e779..4d5a27fc 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -30,6 +30,8 @@ export default function Editor() { setTimeline, propertiesPanel, setPropertiesPanel, + activePreset, + resetCounter, } = usePanelStore(); const { @@ -153,72 +155,302 @@ export default function Editor() {
- - - {/* Main content area */} - - {/* Tools Panel */} - + + + + + + - - + + + + + - + - {/* Preview Area */} - - - + + + + + - + - - - - - - - - - {/* Timeline */} - + + + + + + ) : activePreset === "inspector" ? ( + - - - + setPropertiesPanel(100 - size)} + className="min-w-0 min-h-0" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : activePreset === "vertical-preview" ? ( + + setPreviewPanel(100 - size)} + className="min-w-0 min-h-0" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + + + {/* Main content area */} + + {/* Tools Panel */} + + + + + + + {/* Preview Area */} + + + + + + + + + + + + + + + {/* Timeline */} + + + + + )}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 77202c36..98aec2b9 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -29,6 +29,8 @@ import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { useTheme } from "next-themes"; import { usePlaybackStore } from "@/stores/playback-store"; +import { TransitionUpIcon } from "./icons"; +import { PanelPresetSelector } from "./panel-preset-selector"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); @@ -39,13 +41,6 @@ export function EditorHeader() { const router = useRouter(); const { theme, setTheme } = useTheme(); - const handleExport = () => { - // TODO: Implement export functionality - // NOTE: This is already being worked on - console.log("Export project"); - window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank"); - }; - const handleNameSave = async (newName: string) => { console.log("handleNameSave", newName); if (activeProject && newName.trim() && newName !== activeProject.name) { @@ -78,7 +73,7 @@ export function EditorHeader() { {activeProject?.name} - + @@ -147,15 +142,9 @@ export function EditorHeader() { const rightContent = (