mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: auto-captions
This commit is contained in:
@@ -28,3 +28,5 @@ node_modules
|
|||||||
|
|
||||||
# cursor
|
# cursor
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
apps/transcription/__pycache__
|
||||||
|
apps/transcription/env
|
||||||
@@ -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!
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
modal
|
||||||
|
openai-whisper
|
||||||
|
boto3
|
||||||
|
pydantic
|
||||||
|
cryptography
|
||||||
@@ -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")
|
||||||
@@ -21,3 +21,13 @@ NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
|||||||
# Freesound (generate at https://freesound.org/apiv2/apply/)
|
# Freesound (generate at https://freesound.org/apiv2/apply/)
|
||||||
FREESOUND_CLIENT_ID=...
|
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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.2.18",
|
"packageManager": "bun@1.2.18",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check src/",
|
"lint": "biome check src/",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.35.0",
|
"@upstash/redis": "^1.35.0",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@vercel/analytics": "^1.4.1",
|
||||||
|
"aws4fetch": "^1.0.20",
|
||||||
"better-auth": "^1.2.7",
|
"better-auth": "^1.2.7",
|
||||||
"botid": "^1.4.2",
|
"botid": "^1.4.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"motion": "^12.18.1",
|
"motion": "^12.18.1",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
"next": "^15.4.5",
|
"next": "^15.4.5",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ function ExportButton() {
|
|||||||
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"
|
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}
|
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)]">
|
<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" />
|
<TransitionUpIcon className="z-50" />
|
||||||
<span className="text-[0.875rem] z-50">Export</span>
|
<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-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">
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ interface BaseViewProps {
|
|||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
}[];
|
}[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
ref?: React.RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewContent({ children }: { children: React.ReactNode }) {
|
function ViewContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-5">{children}</div>
|
<div className={`p-5 h-full ${className}`}>{children}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,11 +33,12 @@ export function BaseView({
|
|||||||
defaultTab,
|
defaultTab,
|
||||||
tabs,
|
tabs,
|
||||||
className = "",
|
className = "",
|
||||||
|
ref,
|
||||||
}: BaseViewProps) {
|
}: BaseViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col ${className}`}>
|
<div className={`h-full flex flex-col ${className}`} ref={ref}>
|
||||||
{!tabs || tabs.length === 0 ? (
|
{!tabs || tabs.length === 0 ? (
|
||||||
<ViewContent>{children}</ViewContent>
|
<ViewContent className={className}>{children}</ViewContent>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
|
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
|
||||||
<div className="px-3 pt-4 pb-0">
|
<div className="px-3 pt-4 pb-0">
|
||||||
|
|||||||
@@ -1,9 +1,313 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||||
import { BaseView } from "./base-view";
|
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() {
|
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 (
|
return (
|
||||||
<BaseView>
|
<BaseView ref={containerRef} className="flex flex-col justify-between">
|
||||||
<div>Captions</div>
|
<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>
|
</BaseView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export function PreviewPanel() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={element.id}
|
key={element.id}
|
||||||
className="absolute flex items-center justify-center cursor-grab"
|
className="absolute cursor-grab"
|
||||||
onMouseDown={(e) =>
|
onMouseDown={(e) =>
|
||||||
handleTextMouseDown(e, element, elementData.track.id)
|
handleTextMouseDown(e, element, elementData.track.id)
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ export function PreviewPanel() {
|
|||||||
canvasSize.height) *
|
canvasSize.height) *
|
||||||
100
|
100
|
||||||
}%`,
|
}%`,
|
||||||
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
|
transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
|
||||||
opacity: element.opacity,
|
opacity: element.opacity,
|
||||||
zIndex: 100 + index, // Text elements on top
|
zIndex: 100 + index, // Text elements on top
|
||||||
}}
|
}}
|
||||||
@@ -385,16 +385,16 @@ export function PreviewPanel() {
|
|||||||
<div
|
<div
|
||||||
className={fontClassName}
|
className={fontClassName}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${element.fontSize}px`,
|
fontSize: `${element.fontSize * scaleRatio}px`,
|
||||||
color: element.color,
|
color: element.color,
|
||||||
backgroundColor: element.backgroundColor,
|
backgroundColor: element.backgroundColor,
|
||||||
textAlign: element.textAlign,
|
textAlign: element.textAlign,
|
||||||
fontWeight: element.fontWeight,
|
fontWeight: element.fontWeight,
|
||||||
fontStyle: element.fontStyle,
|
fontStyle: element.fontStyle,
|
||||||
textDecoration: element.textDecoration,
|
textDecoration: element.textDecoration,
|
||||||
padding: "4px 8px",
|
padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`,
|
||||||
borderRadius: "2px",
|
borderRadius: `${2 * scaleRatio}px`,
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "nowrap",
|
||||||
// Fallback for system fonts that don't have classes
|
// Fallback for system fonts that don't have classes
|
||||||
...(fontClassName === "" && { fontFamily: element.fontFamily }),
|
...(fontClassName === "" && { fontFamily: element.fontFamily }),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ export function Onboarding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[425px] outline-hidden!">
|
<DialogContent className="sm:max-w-[425px] !outline-none">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<span className="sr-only">{getStepTitle()}</span>
|
<span className="sr-only">{getStepTitle()}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const buttonVariants = cva(
|
|||||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||||
primary:
|
primary:
|
||||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
"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:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export const env = createEnv({
|
|||||||
UPSTASH_REDIS_REST_TOKEN: z.string(),
|
UPSTASH_REDIS_REST_TOKEN: z.string(),
|
||||||
FREESOUND_CLIENT_ID: z.string(),
|
FREESOUND_CLIENT_ID: z.string(),
|
||||||
FREESOUND_API_KEY: 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: {},
|
client: {},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
@@ -27,5 +34,12 @@ export const env = createEnv({
|
|||||||
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
|
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||||
FREESOUND_CLIENT_ID: process.env.FREESOUND_CLIENT_ID,
|
FREESOUND_CLIENT_ID: process.env.FREESOUND_CLIENT_ID,
|
||||||
FREESOUND_API_KEY: process.env.FREESOUND_API_KEY,
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||||
import { toBlobURL } from "@ffmpeg/util";
|
import { toBlobURL } from "@ffmpeg/util";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
|
|
||||||
let ffmpeg: FFmpeg | null = null;
|
let ffmpeg: FFmpeg | null = null;
|
||||||
|
|
||||||
@@ -7,14 +9,7 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
|
|||||||
if (ffmpeg) return ffmpeg;
|
if (ffmpeg) return ffmpeg;
|
||||||
|
|
||||||
ffmpeg = new FFmpeg();
|
ffmpeg = new FFmpeg();
|
||||||
|
await ffmpeg.load(); // Use default config
|
||||||
// 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"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return ffmpeg;
|
return ffmpeg;
|
||||||
};
|
};
|
||||||
@@ -268,3 +263,206 @@ export const extractAudio = async (
|
|||||||
|
|
||||||
return blob;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: " " },
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "opencut",
|
"name": "opencut",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.3.4",
|
"next": "^15.3.4",
|
||||||
|
"react-country-flag": "^3.1.0",
|
||||||
"wavesurfer.js": "^7.9.8",
|
"wavesurfer.js": "^7.9.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.35.0",
|
"@upstash/redis": "^1.35.0",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@vercel/analytics": "^1.4.1",
|
||||||
|
"aws4fetch": "^1.0.20",
|
||||||
"better-auth": "^1.2.7",
|
"better-auth": "^1.2.7",
|
||||||
"botid": "^1.4.2",
|
"botid": "^1.4.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"motion": "^12.18.1",
|
"motion": "^12.18.1",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
"next": "^15.4.5",
|
"next": "^15.4.5",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
@@ -608,6 +611,8 @@
|
|||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
|
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||||
|
|
||||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -946,7 +951,7 @@
|
|||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"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=="],
|
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
|
||||||
|
|
||||||
@@ -1016,6 +1021,8 @@
|
|||||||
|
|
||||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
"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-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=="],
|
"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=="],
|
||||||
@@ -1254,6 +1261,8 @@
|
|||||||
|
|
||||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
"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=="],
|
"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=="],
|
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||||
@@ -1308,6 +1317,8 @@
|
|||||||
|
|
||||||
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
"opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="],
|
||||||
|
|
||||||
"opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="],
|
"opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="],
|
||||||
@@ -1327,5 +1338,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/@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": ["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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ services:
|
|||||||
- UPSTASH_REDIS_REST_TOKEN=example_token
|
- UPSTASH_REDIS_REST_TOKEN=example_token
|
||||||
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
|
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
|
||||||
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
|
- 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.3.4",
|
"next": "^15.3.4",
|
||||||
|
"react-country-flag": "^3.1.0",
|
||||||
"wavesurfer.js": "^7.9.8"
|
"wavesurfer.js": "^7.9.8"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
|||||||
Reference in New Issue
Block a user