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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user