diff --git a/.gitignore b/.gitignore
index 030fd134..b4756346 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,6 @@ node_modules
*.env
# cursor
-bun.lockb
\ No newline at end of file
+bun.lockb
+apps/transcription/__pycache__
+apps/transcription/env
\ No newline at end of file
diff --git a/apps/transcription/README.md b/apps/transcription/README.md
new file mode 100644
index 00000000..07be6790
--- /dev/null
+++ b/apps/transcription/README.md
@@ -0,0 +1,86 @@
+Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup").
+
+Open your terminal and make sure you're in the `apps/transcription` directory.
+
+1. Create virtual environment
+
+```bash
+python -m venv env
+```
+
+2. Activate it
+
+**Windows:**
+
+```bash
+env\Scripts\activate
+```
+
+**macOS/Linux:**
+
+```bash
+source env/bin/activate
+```
+
+> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found,
+> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe
+
+3. Install libraries/packages/whatever you wanna call them
+
+```bash
+pip install -r requirements.txt
+```
+
+4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/)
+
+> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable.
+
+5. Once you've got a Modal accoumt, run this:
+
+```bash
+python -m modal setup
+```
+
+It's gonna open a browser so you can authenticate.
+
+6. Test it if you want to make sure it actually works:
+
+```bash
+modal run transcription.py
+```
+
+6. Deploy the function!
+
+```bash
+modal deploy transcription.py
+```
+
+7. Set the required secrets in Modal
+
+So the script we just deployed interacts with Cloudflare to do two things:
+
+- Download the audio (so it can be transcribed with Whisper)
+- Delete the file after processing (privacy)
+
+To do those things, the script needs access to these environment variables:
+```bash
+CLOUDFLARE_ACCOUNT_ID=your-account-id
+R2_ACCESS_KEY_ID=your-access-key-id
+R2_SECRET_ACCESS_KEY=your-secret-access-key
+R2_BUCKET_NAME=opencut-transcription
+```
+
+Remember, we set these earlier in `.env.local`.
+
+So let's do it:
+
+ - Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main)
+ - Click "Custom" and enter "opencut-r2-secrets" for the name.
+ - Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only:
+ ```bash
+ CLOUDFLARE_ACCOUNT_ID=your-account-id
+ R2_ACCESS_KEY_ID=your-access-key-id
+ R2_SECRET_ACCESS_KEY=your-secret-access-key
+ R2_BUCKET_NAME=opencut-transcription
+ ```
+ - Click "Done" and you should see some cool particles!
\ No newline at end of file
diff --git a/apps/transcription/requirements.txt b/apps/transcription/requirements.txt
new file mode 100644
index 00000000..c002e683
--- /dev/null
+++ b/apps/transcription/requirements.txt
@@ -0,0 +1,5 @@
+modal
+openai-whisper
+boto3
+pydantic
+cryptography
\ No newline at end of file
diff --git a/apps/transcription/transcription.py b/apps/transcription/transcription.py
new file mode 100644
index 00000000..ad55f6d4
--- /dev/null
+++ b/apps/transcription/transcription.py
@@ -0,0 +1,143 @@
+import modal
+from pydantic import BaseModel
+
+app = modal.App("opencut-transcription")
+
+class TranscribeRequest(BaseModel):
+ filename: str
+ language: str = "auto"
+ decryptionKey: str = None
+ iv: str = None
+
+@app.function(
+ image=modal.Image.debian_slim()
+ .apt_install(["ffmpeg"])
+ .pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]),
+ gpu="A10G",
+ timeout=300, # 5m
+ secrets=[modal.Secret.from_name("opencut-r2-secrets")]
+)
+@modal.fastapi_endpoint(method="POST")
+def transcribe_audio(request: TranscribeRequest):
+ import whisper
+ import boto3
+ import tempfile
+ import os
+ import json
+
+ try:
+ filename = request.filename
+ language = request.language
+ decryption_key = request.decryptionKey
+ iv = request.iv
+
+ if not filename:
+ return {
+ "error": "Missing filename parameter"
+ }
+
+ # Initialize R2 client
+ s3_client = boto3.client(
+ 's3',
+ endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com',
+ aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
+ aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
+ region_name='auto'
+ )
+
+ # Create temporary file for audio
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
+ temp_path = temp_file.name
+
+ try:
+ # Download audio from R2
+ s3_client.download_file(
+ os.environ["R2_BUCKET_NAME"],
+ filename,
+ temp_path
+ )
+
+ # If decryption key provided, decrypt the file directly (zero-knowledge)
+ if decryption_key and iv:
+ import base64
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.backends import default_backend
+
+ # Read the encrypted file
+ with open(temp_path, 'rb') as f:
+ encrypted_data = f.read()
+
+ # Decode the key and IV from base64
+ key_bytes = base64.b64decode(decryption_key)
+ iv_bytes = base64.b64decode(iv)
+
+ # Decrypt the data using AES-GCM
+ # Extract the tag (last 16 bytes) and ciphertext
+ tag = encrypted_data[-16:]
+ ciphertext = encrypted_data[:-16]
+
+ cipher = Cipher(
+ algorithms.AES(key_bytes),
+ modes.GCM(iv_bytes, tag),
+ backend=default_backend()
+ )
+ decryptor = cipher.decryptor()
+ decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
+
+ # Write decrypted audio back to temp file
+ with open(temp_path, 'wb') as f:
+ f.write(decrypted_data)
+
+ # Load Whisper model
+ model = whisper.load_model("base")
+
+ # Transcribe audio
+ if language == "auto":
+ result = model.transcribe(temp_path)
+ else:
+ result = model.transcribe(temp_path, language=language.lower())
+
+ # Delete audio file from R2 (cleanup)
+ s3_client.delete_object(
+ Bucket=os.environ["R2_BUCKET_NAME"],
+ Key=filename
+ )
+
+ # Adjust segment timing - Whisper is consistently late by ~500ms
+ adjusted_segments = []
+ for segment in result["segments"]:
+ adjusted_segment = segment.copy()
+ # Shift start/end times earlier by 500ms, don't go below 0
+ adjusted_segment["start"] = max(0, segment["start"] - 0.5)
+ adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s
+ adjusted_segments.append(adjusted_segment)
+
+ return {
+ "text": result["text"],
+ "segments": adjusted_segments,
+ "language": result["language"]
+ }
+
+ finally:
+ # Clean up temporary file
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ except Exception as e:
+ import traceback
+ print(f"Transcription error: {str(e)}")
+ print(f"Traceback: {traceback.format_exc()}")
+
+ # Return error response that matches expected format
+ return {
+ "error": str(e),
+ "text": "",
+ "segments": [],
+ "language": "unknown"
+ }
+
+@app.local_entrypoint()
+def main():
+ # Test function - you can call this with modal run transcription.py
+ print("Transcription service is ready to deploy!")
+ print("Deploy with: modal deploy transcription.py")
\ No newline at end of file
diff --git a/apps/web/.env.example b/apps/web/.env.example
index af84fd77..e77d3c5a 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -20,4 +20,14 @@ NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Freesound (generate at https://freesound.org/apiv2/apply/)
FREESOUND_CLIENT_ID=...
-FREESOUND_API_KEY=...
\ No newline at end of file
+FREESOUND_API_KEY=...
+
+# Cloudflare R2 (for auto-captions/transcription)
+# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens
+CLOUDFLARE_ACCOUNT_ID=your-account-id
+R2_ACCESS_KEY_ID=your-access-key-id
+R2_SECRET_ACCESS_KEY=your-secret-access-key
+R2_BUCKET_NAME=opencut-transcription
+
+# Modal transcription endpoint (from modal deploy transcription.py)
+MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index d9947643..48a211db 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
- "dev": "next dev",
+ "dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
@@ -29,6 +29,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",
@@ -43,6 +44,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",
diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts
new file mode 100644
index 00000000..dc5b7328
--- /dev/null
+++ b/apps/web/src/app/api/get-upload-url/route.ts
@@ -0,0 +1,128 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { AwsClient } from "aws4fetch";
+import { nanoid } from "nanoid";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+import { isTranscriptionConfigured } from "@/lib/transcription-utils";
+
+const uploadRequestSchema = z.object({
+ fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
+ errorMap: () => ({
+ message: "File extension must be wav, mp3, m4a, or flac",
+ }),
+ }),
+});
+
+const apiResponseSchema = z.object({
+ uploadUrl: z.string().url(),
+ fileName: z.string().min(1),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ // Check transcription configuration
+ const transcriptionCheck = isTranscriptionConfigured();
+ if (!transcriptionCheck.configured) {
+ console.error(
+ "Missing environment variables:",
+ JSON.stringify(transcriptionCheck.missingVars)
+ );
+
+ return NextResponse.json(
+ {
+ error: "Transcription not configured",
+ message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
+ },
+ { status: 503 }
+ );
+ }
+
+ // Parse and validate request body
+ const rawBody = await request.json().catch(() => null);
+ if (!rawBody) {
+ return NextResponse.json(
+ { error: "Invalid JSON in request body" },
+ { status: 400 }
+ );
+ }
+
+ const validationResult = uploadRequestSchema.safeParse(rawBody);
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid request parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { fileExtension } = validationResult.data;
+
+ // Initialize R2 client
+ const client = new AwsClient({
+ accessKeyId: env.R2_ACCESS_KEY_ID,
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
+ });
+
+ // Generate unique filename with timestamp
+ const timestamp = Date.now();
+ const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
+
+ // Create presigned URL
+ const url = new URL(
+ `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
+ );
+
+ url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
+
+ const signed = await client.sign(new Request(url, { method: "PUT" }), {
+ aws: { signQuery: true },
+ });
+
+ if (!signed.url) {
+ throw new Error("Failed to generate presigned URL");
+ }
+
+ // Prepare and validate response
+ const responseData = {
+ uploadUrl: signed.url,
+ fileName,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error generating upload URL:", error);
+ return NextResponse.json(
+ {
+ error: "Failed to generate upload URL",
+ message:
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts
new file mode 100644
index 00000000..9a497f65
--- /dev/null
+++ b/apps/web/src/app/api/transcribe/route.ts
@@ -0,0 +1,189 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+import { isTranscriptionConfigured } from "@/lib/transcription-utils";
+
+const transcribeRequestSchema = z.object({
+ filename: z.string().min(1, "Filename is required"),
+ language: z.string().optional().default("auto"),
+ decryptionKey: z.string().min(1, "Decryption key is required").optional(),
+ iv: z.string().min(1, "IV is required").optional(),
+});
+
+const modalResponseSchema = z.object({
+ text: z.string(),
+ segments: z.array(
+ z.object({
+ id: z.number(),
+ seek: z.number(),
+ start: z.number(),
+ end: z.number(),
+ text: z.string(),
+ tokens: z.array(z.number()),
+ temperature: z.number(),
+ avg_logprob: z.number(),
+ compression_ratio: z.number(),
+ no_speech_prob: z.number(),
+ })
+ ),
+ language: z.string(),
+});
+
+const apiResponseSchema = z.object({
+ text: z.string(),
+ segments: z.array(
+ z.object({
+ id: z.number(),
+ seek: z.number(),
+ start: z.number(),
+ end: z.number(),
+ text: z.string(),
+ tokens: z.array(z.number()),
+ temperature: z.number(),
+ avg_logprob: z.number(),
+ compression_ratio: z.number(),
+ no_speech_prob: z.number(),
+ })
+ ),
+ language: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+ const origin = request.headers.get("origin");
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ // Check transcription configuration
+ const transcriptionCheck = isTranscriptionConfigured();
+ if (!transcriptionCheck.configured) {
+ console.error(
+ "Missing environment variables:",
+ JSON.stringify(transcriptionCheck.missingVars)
+ );
+
+ return NextResponse.json(
+ {
+ error: "Transcription not configured",
+ message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
+ },
+ { status: 503 }
+ );
+ }
+
+ // Parse and validate request body
+ const rawBody = await request.json().catch(() => null);
+ if (!rawBody) {
+ return NextResponse.json(
+ { error: "Invalid JSON in request body" },
+ { status: 400 }
+ );
+ }
+
+ const validationResult = transcribeRequestSchema.safeParse(rawBody);
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid request parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { filename, language, decryptionKey, iv } = validationResult.data;
+
+ // Prepare request body for Modal
+ const modalRequestBody: any = {
+ filename,
+ language,
+ };
+
+ // Add encryption parameters if provided (zero-knowledge)
+ if (decryptionKey && iv) {
+ modalRequestBody.decryptionKey = decryptionKey;
+ modalRequestBody.iv = iv;
+ }
+
+ // Call Modal transcription service
+ const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(modalRequestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Modal API error:", response.status, errorText);
+
+ let errorMessage = "Transcription service unavailable";
+ try {
+ const errorData = JSON.parse(errorText);
+ errorMessage = errorData.error || errorMessage;
+ } catch {
+ // Use default message if parsing fails
+ }
+
+ return NextResponse.json(
+ {
+ error: errorMessage,
+ message: "Failed to process transcription request",
+ },
+ { status: response.status >= 500 ? 502 : response.status }
+ );
+ }
+
+ const rawResult = await response.json();
+ console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
+
+ // Validate Modal response
+ const modalValidation = modalResponseSchema.safeParse(rawResult);
+ if (!modalValidation.success) {
+ console.error("Invalid Modal API response:", modalValidation.error);
+ return NextResponse.json(
+ { error: "Invalid response from transcription service" },
+ { status: 502 }
+ );
+ }
+
+ const result = modalValidation.data;
+
+ // Prepare and validate API response
+ const responseData = {
+ text: result.text,
+ segments: result.segments,
+ language: result.language,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Transcription API error:", error);
+ return NextResponse.json(
+ {
+ error: "Internal server error",
+ message: "An unexpected error occurred during transcription",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index 6db2543b..98aec2b9 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -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"
onClick={handleExport}
>
-
+
Export
diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx
index 296295fc..2cd33535 100644
--- a/apps/web/src/components/editor/media-panel/views/base-view.tsx
+++ b/apps/web/src/components/editor/media-panel/views/base-view.tsx
@@ -11,12 +11,19 @@ interface BaseViewProps {
content: React.ReactNode;
}[];
className?: string;
+ ref?: React.RefObject
;
}
-function ViewContent({ children }: { children: React.ReactNode }) {
+function ViewContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
return (
- {children}
+ {children}
);
}
@@ -26,11 +33,12 @@ export function BaseView({
defaultTab,
tabs,
className = "",
+ ref,
}: BaseViewProps) {
return (
-
+
{!tabs || tabs.length === 0 ? (
-
{children}
+
{children}
) : (
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
index ef919fec..4fe2cbd7 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -1,9 +1,313 @@
-import { BaseView } from "./base-view";
-
-export function Captions() {
- return (
-
- Captions
-
- );
-}
+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
("");
+ const [error, setError] = useState(null);
+ const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
+ const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
+ const containerRef = useRef(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 (
+
+
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index a47e446a..785162a6 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -356,7 +356,7 @@ export function PreviewPanel() {
return (
handleTextMouseDown(e, element, elementData.track.id)
}
@@ -377,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
}}
@@ -385,16 +385,16 @@ export function PreviewPanel() {
void;
+ containerRef: React.RefObject
;
+ languages: Language[];
+}
+
+function FlagPreloader({ languages }: { languages: Language[] }) {
+ return (
+
+ {languages.map((language) => (
+
+ ))}
+
+ );
+}
+
+export function LanguageSelect({
+ selectedCountry,
+ onSelect,
+ containerRef,
+ languages,
+}: LanguageSelectProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [isTapping, setIsTapping] = useState(false);
+ const [isClosing, setIsClosing] = useState(false);
+ const collapsedHeight = "2.5rem";
+ const expandHeight = "12rem";
+ const buttonRef = useRef(null);
+
+ const expand = () => {
+ setIsTapping(true);
+ setTimeout(() => setIsTapping(false), 600);
+ setExpanded(true);
+ buttonRef.current?.focus();
+ };
+
+ useEffect(() => {
+ if (!expanded) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ buttonRef.current &&
+ !buttonRef.current.contains(event.target as Node)
+ ) {
+ setIsClosing(true);
+ setTimeout(() => setIsClosing(false), 600);
+ setExpanded(false);
+ buttonRef.current?.blur();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [expanded]);
+
+ const selectedLanguage = languages.find(
+ (lang) => lang.code === selectedCountry
+ );
+
+ const handleSelect = ({
+ code,
+ e,
+ }: {
+ code: string;
+ e: React.MouseEvent;
+ }) => {
+ e.stopPropagation();
+ e.preventDefault();
+ onSelect(code);
+ setExpanded(false);
+ };
+
+ return (
+
+
+
+ {!expanded ? (
+
+
+ {selectedCountry === "auto" ? (
+
+ ) : (
+
+ )}
+
+ {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
+
+
+
+ ) : (
+
+
+ {languages.map((language) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function LanguageButton({
+ language,
+ onSelect,
+ selectedCountry,
+}: {
+ language: Language;
+ onSelect: ({
+ code,
+ e,
+ }: {
+ code: string;
+ e: React.MouseEvent;
+ }) => void;
+ selectedCountry: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx
index 36ae68d9..d72ac0f6 100644
--- a/apps/web/src/components/onboarding.tsx
+++ b/apps/web/src/components/onboarding.tsx
@@ -79,7 +79,7 @@ export function Onboarding() {
return (