Merge branch 'staging' into feat/timeline-return-to-start

This commit is contained in:
ryu 2025-08-13 14:01:07 +05:00 committed by GitHub
commit 0ae09c9df5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 2860 additions and 792 deletions

View File

@ -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 `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` 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 `<Fragment>...</Fragment>`.
- 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<T>` 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 `<img>` elements in Next.js projects.
- Don't use `<head>` 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

4
.gitignore vendored
View File

@ -27,4 +27,6 @@ node_modules
*.env
# cursor
bun.lockb
bun.lockb
apps/transcription/__pycache__
apps/transcription/env

View File

@ -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!

View File

@ -0,0 +1,5 @@
modal
openai-whisper
boto3
pydantic
cryptography

View File

@ -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")

View File

@ -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=...
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

View File

@ -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"]
CMD ["bun", "apps/web/server.js"]

View File

@ -30,6 +30,10 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "res.cloudinary.com",
},
],
},
};

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -30,6 +30,8 @@ export default function Editor() {
setTimeline,
propertiesPanel,
setPropertiesPanel,
activePreset,
resetCounter,
} = usePanelStore();
const {
@ -153,72 +155,302 @@ export default function Editor() {
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
<EditorHeader />
<div className="flex-1 min-h-0 min-w-0">
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
{activePreset === "media" ? (
<ResizablePanelGroup
key={`media-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
{/* Tools Panel */}
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={100 - toolsPanel}
minSize={60}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<MediaPanel />
</ResizablePanel>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
{/* Preview Area */}
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "inspector" ? (
<ResizablePanelGroup
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
<ResizablePanel
defaultSize={100 - propertiesPanel}
minSize={30}
onResize={(size) => setPropertiesPanel(100 - size)}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 min-h-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "vertical-preview" ? (
<ResizablePanelGroup
key={`vertical-preview-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - previewPanel}
minSize={30}
onResize={(size) => setPreviewPanel(100 - size)}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<ResizablePanelGroup
key={`default-${activePreset}-${resetCounter}`}
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
>
{/* Tools Panel */}
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
{/* Preview Area */}
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
<Onboarding />
</div>

View File

@ -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() {
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuContent align="start" className="w-40 z-100">
<Link href="/projects">
<DropdownMenuItem className="flex items-center gap-1.5">
<ArrowLeft className="h-4 w-4" />
@ -147,15 +142,9 @@ export function EditorHeader() {
const rightContent = (
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<Button
size="sm"
className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm pr-1">Export</span>
</Button>
<ExportButton />
<Button
size="icon"
variant="text"
@ -177,3 +166,27 @@ export function EditorHeader() {
/>
);
}
function ExportButton() {
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");
};
return (
<button
className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.1rem] py-[0.1rem] cursor-pointer hover:brightness-95 transition-all duration-200"
onClick={handleExport}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.45)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-4px)] h-[calc(100%-4px)] top-[0.12rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-lg"></div>
</div>
</div>
</button>
);
}

View File

@ -20,7 +20,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
useEffect(() => {
let mounted = true;
let ws = wavesurfer.current;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
@ -90,7 +90,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
const wsToDestroy = ws;
// Detach from ref immediately
wavesurfer.current = null;
// Wait a tick to destroy so any pending operations can complete
requestAnimationFrame(() => {
try {
@ -111,13 +111,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
return () => {
// Mark component as unmounted
mounted = false;
// Store reference to current wavesurfer instance
const wsToDestroy = wavesurfer.current;
// Immediately clear the ref to prevent accessing it after unmount
wavesurfer.current = null;
// If we have an instance to clean up, do it safely
if (wsToDestroy) {
// Delay destruction to avoid race conditions

View File

@ -0,0 +1,27 @@
"use client";
import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="absolute inset-0 pointer-events-none">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 w-full h-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}

View File

@ -7,6 +7,7 @@ import { TextView } from "./views/text";
import { SoundsView } from "./views/sounds";
import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
import { Captions } from "./views/captions";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
@ -30,11 +31,7 @@ export function MediaPanel() {
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
captions: <Captions />,
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...

View File

@ -2,122 +2,50 @@
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"
>
<div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75",
activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground"
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="size-[1.1rem]!" />
<Tooltip delayDuration={10}>
<TooltipTrigger asChild>
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
variant="sidebar"
sideOffset={8}
>
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
{tab.label}
</div>
</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!"
onClick={onClick}
>
<Icon className="size-4! text-foreground" />
</Button>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface BaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
tabs?: {
value: string;
label: string;
content: React.ReactNode;
}[];
className?: string;
ref?: React.RefObject<HTMLDivElement>;
}
function ViewContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<ScrollArea className="flex-1">
<div className={`p-5 h-full ${className}`}>{children}</div>
</ScrollArea>
);
}
export function BaseView({
children,
defaultTab,
tabs,
className = "",
ref,
}: BaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex-1 flex flex-col min-h-0"
>
<ViewContent>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
)}
</div>
);
}

View File

@ -0,0 +1,313 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
import { BaseView } from "./base-view";
import { Language, LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/ffmpeg-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TextElement } from "@/types/timeline";
export const languages: Language[] = [
{ code: "US", name: "English" },
{ code: "ES", name: "Spanish" },
{ code: "IT", name: "Italian" },
{ code: "FR", name: "French" },
{ code: "DE", name: "German" },
{ code: "PT", name: "Portuguese" },
{ code: "RU", name: "Russian" },
{ code: "JP", name: "Japanese" },
{ code: "CN", name: "Chinese" },
];
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
export function Captions() {
const [selectedCountry, setSelectedCountry] = useState("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { insertTrackAt, addElementToTrack } = useTimelineStore();
// Check if user has already accepted privacy on mount
useEffect(() => {
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
setHasAcceptedPrivacy(hasAccepted);
}, []);
const handleGenerateTranscript = async () => {
try {
setIsProcessing(true);
setError(null);
setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio();
setProcessingStep("Encrypting audio...");
// Encrypt the audio with a random key (zero-knowledge)
const audioBuffer = await audioBlob.arrayBuffer();
const encryptionResult = await encryptWithRandomKey(audioBuffer);
// Convert encrypted data to blob for upload
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
setProcessingStep("Uploading...");
const uploadResponse = await fetch("/api/get-upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileExtension: "wav" }),
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
throw new Error(error.message || "Failed to get upload URL");
}
const { uploadUrl, fileName } = await uploadResponse.json();
// Upload to R2
await fetch(uploadUrl, {
method: "PUT",
body: encryptedBlob,
});
setProcessingStep("Transcribing...");
// Call Modal transcription API with encryption parameters
const transcriptionResponse = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: fileName,
language:
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
// Send the raw encryption key and IV (zero-knowledge)
decryptionKey: arrayBufferToBase64(encryptionResult.key),
iv: arrayBufferToBase64(encryptionResult.iv),
}),
});
if (!transcriptionResponse.ok) {
const error = await transcriptionResponse.json();
throw new Error(error.message || "Transcription failed");
}
const { text, segments } = await transcriptionResponse.json();
console.log("Transcription completed:", { text, segments });
const shortCaptions: Array<{
text: string;
startTime: number;
duration: number;
}> = [];
let globalEndTime = 0; // Track the end time of the last caption globally
segments.forEach((segment: any) => {
const words = segment.text.trim().split(/\s+/);
const segmentDuration = segment.end - segment.start;
const wordsPerSecond = words.length / segmentDuration;
// Split into chunks of 2-4 words
const chunks: string[] = [];
for (let i = 0; i < words.length; i += 3) {
chunks.push(words.slice(i, i + 3).join(" "));
}
// Calculate timing for each chunk to place them sequentially
let chunkStartTime = segment.start;
chunks.forEach((chunk) => {
const chunkWords = chunk.split(/\s+/).length;
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
let adjustedStartTime = chunkStartTime;
// Prevent overlapping: if this caption would start before the last one ends,
// start it right after the last one ends
if (adjustedStartTime < globalEndTime) {
adjustedStartTime = globalEndTime;
}
shortCaptions.push({
text: chunk,
startTime: adjustedStartTime,
duration: chunkDuration,
});
// Update global end time
globalEndTime = adjustedStartTime + chunkDuration;
// Next chunk starts when this one ends (for within-segment timing)
chunkStartTime += chunkDuration;
});
});
// Create a single track for all captions
const captionTrackId = insertTrackAt("text", 0);
// Add all caption elements to the same track
shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, {
type: "text",
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
trimStart: 0,
trimEnd: 0,
fontSize: 65,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
} as TextElement);
});
console.log(
`${shortCaptions.length} short-form caption chunks added to timeline!`
);
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred"
);
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
return (
<BaseView ref={containerRef} className="flex flex-col justify-between">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
onSelect={setSelectedCountry}
containerRef={containerRef}
languages={languages}
/>
</PropertyGroup>
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<Button
className="w-full"
onClick={() => {
if (hasAcceptedPrivacy) {
handleGenerateTranscript();
} else {
setShowPrivacyDialog(true);
}
}}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Audio Processing Notice
</DialogTitle>
<DialogDescription className="space-y-3">
<p>
To generate captions, we need to process your timeline audio
using speech-to-text technology.
</p>
<div className="space-y-2 pt-2">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Zero-knowledge encryption - we cannot decrypt your files
even if we wanted to
</span>
</div>
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Encryption keys generated randomly in your browser, never
stored anywhere
</span>
</div>
<div className="flex items-start gap-2">
<Upload className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Audio encrypted before upload - raw audio never leaves
your device
</span>
</div>
<div className="flex items-start gap-2">
<Trash2 className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Everything permanently deleted within seconds after
transcription
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
<strong>True zero-knowledge privacy:</strong> Encryption keys
are generated randomly in your browser and never stored
anywhere. It's cryptographically impossible for us, our cloud
providers, or anyone else to decrypt your audio files.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowPrivacyDialog(false)}
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={() => {
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
setHasAcceptedPrivacy(true);
setShowPrivacyDialog(false);
handleGenerateTranscript();
}}
disabled={isProcessing}
>
Continue & Generate Captions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</BaseView>
);
}

View File

@ -7,9 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BaseView } from "./base-view";
import {
PropertyItem,
PropertyItemLabel,
@ -34,37 +32,37 @@ export function SettingsView() {
function ProjectSettingsTabs() {
return (
<div className="h-full flex flex-col">
<Tabs defaultValue="project-info" className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger>
</TabsList>
</div>
<Separator className="my-4" />
<ScrollArea className="flex-1">
<TabsContent value="project-info" className="p-5 pt-0 mt-0">
<ProjectInfoView />
</TabsContent>
<TabsContent value="background" className="p-4 pt-0">
<BackgroundView />
</TabsContent>
</ScrollArea>
</Tabs>
</div>
<BaseView
defaultTab="project-info"
tabs={[
{
value: "project-info",
label: "Project info",
content: <ProjectInfoView />,
},
{
value: "background",
label: "Background",
content: <BackgroundView />,
},
]}
/>
);
}
function ProjectInfoView() {
const { activeProject, updateProjectFps } = useProjectStore();
const { canvasPresets, setCanvasSize } = useEditorStore();
const { activeProject, updateProjectFps, updateCanvasSize } =
useProjectStore();
const { canvasPresets } = useEditorStore();
const { getDisplayName } = useAspectRatio();
const handleAspectRatioChange = (value: string) => {
const preset = canvasPresets.find((p) => p.name === value);
if (preset) {
setCanvasSize({ width: preset.width, height: preset.height });
updateCanvasSize(
{ width: preset.width, height: preset.height },
"preset"
);
}
};
@ -259,7 +257,7 @@ function BackgroundView() {
);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
</PropertyGroup>

View File

@ -1,4 +1,5 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { BaseView } from "./base-view";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
@ -28,7 +29,7 @@ const textData: TextElement = {
export function TextView() {
return (
<div className="p-4">
<BaseView>
<DraggableMediaItem
name="Default text"
preview={
@ -48,6 +49,6 @@ export function TextView() {
}
showLabel={false}
/>
</div>
</BaseView>
);
}

View File

@ -14,8 +14,18 @@ import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { FONT_CLASS_MAP } from "@/lib/font-config";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
import { TextElementDragState } from "@/types/editor";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { LayoutGuideOverlay } from "./layout-guide-overlay";
import { Label } from "../ui/label";
import { SocialsIcon } from "../icons";
import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
interface ActiveElement {
element: TimelineElement;
@ -26,8 +36,8 @@ interface ActiveElement {
export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore();
const { canvasSize } = useEditorStore();
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { activeProject } = useProjectStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
@ -35,7 +45,8 @@ export function PreviewPanel() {
height: 0,
});
const [isExpanded, setIsExpanded] = useState(false);
const { activeProject } = useProjectStore();
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
const [dragState, setDragState] = useState<TextElementDragState>({
isDragging: false,
elementId: null,
@ -223,7 +234,8 @@ export function PreviewPanel() {
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
// Iterate tracks from bottom to top so topmost track renders last (on top)
[...tracks].reverse().forEach((track) => {
track.elements.forEach((element) => {
if (element.hidden) return;
const elementStart = element.startTime;
@ -300,6 +312,7 @@ export function PreviewPanel() {
trimEnd={element.trimEnd}
clipDuration={element.duration}
className="w-full h-full object-cover"
trackMuted={true}
/>
</div>
);
@ -343,7 +356,7 @@ export function PreviewPanel() {
return (
<div
key={element.id}
className="absolute flex items-center justify-center cursor-grab"
className="absolute cursor-grab"
onMouseDown={(e) =>
handleTextMouseDown(e, element, elementData.track.id)
}
@ -364,7 +377,7 @@ export function PreviewPanel() {
canvasSize.height) *
100
}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
@ -372,16 +385,16 @@ export function PreviewPanel() {
<div
className={fontClassName}
style={{
fontSize: `${element.fontSize}px`,
fontSize: `${element.fontSize * scaleRatio}px`,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
whiteSpace: "pre-wrap",
padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`,
borderRadius: `${2 * scaleRatio}px`,
whiteSpace: "nowrap",
// Fallback for system fonts that don't have classes
...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
@ -423,6 +436,7 @@ export function PreviewPanel() {
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={element.muted || elementData.track.muted}
/>
</div>
);
@ -448,14 +462,18 @@ export function PreviewPanel() {
// Audio elements (no visual representation)
if (mediaItem.type === "audio") {
return (
<div key={element.id} className="absolute inset-0">
<div
key={element.id}
className="absolute inset-0"
style={{ pointerEvents: "none" }}
>
<AudioPlayer
src={mediaItem.url!}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={elementData.track.muted}
trackMuted={element.muted || elementData.track.muted}
/>
</div>
);
@ -496,13 +514,7 @@ export function PreviewPanel() {
renderElement(elementData, index)
)
)}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
<LayoutGuideOverlay />
</div>
) : null}
@ -758,13 +770,7 @@ function FullscreenPreview({
renderElement(elementData, index)
)
)}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
<LayoutGuideOverlay />
</div>
</div>
<div className="p-4 bg-background">
@ -799,6 +805,7 @@ function PreviewToolbar({
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
const { layoutGuide, toggleLayoutGuide } = useEditorStore();
if (isExpanded) {
return (
@ -818,7 +825,7 @@ function PreviewToolbar({
return (
<div
data-toolbar
className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white"
className="flex justify-end gap-2 h-auto pb-5 pr-5 pt-4 w-full"
>
<div className="flex items-center gap-2">
<Button
@ -834,6 +841,54 @@ function PreviewToolbar({
<Play className="h-3 w-3" />
)}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="h-auto p-0 mr-1"
title="Toggle layout guide"
>
<SocialsIcon className="!size-6" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Layout guide</h4>
<p className="text-sm text-muted-foreground">
Show platform-specific layout guides to help align your
content with interface elements like profile pictures,
usernames, and interaction buttons.
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="none"
checked={layoutGuide.platform === null}
onCheckedChange={() =>
toggleLayoutGuide(layoutGuide.platform || "tiktok")
}
/>
<Label htmlFor="none">None</Label>
</div>
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
<div key={platform} className="flex items-center space-x-2">
<Checkbox
id={platform}
checked={layoutGuide.platform === platform}
onCheckedChange={() =>
toggleLayoutGuide(platform as PlatformLayout)
}
/>
<Label htmlFor={platform}>{label}</Label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
<Button
variant="text"
size="icon"

View File

@ -28,9 +28,7 @@ export function SnapIndicator({
// Track scroll position to lock snap indicator to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;

View File

@ -55,7 +55,7 @@ import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
import {
getTrackHeight,
getCumulativeHeightBefore,
@ -169,11 +169,32 @@ export function Timeline() {
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
// Only track mouse down on timeline background areas (not elements)
const target = e.target as HTMLElement;
console.log(
JSON.stringify({
debug_mousedown: "START",
target_class: target.className,
target_parent_class: target.parentElement?.className,
clientX: e.clientX,
clientY: e.clientY,
timeStamp: e.timeStamp,
})
);
const isTimelineBackground =
!target.closest(".timeline-element") &&
!playheadRef.current?.contains(target) &&
!target.closest("[data-track-labels]");
console.log(
JSON.stringify({
debug_mousedown: "CHECK",
isTimelineBackground,
hasTimelineElement: !!target.closest(".timeline-element"),
hasPlayhead: !!playheadRef.current?.contains(target),
hasTrackLabels: !!target.closest("[data-track-labels]"),
})
);
if (isTimelineBackground) {
mouseTrackingRef.current = {
isMouseDown: true,
@ -181,12 +202,38 @@ export function Timeline() {
downY: e.clientY,
downTime: e.timeStamp,
};
console.log(
JSON.stringify({
debug_mousedown: "TRACKED",
mouseTracking: mouseTrackingRef.current,
})
);
} else {
console.log(
JSON.stringify({
debug_mousedown: "IGNORED - not timeline background",
})
);
}
}, []);
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
console.log(
JSON.stringify({
debug_click: "START",
target: (e.target as HTMLElement).className,
target_parent: (e.target as HTMLElement).parentElement?.className,
mouseTracking: mouseTrackingRef.current,
isSelecting,
justFinishedSelecting,
clickX: e.clientX,
clickY: e.clientY,
timeStamp: e.timeStamp,
})
);
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
// Reset mouse tracking
@ -201,8 +248,8 @@ export function Timeline() {
if (!isMouseDown) {
console.log(
JSON.stringify({
ignoredClickWithoutMouseDown: true,
timeStamp: e.timeStamp,
debug_click: "REJECTED - no mousedown",
mouseTracking: mouseTrackingRef.current,
})
);
return;
@ -216,11 +263,10 @@ export function Timeline() {
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
console.log(
JSON.stringify({
ignoredDragNotClick: true,
debug_click: "REJECTED - movement too large",
deltaX,
deltaY,
deltaTime,
timeStamp: e.timeStamp,
})
);
return;
@ -228,27 +274,54 @@ export function Timeline() {
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
console.log(
JSON.stringify({
debug_click: "REJECTED - selection operation",
isSelecting,
justFinishedSelecting,
})
);
return;
}
// Don't seek if clicking on timeline elements, but still deselect
if ((e.target as HTMLElement).closest(".timeline-element")) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked timeline element",
})
);
return;
}
// Don't seek if clicking on playhead
if (playheadRef.current?.contains(e.target as Node)) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked playhead",
})
);
return;
}
// Don't seek if clicking on track labels
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked track labels",
})
);
clearSelectedElements();
return;
}
// Clear selected elements when clicking empty timeline area
console.log(JSON.stringify({ clearingSelectedElements: true }));
console.log(
JSON.stringify({
debug_click: "PROCEEDING - clearing elements",
clearingSelectedElements: true,
})
);
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
@ -256,24 +329,39 @@ export function Timeline() {
"[data-ruler-area]"
);
console.log(
JSON.stringify({
debug_click: "CALCULATING POSITION",
isRulerClick,
clientX: e.clientX,
clientY: e.clientY,
target_element: (e.target as HTMLElement).tagName,
target_class: (e.target as HTMLElement).className,
})
);
let mouseX: number;
let scrollLeft = 0;
if (isRulerClick) {
// Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerContent) return;
const rulerContent = rulerScrollRef.current;
if (!rulerContent) {
console.log(
JSON.stringify({
debug_click: "ERROR - no ruler container found",
})
);
return;
}
const rect = rulerContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
// Calculate based on tracks content position
const tracksContent = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!tracksContent) return;
const tracksContent = tracksScrollRef.current;
if (!tracksContent) {
return;
}
const rect = tracksContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = tracksContent.scrollLeft;
@ -291,7 +379,6 @@ export function Timeline() {
// Use frame snapping for timeline clicking
const projectFps = activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
seek(time);
},
[
@ -405,7 +492,21 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
useTimelineStore.getState().addMediaToNewTrack(addedItem);
const trackType: TrackType =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = useTimelineStore
.getState()
.insertTrackAt(trackType, 0);
useTimelineStore.getState().addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
} catch (error) {
@ -428,15 +529,9 @@ export function Timeline() {
// --- Scroll synchronization effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
const trackLabelsViewport = trackLabelsScrollRef.current;
if (!rulerViewport || !tracksViewport) return;

View File

@ -50,6 +50,7 @@ export function TimelineElement({
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@ -57,7 +58,7 @@ export function TimelineElement({
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
const isAudio = mediaItem?.type === "audio";
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
@ -124,8 +125,12 @@ export function TimelineElement({
}
};
const handleToggleElementHidden = (e: React.MouseEvent) => {
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasAudio && element.type === "media") {
toggleElementMuted(track.id, element.id);
return;
}
toggleElementHidden(track.id, element.id);
};
@ -246,6 +251,8 @@ export function TimelineElement({
}
};
const isMuted = element.type === "media" ? element.muted === true : false;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
@ -279,9 +286,9 @@ export function TimelineElement({
{renderElementContent()}
</div>
{element.hidden && (
{(hasAudio ? isMuted : element.hidden) && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
{isAudio ? (
{hasAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
@ -309,9 +316,9 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementHidden}>
{isAudio ? (
element.hidden ? (
<ContextMenuItem onClick={handleToggleElementContext}>
{hasAudio ? (
isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
@ -322,8 +329,8 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{isAudio
? element.hidden
{hasAudio
? isMuted
? "Unmute"
: "Mute"
: element.hidden

View File

@ -51,9 +51,7 @@ export function TimelinePlayhead({
// Track scroll position to lock playhead to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;
@ -86,9 +84,7 @@ export function TimelinePlayhead({
// Get the timeline content width and viewport width for right boundary
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
const viewportWidth = tracksViewport?.clientWidth || 1000;
// Constrain playhead to never appear outside the timeline area
@ -126,7 +122,7 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-150"
className="absolute pointer-events-auto z-40"
style={{
left: `${leftPosition}px`,
top: 0,

View File

@ -4,11 +4,10 @@ import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
import { processMediaFiles } from "@/lib/media-processing";
import { TimelineElement } from "./timeline-element";
import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
getMainTrack,
canElementGoOnTrack,
} from "@/types/timeline";
@ -16,6 +15,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
import type {
TimelineElement as TimelineElementType,
DragData,
TrackType,
} from "@/types/timeline";
import {
snapTimeToFrame,
@ -689,8 +689,9 @@ export function TimelineTrackContent({
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasTimelineElement && !hasMediaItem) return;
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
const trackContainer = e.currentTarget.querySelector(
".track-elements-container"
@ -1050,6 +1051,50 @@ export function TimelineTrackContent({
trimEnd: 0,
});
}
} else if (hasFiles) {
// External file drops
const { activeProject } = useProjectStore.getState();
const { addMediaItem } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) {
toast.error("No active project");
return;
}
// Process and add files to new timeline tracks at playhead position
processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => {
for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
(item) =>
item.name === processedItem.name &&
item.url === processedItem.url
);
if (addedItem) {
const trackType: TrackType =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = insertTrackAt(trackType, 0);
addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
})
.catch((error) => {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
});
}
} catch (error) {
console.error("Error handling drop:", error);

View File

@ -9,7 +9,7 @@ import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<Image src="/logo.svg" alt="OpenCut Logo" className="invert dark:invert-0" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
@ -40,7 +40,7 @@ export function Header() {
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>

View File

@ -163,3 +163,71 @@ export function DataBuddyIcon({
</svg>
);
}
export function SocialsIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 345 243"
fill="none"
className={className}
>
<g opacity="0.5">
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" fill="#FFFC00"/>
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" fill="white"/>
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" stroke="black" strokeWidth="7"/>
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" stroke="black" strokeWidth="7"/>
</g>
<path fillRule="evenodd" clipRule="evenodd" d="M133.5 4H321.5C334.48 4 345 14.5205 345 27.5V215.5C345 228.48 334.48 239 321.5 239H133.5C120.52 239 110 228.48 110 215.5V27.5C110 14.5205 120.52 4 133.5 4Z" fill="#010101"/>
<path fillRule="evenodd" clipRule="evenodd" d="M261.497 99.4086C271.784 106.758 284.388 111.083 297.999 111.083V84.9035C295.423 84.9045 292.854 84.6357 290.333 84.1016V104.709C276.723 104.709 264.121 100.384 253.831 93.0345V146.459C253.831 173.185 232.155 194.85 205.417 194.85C195.44 194.85 186.167 191.835 178.464 186.665C187.256 195.65 199.516 201.224 213.08 201.224C239.821 201.224 261.498 179.56 261.498 152.833L261.497 99.4086ZM270.953 72.9965C265.696 67.2559 262.244 59.8365 261.497 51.634V48.267H254.233C256.061 58.6916 262.298 67.5981 270.953 72.9965ZM195.376 166.157C192.438 162.308 190.851 157.598 190.858 152.756C190.858 140.533 200.773 130.622 213.005 130.622C215.285 130.621 217.551 130.97 219.723 131.659V104.894C217.185 104.547 214.622 104.399 212.061 104.454V125.286C209.887 124.597 207.62 124.247 205.34 124.249C193.107 124.249 183.193 134.159 183.193 146.384C183.193 155.027 188.149 162.512 195.376 166.157Z" fill="#EE1D52"/>
<path fillRule="evenodd" clipRule="evenodd" d="M253.831 93.0345C264.121 100.384 276.723 104.709 290.333 104.709V84.1016C282.736 82.4848 276.011 78.5162 270.953 72.9965C262.298 67.5981 256.061 58.6916 254.233 48.267H235.152V152.832C235.108 165.022 225.21 174.892 213.004 174.892C205.811 174.892 199.422 171.465 195.376 166.157C188.149 162.512 183.193 155.027 183.193 146.384C183.193 134.159 193.107 124.249 205.34 124.249C207.683 124.249 209.942 124.614 212.061 125.286V104.454C185.792 104.996 164.665 126.449 164.665 152.833C164.665 166.003 169.926 177.942 178.464 186.665C186.167 191.835 195.44 194.85 205.417 194.85C232.155 194.85 253.831 173.185 253.831 146.459V93.0345Z" fill="white"/>
<path fillRule="evenodd" clipRule="evenodd" d="M290.333 84.1016V78.5293C283.482 78.5396 276.766 76.6229 270.953 72.9965C276.099 78.6269 282.874 82.5087 290.333 84.1016ZM254.233 48.267C254.058 47.2708 253.924 46.2679 253.831 45.2608V41.8938H227.485V146.459C227.443 158.648 217.545 168.518 205.339 168.518C201.754 168.518 198.372 167.669 195.376 166.157C199.422 171.465 205.811 174.892 213.004 174.892C225.21 174.892 235.108 165.022 235.152 152.832V48.267H254.233ZM212.061 104.454L212.061 98.5212C209.859 98.2202 207.64 98.0698 205.418 98.071C178.676 98.071 157 119.736 157 146.459C157 163.214 165.518 177.979 178.464 186.665C169.926 177.942 164.666 166.002 164.666 152.832C164.666 126.449 185.791 104.996 212.061 104.454Z" fill="#69C9D0"/>
</svg>
);
}
export function TransitionUpIcon({
className = "",
size = 16,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 12.6667C2.5 13.4951 3.17157 14.1667 4 14.1667H12C12.8284 14.1667 13.5 13.4951 13.5 12.6667V11.3333C13.5 10.5049 12.8284 9.83333 12 9.83333H4C3.17157 9.83333 2.5 10.5049 2.5 11.3333V12.6667ZM4 15.1667C2.61929 15.1667 1.5 14.0474 1.5 12.6667V11.3333C1.5 9.95262 2.61929 8.83333 4 8.83333H12C13.3807 8.83333 14.5 9.95262 14.5 11.3333V12.6667C14.5 14.0474 13.3807 15.1667 12 15.1667H4Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 5.83333C1.72386 5.83333 1.5 5.60947 1.5 5.33333L1.5 4C1.5 2.2511 2.91777 0.833332 4.66667 0.833332L11.3333 0.833332C13.0822 0.833332 14.5 2.2511 14.5 4V5.33333C14.5 5.60947 14.2761 5.83333 14 5.83333C13.7239 5.83333 13.5 5.60947 13.5 5.33333V4C13.5 2.80338 12.53 1.83333 11.3333 1.83333L4.66667 1.83333C3.47005 1.83333 2.5 2.80338 2.5 4V5.33333C2.5 5.60947 2.27614 5.83333 2 5.83333Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.35355 3.64645C8.15829 3.45118 7.84171 3.45118 7.64645 3.64645L5.64645 5.64645C5.45118 5.84171 5.45118 6.15829 5.64645 6.35355C5.84171 6.54882 6.15829 6.54882 6.35355 6.35355L7.5 5.20711L7.5 9.33333C7.5 9.60948 7.72386 9.83333 8 9.83333C8.27614 9.83333 8.5 9.60948 8.5 9.33333V5.20711L9.64645 6.35355C9.84171 6.54882 10.1583 6.54882 10.3536 6.35355C10.5488 6.15829 10.5488 5.84171 10.3536 5.64645L8.35355 3.64645Z"
fill="white"
/>
</svg>
);
}

View File

@ -50,7 +50,7 @@ export function Handlebars({ children }: HandlebarsProps) {
<div ref={containerRef} className="relative -rotate-[2.76deg] mt-0.5">
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-1">
<motion.div
className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-background flex items-center justify-center select-none"
style={{
x: leftHandleX,
}}
@ -66,7 +66,7 @@ export function Handlebars({ children }: HandlebarsProps) {
</motion.div>
<motion.div
className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-background flex items-center justify-center select-none"
style={{
x: rightHandleX,
}}

View File

@ -14,8 +14,8 @@ export function Hero() {
return (
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
<Image
className="absolute top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
className="absolute top-0 left-0 -z-50 size-full object-cover invert dark:invert-0 opacity-85"
src="/landing-page-dark.png"
height={1903.5}
width={1269}
alt="landing-page.bg"

View File

@ -0,0 +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<HTMLDivElement>;
languages: Language[];
}
function FlagPreloader({ languages }: { languages: Language[] }) {
return (
<div className="absolute -top-[9999px] left-0 pointer-events-none">
{languages.map((language) => (
<ReactCountryFlag
key={language.code}
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
))}
</div>
);
}
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<HTMLButtonElement>(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<HTMLButtonElement>;
}) => {
e.stopPropagation();
e.preventDefault();
onSelect(code);
setExpanded(false);
};
return (
<div className="relative w-full h-9">
<FlagPreloader languages={languages} />
<motion.button
type="button"
className={cn(
"absolute w-full h-full flex flex-col overflow-hidden items-start justify-between z-10 rounded-lg px-3 cursor-pointer",
"!bg-foreground/10 backdrop-blur-md text-foreground py-0",
"transition-[color,box-shadow] focus:border-ring focus:ring-ring/50 focus:ring-[1px]"
)}
initial={{
height: collapsedHeight,
scale: 1,
}}
animate={{
height: expanded ? expandHeight : collapsedHeight,
scale: isTapping ? [1, 0.985, 1] : 1,
}}
transition={{
height: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },
scale: { duration: 0.6, ease: "easeOut" },
}}
onClick={expand}
ref={buttonRef}
>
{!expanded ? (
<div
className="flex items-center justify-between w-full"
style={{
height: collapsedHeight,
}}
>
<div className="flex items-center gap-2">
{selectedCountry === "auto" ? (
<Globe className="!size-[1.05rem]" />
) : (
<ReactCountryFlag
countryCode={selectedCountry}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">
{selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
</span>
</div>
</div>
) : (
<div className="flex flex-col gap-2 my-2.5 w-full overflow-y-auto scrollbar-hidden">
<LanguageButton
language={{ code: "auto", name: "Auto" }}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
{languages.map((language) => (
<LanguageButton
key={language.code}
language={language}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
))}
</div>
)}
</motion.button>
<motion.div
className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none z-20 mt-0.5"
initial={{ opacity: 1 }}
animate={{ opacity: expanded ? 0 : 1 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<ChevronDown className="text-muted-foreground size-4" />
</motion.div>
</div>
);
}
function LanguageButton({
language,
onSelect,
selectedCountry,
}: {
language: Language;
onSelect: ({
code,
e,
}: {
code: string;
e: React.MouseEvent<HTMLButtonElement>;
}) => void;
selectedCountry: string;
}) {
return (
<button
type="button"
className="flex items-center gap-2 cursor-pointer text-foreground hover:text-foreground/75"
onClick={(e) => onSelect({ code: language.code, e })}
>
{language.code === "auto" ? (
<Globe className="!size-[1.0rem]" />
) : (
<ReactCountryFlag
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">{language.name}</span>
</button>
);
}

View File

@ -79,7 +79,7 @@ export function Onboarding() {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] outline-hidden!">
<DialogContent className="sm:max-w-[425px] !outline-none">
<DialogTitle>
<span className="sr-only">{getStepTitle()}</span>
</DialogTitle>

View File

@ -0,0 +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<PanelPreset, string> = {
default: "Default",
media: "Media",
inspector: "Inspector",
"vertical-preview": "Vertical Preview",
};
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 flex items-center gap-1 text-xs"
title="Panel Presets"
>
<LayoutPanelTop className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
className="flex items-start justify-between gap-2 py-2 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PRESET_LABELS[preset]}
</span>
{activePreset === preset && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
{PRESET_DESCRIPTIONS[preset]}
</p>
</div>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
onClick={(e) => handleResetPreset(preset, e)}
title={`Reset ${PRESET_LABELS[preset]} preset`}
>
<RotateCcw className="h-3 w-3" />
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -51,7 +51,7 @@ export function RenameProjectDialog({
}
}}
placeholder="Enter a new name"
className="mt-4 bg-background/50"
className="mt-4"
/>
<DialogFooter>

View File

@ -13,6 +13,8 @@ const buttonVariants = cva(
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
primary:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:

View File

@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-100 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-150 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}

View File

@ -51,7 +51,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={inputType}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-accent/50 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
paddingRight,

View File

@ -19,18 +19,18 @@ export function SponsorButton({
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-xs hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-background/5 backdrop-blur-xs hover:bg-background/10 transition-all duration-200 group shadow-lg ${className}`}
>
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors">
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">
Sponsored by
</span>
<div className="flex items-center gap-1.5">
<div className="text-zinc-100 group-hover:text-white transition-colors">
<div className="text-foreground/90 group-hover:text-foreground transition-colors">
<Logo className="w-4 h-4" />
</div>
<span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors">
<span className="text-xs font-medium text-foreground/90 group-hover:text-foreground transition-colors">
{companyName}
</span>
</div>

View File

@ -1,9 +1,8 @@
"use client";
import { cva, type VariantProps } from 'class-variance-authority';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import * as React from 'react';
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
@ -11,22 +10,63 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const tooltipVariants = cva(
'z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
{
variants: {
variant: {
default: 'bg-popover text-popover-foreground border px-3 py-1.5',
destructive:
'bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]',
outline: 'border-border',
important:
'bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]',
promotions:
'bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]',
personal:
'bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]',
updates:
'bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]',
forums:
'bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]',
sidebar: 'bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2',
},
},
defaultVariants: {
variant: 'default',
},
},
);
interface TooltipContentProps
extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
VariantProps<typeof tooltipVariants> {}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
TooltipContentProps
>(({ className, sideOffset = 4, variant, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
{variant === 'sidebar' && (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute left-[-6px] top-1/2 -translate-y-1/2"
>
<path d="M6 0L0 5L6 10V0Z" className="fill-white/80 dark:fill-[#413F3E]" />
</svg>
)}
{props.children}
</TooltipPrimitive.Content>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -11,6 +11,7 @@ interface VideoPlayerProps {
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function VideoPlayer({
@ -21,6 +22,7 @@ export function VideoPlayer({
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
@ -109,9 +111,9 @@ export function VideoPlayer({
if (!video) return;
video.volume = volume;
video.muted = muted;
video.muted = muted || trackMuted;
video.playbackRate = speed;
}, [volume, speed, muted]);
}, [volume, speed, muted, trackMuted]);
return (
<video

View File

@ -27,4 +27,7 @@ export const patternCraftGradients = [
// Purple Glow Left
"radial-gradient(circle at top left, rgba(173, 109, 244, 0.5), transparent 70%)",
// Pastel Wave
"linear-gradient(120deg, #d5c5ff 0%, #a7f3d0 50%, #f0f0f0 100%)",
];

View File

@ -17,6 +17,13 @@ export const env = createEnv({
UPSTASH_REDIS_REST_TOKEN: z.string(),
FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: z.string(),
// R2 / Cloudflare
CLOUDFLARE_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
R2_BUCKET_NAME: z.string(),
// Modal transcription
MODAL_TRANSCRIPTION_URL: z.string(),
},
client: {},
runtimeEnv: {
@ -27,5 +34,12 @@ export const env = createEnv({
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
FREESOUND_CLIENT_ID: process.env.FREESOUND_CLIENT_ID,
FREESOUND_API_KEY: process.env.FREESOUND_API_KEY,
// R2 / Cloudflare
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
R2_BUCKET_NAME: process.env.R2_BUCKET_NAME,
// Modal transcription
MODAL_TRANSCRIPTION_URL: process.env.MODAL_TRANSCRIPTION_URL,
},
});

View File

@ -1,12 +1,17 @@
import { useEditorStore } from "@/stores/editor-store";
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
export function useAspectRatio() {
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
const { canvasPresets } = useEditorStore();
const { activeProject } = useProjectStore();
const { mediaItems } = useMediaStore();
const { tracks } = useTimelineStore();
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
const canvasMode = activeProject?.canvasMode || "preset";
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>

View File

@ -119,12 +119,8 @@ export function useTimelinePlayhead({
// Auto-scroll function during dragging
const performAutoScroll = useCallback(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport || !isScrubbing) return;
@ -240,12 +236,8 @@ export function useTimelinePlayhead({
// Only auto-scroll during playback, not during manual interactions
if (!isPlaying || isScrubbing) return;
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50

View File

@ -0,0 +1,46 @@
import { CanvasSize } from "@/types/editor";
const DEFAULT_CANVAS_PRESETS = [
{ name: "16:9", width: 1920, height: 1080 },
{ name: "9:16", width: 1080, height: 1920 },
{ name: "1:1", width: 1080, height: 1080 },
{ name: "4:3", width: 1440, height: 1080 },
];
/**
* Helper function to find the best matching canvas preset for an aspect ratio
* @param aspectRatio The target aspect ratio to match
* @returns The best matching canvas size
*/
export function findBestCanvasPreset(aspectRatio: number): CanvasSize {
// Calculate aspect ratio for each preset and find the closest match
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
let smallestDifference = Math.abs(
aspectRatio - bestMatch.width / bestMatch.height
);
for (const preset of DEFAULT_CANVAS_PRESETS) {
const presetAspectRatio = preset.width / preset.height;
const difference = Math.abs(aspectRatio - presetAspectRatio);
if (difference < smallestDifference) {
smallestDifference = difference;
bestMatch = preset;
}
}
// If the difference is still significant (> 0.1), create a custom size
// based on the media aspect ratio with a reasonable resolution
const bestAspectRatio = bestMatch.width / bestMatch.height;
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
// Create custom dimensions based on the aspect ratio
if (aspectRatio > 1) {
// Landscape - use 1920 width
return { width: 1920, height: Math.round(1920 / aspectRatio) };
}
// Portrait or square - use 1080 height
return { width: Math.round(1080 * aspectRatio), height: 1080 };
}
return { width: bestMatch.width, height: bestMatch.height };
}

View File

@ -1,5 +1,7 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
let ffmpeg: FFmpeg | null = null;
@ -7,14 +9,7 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
if (ffmpeg) return ffmpeg;
ffmpeg = new FFmpeg();
// Use locally hosted files instead of CDN
const baseURL = "/ffmpeg";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
});
await ffmpeg.load(); // Use default config
return ffmpeg;
};
@ -268,3 +263,206 @@ export const extractAudio = async (
return blob;
};
export const extractTimelineAudio = async (
onProgress?: (progress: number) => void
): Promise<Blob> => {
// Create fresh FFmpeg instance for this operation
const ffmpeg = new FFmpeg();
try {
await ffmpeg.load();
} catch (error) {
console.error("Failed to load fresh FFmpeg instance:", error);
throw new Error("Unable to initialize audio processing. Please try again.");
}
const timeline = useTimelineStore.getState();
const mediaStore = useMediaStore.getState();
const tracks = timeline.tracks;
const totalDuration = timeline.getTotalDuration();
if (totalDuration === 0) {
const emptyAudioData = new ArrayBuffer(44);
return new Blob([emptyAudioData], { type: "audio/wav" });
}
if (onProgress) {
ffmpeg.on("progress", ({ progress }) => {
onProgress(progress * 100);
});
}
const audioElements: Array<{
file: File;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
trackMuted: boolean;
}> = [];
for (const track of tracks) {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaStore.mediaItems.find(
(m) => m.id === element.mediaId
);
if (!mediaItem) continue;
if (mediaItem.type === "video" || mediaItem.type === "audio") {
audioElements.push({
file: mediaItem.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
trackMuted: track.muted || false,
});
}
}
}
}
if (audioElements.length === 0) {
// Return silent audio if no audio elements
const silentDuration = Math.max(1, totalDuration); // At least 1 second
try {
const silentAudio = await generateSilentAudio(silentDuration);
return silentAudio;
} catch (error) {
console.error("Failed to generate silent audio:", error);
throw new Error("Unable to generate audio for empty timeline.");
}
}
// Create a complex filter to mix all audio sources
const inputFiles: string[] = [];
const filterInputs: string[] = [];
try {
for (let i = 0; i < audioElements.length; i++) {
const element = audioElements[i];
const inputName = `input_${i}.${element.file.name.split(".").pop()}`;
inputFiles.push(inputName);
try {
await ffmpeg.writeFile(
inputName,
new Uint8Array(await element.file.arrayBuffer())
);
} catch (error) {
console.error(`Failed to write file ${element.file.name}:`, error);
throw new Error(
`Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.`
);
}
const actualStart = element.trimStart;
const actualDuration =
element.duration - element.trimStart - element.trimEnd;
const filterName = `audio_${i}`;
filterInputs.push(
`[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]`
);
}
const mixFilter =
audioElements.length === 1
? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`
: `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
const complexFilter = [...filterInputs, mixFilter].join(";");
const outputName = "timeline_audio.wav";
const ffmpegArgs = [
...inputFiles.flatMap((name) => ["-i", name]),
"-filter_complex",
complexFilter,
"-map",
"[out]",
"-t",
totalDuration.toString(),
"-c:a",
"pcm_s16le",
"-ar",
"44100",
outputName,
];
try {
await ffmpeg.exec(ffmpegArgs);
} catch (error) {
console.error("FFmpeg execution failed:", error);
throw new Error(
"Audio processing failed. Some audio files may be corrupted or incompatible."
);
}
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: "audio/wav" });
return blob;
} catch (error) {
for (const inputFile of inputFiles) {
try {
await ffmpeg.deleteFile(inputFile);
} catch (cleanupError) {
console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError);
}
}
try {
await ffmpeg.deleteFile("timeline_audio.wav");
} catch (cleanupError) {
console.warn("Failed to cleanup output file:", cleanupError);
}
throw error;
} finally {
for (const inputFile of inputFiles) {
try {
await ffmpeg.deleteFile(inputFile);
} catch (cleanupError) {}
}
try {
await ffmpeg.deleteFile("timeline_audio.wav");
} catch (cleanupError) {}
}
};
const generateSilentAudio = async (durationSeconds: number): Promise<Blob> => {
const ffmpeg = await initFFmpeg();
const outputName = "silent.wav";
try {
await ffmpeg.exec([
"-f",
"lavfi",
"-i",
`anullsrc=channel_layout=stereo:sample_rate=44100`,
"-t",
durationSeconds.toString(),
"-c:a",
"pcm_s16le",
outputName,
]);
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: "audio/wav" });
return blob;
} catch (error) {
console.error("Failed to generate silent audio:", error);
throw error;
} finally {
try {
await ffmpeg.deleteFile(outputName);
} catch (cleanupError) {
// Silent cleanup
}
}
};

View File

@ -74,6 +74,8 @@ class StorageService {
blurIntensity: project.blurIntensity,
bookmarks: project.bookmarks,
fps: project.fps,
canvasSize: project.canvasSize,
canvasMode: project.canvasMode,
};
await this.projectsAdapter.set(project.id, serializedProject);
@ -96,6 +98,8 @@ class StorageService {
blurIntensity: serializedProject.blurIntensity,
bookmarks: serializedProject.bookmarks,
fps: serializedProject.fps,
canvasSize: serializedProject.canvasSize,
canvasMode: serializedProject.canvasMode,
};
}

View File

@ -0,0 +1,13 @@
import { env } from "@/env";
export function isTranscriptionConfigured() {
const missingVars = [];
if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID");
if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID");
if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY");
if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME");
if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL");
return { configured: missingVars.length === 0, missingVars };
}

View File

@ -0,0 +1,71 @@
/**
* True zero-knowledge encryption utilities
* Keys are generated randomly in the browser and never derived from server secrets
*/
export interface ZeroKnowledgeEncryptionResult {
encryptedData: ArrayBuffer;
key: ArrayBuffer;
iv: ArrayBuffer;
}
/**
* Encrypt data with a randomly generated key (true zero-knowledge)
*/
export async function encryptWithRandomKey(
data: ArrayBuffer
): Promise<ZeroKnowledgeEncryptionResult> {
// Generate a truly random 256-bit key
const key = crypto.getRandomValues(new Uint8Array(32));
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Import the key for encryption
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "AES-GCM" },
false,
["encrypt"]
);
// Encrypt the data
const encryptedResult = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
cryptoKey,
data
);
// For AES-GCM, we need to append the authentication tag
// The encrypted result contains both ciphertext and tag
return {
encryptedData: encryptedResult,
key: key.buffer,
iv: iv.buffer,
};
}
/**
* Convert ArrayBuffer to base64 string for transmission
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string back to ArrayBuffer
*/
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}

View File

@ -1,25 +1,32 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
import { persist } from "zustand/middleware";
import { CanvasPreset } from "@/types/editor";
type CanvasMode = "preset" | "original" | "custom";
export type PlatformLayout = "tiktok";
export const PLATFORM_LAYOUTS: Record<PlatformLayout, string> = {
tiktok: "TikTok",
};
interface LayoutGuideSettings {
platform: PlatformLayout | null;
}
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
// Canvas/Project settings
canvasSize: CanvasSize;
canvasMode: CanvasMode;
// Editor UI settings
canvasPresets: CanvasPreset[];
layoutGuide: LayoutGuideSettings;
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
toggleLayoutGuide: (platform: PlatformLayout) => void;
}
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
@ -29,76 +36,56 @@ const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
{ name: "4:3", width: 1440, height: 1080 },
];
// Helper function to find the best matching canvas preset for an aspect ratio
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
// Calculate aspect ratio for each preset and find the closest match
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
let smallestDifference = Math.abs(
aspectRatio - bestMatch.width / bestMatch.height
);
export const useEditorStore = create<EditorState>()(
persist(
(set) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
canvasPresets: DEFAULT_CANVAS_PRESETS,
layoutGuide: {
platform: null,
},
for (const preset of DEFAULT_CANVAS_PRESETS) {
const presetAspectRatio = preset.width / preset.height;
const difference = Math.abs(aspectRatio - presetAspectRatio);
// Actions
setInitializing: (loading) => {
set({ isInitializing: loading });
},
if (difference < smallestDifference) {
smallestDifference = difference;
bestMatch = preset;
setPanelsReady: (ready) => {
set({ isPanelsReady: ready });
},
initializeApp: async () => {
console.log("Initializing video editor...");
set({ isInitializing: true, isPanelsReady: false });
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
setLayoutGuide: (settings) => {
set((state) => ({
layoutGuide: {
...state.layoutGuide,
...settings,
},
}));
},
toggleLayoutGuide: (platform) => {
set((state) => ({
layoutGuide: {
platform: state.layoutGuide.platform === platform ? null : platform,
},
}));
},
}),
{
name: "editor-settings",
partialize: (state) => ({
layoutGuide: state.layoutGuide,
}),
}
}
// If the difference is still significant (> 0.1), create a custom size
// based on the media aspect ratio with a reasonable resolution
const bestAspectRatio = bestMatch.width / bestMatch.height;
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
// Create custom dimensions based on the aspect ratio
if (aspectRatio > 1) {
// Landscape - use 1920 width
return { width: 1920, height: Math.round(1920 / aspectRatio) };
}
// Portrait or square - use 1080 height
return { width: Math.round(1080 * aspectRatio), height: 1080 };
}
return { width: bestMatch.width, height: bestMatch.height };
};
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
set({ isInitializing: loading });
},
setPanelsReady: (ready) => {
set({ isPanelsReady: ready });
},
initializeApp: async () => {
console.log("Initializing video editor...");
set({ isInitializing: true, isPanelsReady: false });
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
setCanvasSize: (size) => {
set({ canvasSize: size, canvasMode: "preset" });
},
setCanvasSizeToOriginal: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));
)
);

View File

@ -1,48 +1,222 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
const DEFAULT_PANEL_SIZES = {
toolsPanel: 45,
previewPanel: 75,
propertiesPanel: 20,
mainContent: 70,
timeline: 30,
} as const;
export type PanelPreset =
| "default"
| "media"
| "inspector"
| "vertical-preview";
interface PanelState {
// Panel sizes as percentages
interface PanelSizes {
toolsPanel: number;
previewPanel: number;
propertiesPanel: number;
mainContent: number;
timeline: number;
}
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = {
default: {
toolsPanel: 25,
previewPanel: 50,
propertiesPanel: 25,
mainContent: 70,
timeline: 30,
},
media: {
toolsPanel: 30,
previewPanel: 45,
propertiesPanel: 25,
mainContent: 100,
timeline: 25,
},
inspector: {
toolsPanel: 30,
previewPanel: 70,
propertiesPanel: 30,
mainContent: 75,
timeline: 25,
},
"vertical-preview": {
toolsPanel: 30,
previewPanel: 40,
propertiesPanel: 30,
mainContent: 75,
timeline: 25,
},
};
interface PanelState extends PanelSizes {
activePreset: PanelPreset;
presetCustomSizes: Record<PanelPreset, Partial<PanelSizes>>;
resetCounter: number;
mediaViewMode: "grid" | "list";
// Actions
setToolsPanel: (size: number) => void;
setPreviewPanel: (size: number) => void;
setPropertiesPanel: (size: number) => void;
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
setMediaViewMode: (mode: "grid" | "list") => void;
setActivePreset: (preset: PanelPreset) => void;
resetPreset: (preset: PanelPreset) => void;
getCurrentPresetSizes: () => PanelSizes;
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Default sizes - optimized for responsiveness
...DEFAULT_PANEL_SIZES,
(set, get) => ({
...PRESET_CONFIGS.default,
activePreset: "default" as PanelPreset,
presetCustomSizes: {
default: {},
media: {},
inspector: {},
"vertical-preview": {},
},
resetCounter: 0,
mediaViewMode: "grid" as const,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
setPreviewPanel: (size) => set({ previewPanel: size }),
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
setMainContent: (size) => set({ mainContent: size }),
setTimeline: (size) => set({ timeline: size }),
setToolsPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
toolsPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
toolsPanel: size,
},
},
});
},
setPreviewPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
previewPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
previewPanel: size,
},
},
});
},
setPropertiesPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
propertiesPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
propertiesPanel: size,
},
},
});
},
setMainContent: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
mainContent: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
mainContent: size,
},
},
});
},
setTimeline: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
timeline: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
timeline: size,
},
},
});
},
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
setActivePreset: (preset) => {
const {
activePreset: currentPreset,
presetCustomSizes,
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
} = get();
const updatedPresetCustomSizes = {
...presetCustomSizes,
[currentPreset]: {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
},
};
const defaultSizes = PRESET_CONFIGS[preset];
const customSizes = updatedPresetCustomSizes[preset] || {};
const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes;
set({
activePreset: preset,
presetCustomSizes: updatedPresetCustomSizes,
...finalSizes,
});
},
resetPreset: (preset) => {
const { presetCustomSizes, activePreset, resetCounter } = get();
const defaultSizes = PRESET_CONFIGS[preset];
const newPresetCustomSizes = {
...presetCustomSizes,
[preset]: {},
};
const updates: Partial<PanelState> = {
presetCustomSizes: newPresetCustomSizes,
resetCounter: resetCounter + 1,
};
if (preset === activePreset) {
Object.assign(updates, defaultSizes);
}
set(updates);
},
getCurrentPresetSizes: () => {
const {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
} = get();
return {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
};
},
}),
{
name: "panel-sizes",

View File

@ -82,6 +82,24 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
speed: 1.0,
play: () => {
const state = get();
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (effectiveDuration > 0) {
const fps = useProjectStore.getState().activeProject?.fps ?? 30;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
if (state.currentTime >= endThreshold) {
get().seek(0);
}
}
set({ isPlaying: true });
startTimer(get);
},

View File

@ -5,6 +5,24 @@ import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
import { CanvasSize, CanvasMode } from "@/types/editor";
export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 };
const DEFAULT_PROJECT: TProject = {
id: generateUUID(),
name: "Untitled",
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
bookmarks: [],
fps: 30,
canvasSize: DEFAULT_CANVAS_SIZE,
canvasMode: "preset",
};
interface ProjectStore {
activeProject: TProject | null;
@ -28,6 +46,7 @@ interface ProjectStore {
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
// Bookmark methods
toggleBookmark: (time: number) => Promise<void>;
@ -144,18 +163,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
},
createNewProject: async (name: string) => {
const newProject: TProject = {
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
bookmarks: [],
fps: 30,
};
const newProject: TProject = { ...DEFAULT_PROJECT, name };
set({ activeProject: newProject });
@ -428,6 +436,29 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
}
},
updateCanvasSize: async (size: CanvasSize, mode: CanvasMode) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
canvasSize: size,
canvasMode: mode,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update canvas size:", error);
toast.error("Failed to update canvas size", {
description: "Please try again",
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();

View File

@ -10,17 +10,16 @@ import {
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import {
useMediaStore,
getMediaAspectRatio,
type MediaItem,
} from "./media-store";
import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { toast } from "sonner";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
// Helper function to manage element naming with suffixes
@ -30,10 +29,10 @@ const getElementNameWithSuffix = (
): string => {
// Remove existing suffixes to prevent accumulation
const baseName = originalName
.replace(/ \(left\)$/, "")
.replace(/ \(right\)$/, "")
.replace(/ \(audio\)$/, "")
.replace(/ \(split \d+\)$/, "");
.replace(/ \(left\)$/i, "")
.replace(/ \(right\)$/i, "")
.replace(/ \(audio\)$/i, "")
.replace(/ \(split \d+\)$/i, "");
return `${baseName} (${suffix})`;
};
@ -127,6 +126,7 @@ interface TimelineStore {
) => void;
toggleTrackMute: (trackId: string) => void;
toggleElementHidden: (trackId: string, elementId: string) => void;
toggleElementMuted: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
@ -323,7 +323,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
addTrack: (type) => {
get().pushHistory();
// Generate proper track name based on type
const trackName =
type === "media"
? "Media Track"
@ -348,7 +347,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
insertTrackAt: (type, index) => {
get().pushHistory();
// Generate proper track name based on type
const trackName =
type === "media"
? "Media Track"
@ -393,13 +391,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().pushHistory();
// If track has no elements, just remove it normally
if (trackToRemove.elements.length === 0) {
updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
return;
}
// Find all the time ranges occupied by elements in the track being removed
const occupiedRanges = trackToRemove.elements.map((element) => ({
startTime: element.startTime,
endTime:
@ -407,10 +398,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
(element.duration - element.trimStart - element.trimEnd),
}));
// Sort ranges by start time
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
// Merge overlapping ranges to get consolidated gaps
const mergedRanges: Array<{
startTime: number;
endTime: number;
@ -427,11 +416,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} else {
const lastRange = mergedRanges[mergedRanges.length - 1];
if (range.startTime <= lastRange.endTime) {
// Overlapping or adjacent ranges, merge them
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
lastRange.duration = lastRange.endTime - lastRange.startTime;
} else {
// Non-overlapping range, add as new
mergedRanges.push({
startTime: range.startTime,
endTime: range.endTime,
@ -441,17 +428,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
}
// Remove the track and apply ripple effects to remaining tracks
const updatedTracks = _tracks
.filter((track) => track.id !== trackId)
.map((track) => {
const updatedElements = track.elements.map((element) => {
let newStartTime = element.startTime;
// Process gaps from right to left (latest to earliest) to avoid cumulative shifts
for (let i = mergedRanges.length - 1; i >= 0; i--) {
const gap = mergedRanges[i];
// If this element starts after the gap, shift it left by the gap duration
if (newStartTime >= gap.endTime) {
newStartTime -= gap.duration;
}
@ -463,7 +447,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
});
// Check for overlaps and resolve them if necessary
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
const resolvedElements = resolveElementOverlaps(updatedElements);
@ -479,33 +462,28 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
addElementToTrack: (trackId, elementData) => {
get().pushHistory();
// Validate element type matches track type
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) {
console.error("Track not found:", trackId);
return;
}
// Use utility function for validation
const validation = validateElementTrackCompatibility(elementData, track);
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
// For media elements, validate mediaId exists
if (elementData.type === "media" && !elementData.mediaId) {
console.error("Media element must have mediaId");
return;
}
// For text elements, validate required text properties
if (elementData.type === "text" && !elementData.content) {
console.error("Text element must have content");
return;
}
// Check if this is the first element being added to the timeline
const currentState = get();
const totalElementsInTimeline = currentState._tracks.reduce(
(total, track) => total + track.elements.length,
@ -519,10 +497,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
startTime: elementData.startTime || 0,
trimStart: 0,
trimEnd: 0,
} as TimelineElement; // Type assertion since we trust the caller passes valid data
...(elementData.type === "media" ? { muted: false } : {}),
} as TimelineElement;
// If this is the first element and it's a media element, automatically set the project canvas size
// to match the media's aspect ratio and FPS (for videos)
if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaItems.find(
@ -533,13 +510,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
mediaItem &&
(mediaItem.type === "image" || mediaItem.type === "video")
) {
const editorStore = useEditorStore.getState();
editorStore.setCanvasSizeFromAspectRatio(
getMediaAspectRatio(mediaItem)
const projectStore = useProjectStore.getState();
projectStore.updateCanvasSize(
findBestCanvasPreset(getMediaAspectRatio(mediaItem)),
"original"
);
}
// Set project FPS from the first video element
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
const projectStore = useProjectStore.getState();
if (projectStore.activeProject) {
@ -591,7 +568,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
// If ripple editing is disabled, use regular removal
get().removeElementFromTrack(trackId, elementId, pushHistory);
return;
}
@ -608,15 +584,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
element.duration - element.trimStart - element.trimEnd;
const elementEndTime = elementStartTime + elementDuration;
// Remove the element and shift all elements that come after it
const updatedTracks = _tracks
.map((currentTrack) => {
// Only apply ripple effects to the same track unless multi-track ripple is enabled
const shouldApplyRipple = currentTrack.id === trackId;
const updatedElements = currentTrack.elements
.filter((currentElement) => {
// Remove the target element
if (
currentElement.id === elementId &&
currentTrack.id === trackId
@ -626,12 +599,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return true;
})
.map((currentElement) => {
// Only apply ripple effects if we should process this track
if (!shouldApplyRipple) {
return currentElement;
}
// Shift elements that start after the removed element
if (currentElement.startTime >= elementEndTime) {
return {
...currentElement,
@ -644,10 +615,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return currentElement;
});
// Check for overlaps and resolve them if necessary
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
// Resolve overlaps by adjusting element positions
const resolvedElements = resolveElementOverlaps(updatedElements);
return { ...currentTrack, elements: resolvedElements };
}
@ -670,7 +639,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (!elementToMove || !toTrack) return;
// Validate element type compatibility with target track
const validation = validateElementTrackCompatibility(
elementToMove,
toTrack
@ -776,7 +744,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
// If ripple editing is disabled, use regular update
get().updateElementStartTime(trackId, elementId, newStartTime);
return;
}
@ -796,9 +763,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
newStartTime + (element.duration - element.trimStart - element.trimEnd);
const timeDelta = newStartTime - oldStartTime;
// Update tracks based on multi-track ripple setting
const updatedTracks = _tracks.map((currentTrack) => {
// Only apply ripple effects to the same track unless multi-track ripple is enabled
const shouldApplyRipple = currentTrack.id === trackId;
const updatedElements = currentTrack.elements.map((currentElement) => {
@ -806,12 +771,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return { ...currentElement, startTime: Math.max(0, newStartTime) };
}
// Only apply ripple effects if we should process this track
if (!shouldApplyRipple) {
return currentElement;
}
// For ripple editing, we need to move elements that come after the moved element
const currentElementStart = currentElement.startTime;
const currentElementEnd =
currentElement.startTime +
@ -819,19 +782,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
currentElement.trimStart -
currentElement.trimEnd);
// If moving element to the right (positive delta)
if (timeDelta > 0) {
// Move elements that start after the original position of the moved element
if (currentElementStart >= oldEndTime) {
return {
...currentElement,
startTime: currentElementStart + timeDelta,
};
}
}
// If moving element to the left (negative delta)
else if (timeDelta < 0) {
// Move elements that start after the new position of the moved element
} else if (timeDelta < 0) {
if (
currentElementStart >= newEndTime &&
currentElementStart >= oldStartTime
@ -846,10 +804,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return currentElement;
});
// Check for overlaps and resolve them if necessary
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
// Resolve overlaps by adjusting element positions
const resolvedElements = resolveElementOverlaps(updatedElements);
return { ...currentTrack, elements: resolvedElements };
}
@ -887,6 +843,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
toggleElementMuted: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId && element.type === "media"
? { ...element, muted: !element.muted }
: element
),
}
: track
)
);
},
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
@ -1050,12 +1024,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().pushHistory();
// Find existing audio track or prepare to create one
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
const audioElementId = generateUUID();
if (existingAudioTrack) {
// Add audio element to existing audio track
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === existingAudioTrack.id
@ -1074,7 +1046,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
)
);
} else {
// Create new audio track with the audio element in a single atomic update
const newAudioTrack: TimelineTrack = {
id: generateUUID(),
name: "Audio Track",
@ -1124,7 +1095,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return { success: false, error: "No active project found" };
}
// Import required media processing functions
const {
getFileType,
getImageDimensions,
@ -1141,7 +1111,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
// Process the new media file
const mediaData: any = {
name: newFile.name,
type: fileType,
@ -1150,7 +1119,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
try {
// Get media-specific metadata
if (fileType === "image") {
const { width, height } = await getImageDimensions(newFile);
mediaData.width = width;
@ -1175,7 +1143,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
// Add new media item to store
try {
await mediaStore.addMediaItem(
projectStore.activeProject.id,
@ -1188,7 +1155,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
};
}
// Find the newly created media item
const newMediaItem = mediaStore.mediaItems.find(
(item) => item.file === newFile
);
@ -1202,7 +1168,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().pushHistory();
// Update the timeline element to reference the new media
updateTracksAndSave(
_tracks.map((track) =>
track.id === trackId
@ -1214,7 +1179,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
...c,
mediaId: newMediaItem.id,
name: newMediaItem.name,
// Update duration if the new media has a different duration
duration: newMediaItem.duration || c.duration,
}
: c
@ -1355,22 +1319,18 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
});
},
// Persistence methods
loadProjectTimeline: async (projectId) => {
try {
const tracks = await storageService.loadTimeline(projectId);
if (tracks) {
updateTracks(tracks);
} else {
// No timeline saved yet, initialize with default
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
}
// Clear history when loading a project
set({ history: [], redoStack: [] });
} catch (error) {
console.error("Failed to load timeline:", error);
// Initialize with default on error
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
set({ history: [], redoStack: [] });
@ -1429,8 +1389,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
},
findOrCreateTrack: (trackType) => {
// Always create new text track to allow multiple text elements
// Insert text tracks at the top
if (trackType === "text") {
return get().insertTrackAt(trackType, 0);
}
@ -1448,10 +1406,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const duration =
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
// Get all tracks of the right type
const tracks = get()._tracks.filter((t) => t.type === trackType);
// Try to find a track with no overlap
let targetTrackId = null;
for (const track of tracks) {
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
@ -1460,7 +1416,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
}
// If no free track found, create a new one
if (!targetTrackId) {
targetTrackId = get().addTrack(trackType);
}
@ -1473,12 +1428,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
addTextAtTime: (item, currentTime = 0) => {
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
const targetTrackId = get().insertTrackAt("text", 0);
get().addElementToTrack(targetTrackId, {
type: "text",
@ -1516,12 +1472,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
startTime: 0,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
addTextToNewTrack: (item) => {
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
const targetTrackId = get().insertTrackAt("text", 0);
get().addElementToTrack(targetTrackId, {
type: "text",

View File

@ -1,5 +1,7 @@
export type BackgroundType = "blur" | "mirror" | "color";
export type CanvasMode = "preset" | "original" | "custom";
export interface CanvasSize {
width: number;
height: number;

View File

@ -1,3 +1,5 @@
import { CanvasSize } from "./editor";
export interface TProject {
id: string;
name: string;
@ -10,4 +12,12 @@ export interface TProject {
blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
bookmarks?: number[];
canvasSize: CanvasSize;
/**
* Tracks how the canvas size was set:
* - "preset": User selected a standard preset (e.g. 16:9, 9:16)
* - "original": Using the first media item's dimensions
* - "custom": User set a custom aspect ratio or dimensions
*/
canvasMode: "preset" | "original" | "custom";
}

View File

@ -18,6 +18,7 @@ interface BaseTimelineElement {
export interface MediaElement extends BaseTimelineElement {
type: "media";
mediaId: string;
muted?: boolean;
}
// Text element with embedded text data

View File

@ -5,6 +5,7 @@
"name": "opencut",
"dependencies": {
"next": "^15.3.4",
"react-country-flag": "^3.1.0",
"wavesurfer.js": "^7.9.8",
},
"devDependencies": {
@ -32,6 +33,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",
@ -46,6 +48,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",
@ -76,7 +79,7 @@
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@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",
@ -609,6 +612,8 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"better-auth": ["better-auth@1.3.2", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-510kOtFBTdp4z51hWtTEqk9yqSinXzyg7PkDFnXYMq1K0KvdXRY1A9t9J998i0CSf/tJA0wNoN3S8exkOgBvTw=="],
@ -947,7 +952,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
@ -1017,6 +1022,8 @@
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-country-flag": ["react-country-flag@3.1.0", "", { "peerDependencies": { "react": ">=16" } }, "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g=="],
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
@ -1249,7 +1256,7 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"opencut/@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
"opencut/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"opencut/next": ["next@15.4.5", "", { "dependencies": { "@next/env": "15.4.5", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.5", "@next/swc-darwin-x64": "15.4.5", "@next/swc-linux-arm64-gnu": "15.4.5", "@next/swc-linux-arm64-musl": "15.4.5", "@next/swc-linux-x64-gnu": "15.4.5", "@next/swc-linux-x64-musl": "15.4.5", "@next/swc-win32-arm64-msvc": "15.4.5", "@next/swc-win32-x64-msvc": "15.4.5", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g=="],
@ -1257,6 +1264,8 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
@ -1311,7 +1320,9 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"opencut/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
"opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="],
@ -1332,5 +1343,7 @@
"opencut/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A=="],
"opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
}
}

View File

@ -50,6 +50,9 @@ services:
build:
context: .
dockerfile: ./apps/web/Dockerfile
args:
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
restart: unless-stopped
ports:
- "3100:3000" # app is running on 3000 so we run this at 3100
@ -60,6 +63,14 @@ services:
- BETTER_AUTH_SECRET=your-production-secret-key-here
- UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80
- UPSTASH_REDIS_REST_TOKEN=example_token
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
# Transcription (Optional - leave blank to disable auto-captions)
- CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
- MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL}
depends_on:
db:
condition: service_healthy
@ -71,10 +82,10 @@ services:
timeout: 10s
retries: 5
start_period: 30s
volumes:
volumes:
postgres_data:
networks:
default:
networks:
default:
name: opencut-network

View File

@ -21,6 +21,7 @@
},
"dependencies": {
"next": "^15.3.4",
"react-country-flag": "^3.1.0",
"wavesurfer.js": "^7.9.8"
},
"trustedDependencies": [

View File

@ -11,7 +11,12 @@
"UPSTASH_REDIS_REST_TOKEN",
"MARBLE_WORKSPACE_KEY",
"FREESOUND_CLIENT_ID",
"FREESOUND_API_KEY"
"FREESOUND_API_KEY",
"CLOUDFLARE_ACCOUNT_ID",
"R2_ACCESS_KEY_ID",
"R2_SECRET_ACCESS_KEY",
"R2_BUCKET_NAME",
"MODAL_TRANSCRIPTION_URL"
]
},
"check-types": {