summaryrefslogtreecommitdiff
path: root/apps/web/src/app
diff options
context:
space:
mode:
authorryu <ryumacodes@gmail.com>2025-08-13 14:01:07 +0500
committerGitHub <noreply@github.com>2025-08-13 14:01:07 +0500
commit0ae09c9df5591e2d18991a9f98647826997fe740 (patch)
tree69738461bd269ad418bc82c47e64a2670cee7f19 /apps/web/src/app
parente71788014d137a74f10420601451ddfbb84701bb (diff)
parentf7fddff1fc8359fc056a2a90488121980fb8ead2 (diff)
downloadopencut-0ae09c9df5591e2d18991a9f98647826997fe740.tar.gz
Merge branch 'staging' into feat/timeline-return-to-start
Diffstat (limited to 'apps/web/src/app')
-rw-r--r--apps/web/src/app/api/get-upload-url/route.ts128
-rw-r--r--apps/web/src/app/api/transcribe/route.ts189
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx340
3 files changed, 603 insertions, 54 deletions
diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts
new file mode 100644
index 00000000..dc5b7328
--- /dev/null
+++ b/apps/web/src/app/api/get-upload-url/route.ts
@@ -0,0 +1,128 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { AwsClient } from "aws4fetch";
+import { nanoid } from "nanoid";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+import { isTranscriptionConfigured } from "@/lib/transcription-utils";
+
+const uploadRequestSchema = z.object({
+ fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
+ errorMap: () => ({
+ message: "File extension must be wav, mp3, m4a, or flac",
+ }),
+ }),
+});
+
+const apiResponseSchema = z.object({
+ uploadUrl: z.string().url(),
+ fileName: z.string().min(1),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ // Check transcription configuration
+ const transcriptionCheck = isTranscriptionConfigured();
+ if (!transcriptionCheck.configured) {
+ console.error(
+ "Missing environment variables:",
+ JSON.stringify(transcriptionCheck.missingVars)
+ );
+
+ return NextResponse.json(
+ {
+ error: "Transcription not configured",
+ message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
+ },
+ { status: 503 }
+ );
+ }
+
+ // Parse and validate request body
+ const rawBody = await request.json().catch(() => null);
+ if (!rawBody) {
+ return NextResponse.json(
+ { error: "Invalid JSON in request body" },
+ { status: 400 }
+ );
+ }
+
+ const validationResult = uploadRequestSchema.safeParse(rawBody);
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid request parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { fileExtension } = validationResult.data;
+
+ // Initialize R2 client
+ const client = new AwsClient({
+ accessKeyId: env.R2_ACCESS_KEY_ID,
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
+ });
+
+ // Generate unique filename with timestamp
+ const timestamp = Date.now();
+ const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
+
+ // Create presigned URL
+ const url = new URL(
+ `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
+ );
+
+ url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
+
+ const signed = await client.sign(new Request(url, { method: "PUT" }), {
+ aws: { signQuery: true },
+ });
+
+ if (!signed.url) {
+ throw new Error("Failed to generate presigned URL");
+ }
+
+ // Prepare and validate response
+ const responseData = {
+ uploadUrl: signed.url,
+ fileName,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error generating upload URL:", error);
+ return NextResponse.json(
+ {
+ error: "Failed to generate upload URL",
+ message:
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts
new file mode 100644
index 00000000..9a497f65
--- /dev/null
+++ b/apps/web/src/app/api/transcribe/route.ts
@@ -0,0 +1,189 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+import { isTranscriptionConfigured } from "@/lib/transcription-utils";
+
+const transcribeRequestSchema = z.object({
+ filename: z.string().min(1, "Filename is required"),
+ language: z.string().optional().default("auto"),
+ decryptionKey: z.string().min(1, "Decryption key is required").optional(),
+ iv: z.string().min(1, "IV is required").optional(),
+});
+
+const modalResponseSchema = z.object({
+ text: z.string(),
+ segments: z.array(
+ z.object({
+ id: z.number(),
+ seek: z.number(),
+ start: z.number(),
+ end: z.number(),
+ text: z.string(),
+ tokens: z.array(z.number()),
+ temperature: z.number(),
+ avg_logprob: z.number(),
+ compression_ratio: z.number(),
+ no_speech_prob: z.number(),
+ })
+ ),
+ language: z.string(),
+});
+
+const apiResponseSchema = z.object({
+ text: z.string(),
+ segments: z.array(
+ z.object({
+ id: z.number(),
+ seek: z.number(),
+ start: z.number(),
+ end: z.number(),
+ text: z.string(),
+ tokens: z.array(z.number()),
+ temperature: z.number(),
+ avg_logprob: z.number(),
+ compression_ratio: z.number(),
+ no_speech_prob: z.number(),
+ })
+ ),
+ language: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+ const origin = request.headers.get("origin");
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ // Check transcription configuration
+ const transcriptionCheck = isTranscriptionConfigured();
+ if (!transcriptionCheck.configured) {
+ console.error(
+ "Missing environment variables:",
+ JSON.stringify(transcriptionCheck.missingVars)
+ );
+
+ return NextResponse.json(
+ {
+ error: "Transcription not configured",
+ message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
+ },
+ { status: 503 }
+ );
+ }
+
+ // Parse and validate request body
+ const rawBody = await request.json().catch(() => null);
+ if (!rawBody) {
+ return NextResponse.json(
+ { error: "Invalid JSON in request body" },
+ { status: 400 }
+ );
+ }
+
+ const validationResult = transcribeRequestSchema.safeParse(rawBody);
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid request parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { filename, language, decryptionKey, iv } = validationResult.data;
+
+ // Prepare request body for Modal
+ const modalRequestBody: any = {
+ filename,
+ language,
+ };
+
+ // Add encryption parameters if provided (zero-knowledge)
+ if (decryptionKey && iv) {
+ modalRequestBody.decryptionKey = decryptionKey;
+ modalRequestBody.iv = iv;
+ }
+
+ // Call Modal transcription service
+ const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(modalRequestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Modal API error:", response.status, errorText);
+
+ let errorMessage = "Transcription service unavailable";
+ try {
+ const errorData = JSON.parse(errorText);
+ errorMessage = errorData.error || errorMessage;
+ } catch {
+ // Use default message if parsing fails
+ }
+
+ return NextResponse.json(
+ {
+ error: errorMessage,
+ message: "Failed to process transcription request",
+ },
+ { status: response.status >= 500 ? 502 : response.status }
+ );
+ }
+
+ const rawResult = await response.json();
+ console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
+
+ // Validate Modal response
+ const modalValidation = modalResponseSchema.safeParse(rawResult);
+ if (!modalValidation.success) {
+ console.error("Invalid Modal API response:", modalValidation.error);
+ return NextResponse.json(
+ { error: "Invalid response from transcription service" },
+ { status: 502 }
+ );
+ }
+
+ const result = modalValidation.data;
+
+ // Prepare and validate API response
+ const responseData = {
+ text: result.text,
+ segments: result.segments,
+ language: result.language,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Transcription API error:", error);
+ return NextResponse.json(
+ {
+ error: "Internal server error",
+ message: "An unexpected error occurred during transcription",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index cae8e779..4d5a27fc 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -30,6 +30,8 @@ export default function Editor() {
setTimeline,
propertiesPanel,
setPropertiesPanel,
+ activePreset,
+ resetCounter,
} = usePanelStore();
const {
@@ -153,72 +155,302 @@ export default function Editor() {
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
<EditorHeader />
<div className="flex-1 min-h-0 min-w-0">
- <ResizablePanelGroup
- direction="vertical"
- className="h-full w-full gap-[0.18rem]"
- >
- <ResizablePanel
- defaultSize={mainContent}
- minSize={30}
- maxSize={85}
- onResize={setMainContent}
- className="min-h-0"
+ {activePreset === "media" ? (
+ <ResizablePanelGroup
+ key={`media-${activePreset}-${resetCounter}`}
+ direction="horizontal"
+ className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
- {/* Main content area */}
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem] px-3"
+ <ResizablePanel
+ defaultSize={toolsPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setToolsPanel}
+ className="min-w-0 rounded-sm"
>
- {/* Tools Panel */}
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
+ <MediaPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={100 - toolsPanel}
+ minSize={60}
+ className="min-w-0 min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="h-full w-full gap-[0.18rem]"
>
- <MediaPanel />
- </ResizablePanel>
+ <ResizablePanel
+ defaultSize={mainContent}
+ minSize={30}
+ maxSize={85}
+ onResize={setMainContent}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="h-full w-full gap-[0.19rem]"
+ >
+ <ResizablePanel
+ defaultSize={previewPanel}
+ minSize={30}
+ onResize={setPreviewPanel}
+ className="min-w-0 min-h-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
- <ResizableHandle withHandle />
+ <ResizablePanel
+ defaultSize={propertiesPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setPropertiesPanel}
+ className="min-w-0"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
- {/* Preview Area */}
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0 flex-1"
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={timeline}
+ minSize={15}
+ maxSize={70}
+ onResize={setTimeline}
+ className="min-h-0"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ ) : activePreset === "inspector" ? (
+ <ResizablePanelGroup
+ key={`inspector-${activePreset}-${resetCounter}`}
+ direction="horizontal"
+ className="h-full w-full gap-[0.18rem] px-3 pb-3"
+ >
+ <ResizablePanel
+ defaultSize={100 - propertiesPanel}
+ minSize={30}
+ onResize={(size) => setPropertiesPanel(100 - size)}
+ className="min-w-0 min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="h-full w-full gap-[0.18rem]"
>
- <PreviewPanel />
- </ResizablePanel>
+ <ResizablePanel
+ defaultSize={mainContent}
+ minSize={30}
+ maxSize={85}
+ onResize={setMainContent}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="h-full w-full gap-[0.19rem]"
+ >
+ <ResizablePanel
+ defaultSize={toolsPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setToolsPanel}
+ className="min-w-0 rounded-sm"
+ >
+ <MediaPanel />
+ </ResizablePanel>
- <ResizableHandle withHandle />
+ <ResizableHandle withHandle />
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0 rounded-sm"
+ <ResizablePanel
+ defaultSize={previewPanel}
+ minSize={30}
+ onResize={setPreviewPanel}
+ className="min-w-0 min-h-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={timeline}
+ minSize={15}
+ maxSize={70}
+ onResize={setTimeline}
+ className="min-h-0"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={propertiesPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setPropertiesPanel}
+ className="min-w-0 min-h-0"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ ) : activePreset === "vertical-preview" ? (
+ <ResizablePanelGroup
+ key={`vertical-preview-${activePreset}-${resetCounter}`}
+ direction="horizontal"
+ className="h-full w-full gap-[0.18rem] px-3 pb-3"
+ >
+ <ResizablePanel
+ defaultSize={100 - previewPanel}
+ minSize={30}
+ onResize={(size) => setPreviewPanel(100 - size)}
+ className="min-w-0 min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="h-full w-full gap-[0.18rem]"
>
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
+ <ResizablePanel
+ defaultSize={mainContent}
+ minSize={30}
+ maxSize={85}
+ onResize={setMainContent}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="h-full w-full gap-[0.19rem]"
+ >
+ <ResizablePanel
+ defaultSize={toolsPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setToolsPanel}
+ className="min-w-0 rounded-sm"
+ >
+ <MediaPanel />
+ </ResizablePanel>
- <ResizableHandle withHandle />
+ <ResizableHandle withHandle />
- {/* Timeline */}
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0 px-3 pb-3"
+ <ResizablePanel
+ defaultSize={propertiesPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setPropertiesPanel}
+ className="min-w-0"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={timeline}
+ minSize={15}
+ maxSize={70}
+ onResize={setTimeline}
+ className="min-h-0"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={previewPanel}
+ minSize={30}
+ onResize={setPreviewPanel}
+ className="min-w-0 min-h-0"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ ) : (
+ <ResizablePanelGroup
+ key={`default-${activePreset}-${resetCounter}`}
+ direction="vertical"
+ className="h-full w-full gap-[0.18rem]"
>
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
+ <ResizablePanel
+ defaultSize={mainContent}
+ minSize={30}
+ maxSize={85}
+ onResize={setMainContent}
+ className="min-h-0"
+ >
+ {/* Main content area */}
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="h-full w-full gap-[0.19rem] px-3"
+ >
+ {/* Tools Panel */}
+ <ResizablePanel
+ defaultSize={toolsPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setToolsPanel}
+ className="min-w-0 rounded-sm"
+ >
+ <MediaPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ {/* Preview Area */}
+ <ResizablePanel
+ defaultSize={previewPanel}
+ minSize={30}
+ onResize={setPreviewPanel}
+ className="min-w-0 min-h-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={propertiesPanel}
+ minSize={15}
+ maxSize={40}
+ onResize={setPropertiesPanel}
+ className="min-w-0 rounded-sm"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ {/* Timeline */}
+ <ResizablePanel
+ defaultSize={timeline}
+ minSize={15}
+ maxSize={70}
+ onResize={setTimeline}
+ className="min-h-0 px-3 pb-3"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ )}
</div>
<Onboarding />
</div>