diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts index dc5b7328f..34fad0d9e 100644 --- a/apps/web/src/app/api/get-upload-url/route.ts +++ b/apps/web/src/app/api/get-upload-url/route.ts @@ -1,128 +1,136 @@ -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 } - ); - } -} +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), +}); + +/** + * Generates a presigned upload URL and a unique filename for uploading an audio file to Cloudflare R2. + * + * Accepts a JSON request body with a `fileExtension` (one of "wav", "mp3", "m4a", "flac"). Applies client rate limiting and verifies required transcription environment configuration before producing a signed PUT URL valid for 1 hour. + * + * @param request - Incoming Next.js request whose JSON body must include `fileExtension` + * @returns On success, an object with `uploadUrl` (the presigned PUT URL) and `fileName` (the generated object path). On failure, a JSON error object with an `error` field and optional `message`/`details`; responses use appropriate HTTP status codes (400, 429, 503, 500). + */ +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 } + ); + } +} \ No newline at end of file diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index c89bc76c6..8fe41104a 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,265 +1,272 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; - -const searchParamsSchema = z.object({ - q: z.string().max(500, "Query too long").optional(), - type: z.enum(["songs", "effects"]).optional(), - page: z.coerce.number().int().min(1).max(1000).default(1), - page_size: z.coerce.number().int().min(1).max(150).default(20), - sort: z - .enum(["downloads", "rating", "created", "score"]) - .default("downloads"), - min_rating: z.coerce.number().min(0).max(5).default(3), - commercial_only: z.coerce.boolean().default(true), -}); - -const freesoundResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string().url(), - previews: z - .object({ - "preview-hq-mp3": z.string().url(), - "preview-lq-mp3": z.string().url(), - "preview-hq-ogg": z.string().url(), - "preview-lq-ogg": z.string().url(), - }) - .optional(), - download: z.string().url().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - num_downloads: z.number().optional(), - avg_rating: z.number().optional(), - num_ratings: z.number().optional(), -}); - -const freesoundResponseSchema = z.object({ - count: z.number(), - next: z.string().url().nullable(), - previous: z.string().url().nullable(), - results: z.array(freesoundResultSchema), -}); - -const transformedResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string(), - previewUrl: z.string().optional(), - downloadUrl: z.string().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - downloads: z.number().optional(), - rating: z.number().optional(), - ratingCount: z.number().optional(), -}); - -const apiResponseSchema = z.object({ - count: z.number(), - next: z.string().nullable(), - previous: z.string().nullable(), - results: z.array(transformedResultSchema), - query: z.string().optional(), - type: z.string(), - page: z.number(), - pageSize: z.number(), - sort: z.string(), - minRating: z.number().optional(), -}); - -export async function GET(request: NextRequest) { - try { - 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 }); - } - - const { searchParams } = new URL(request.url); - - const validationResult = searchParamsSchema.safeParse({ - q: searchParams.get("q") || undefined, - type: searchParams.get("type") || undefined, - page: searchParams.get("page") || undefined, - page_size: searchParams.get("page_size") || undefined, - sort: searchParams.get("sort") || undefined, - min_rating: searchParams.get("min_rating") || undefined, - }); - - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { - q: query, - type, - page, - page_size: pageSize, - sort, - min_rating, - commercial_only, - } = validationResult.data; - - if (type === "songs") { - return NextResponse.json( - { - error: "Songs are not available yet", - message: - "Song search functionality is coming soon. Try searching for sound effects instead.", - }, - { status: 501 } - ); - } - - const baseUrl = "https://freesound.org/apiv2/search/text/"; - - // Use score sorting for search queries, downloads for top sounds - const sortParam = query - ? sort === "score" - ? "score" - : `${sort}_desc` - : `${sort}_desc`; - - const params = new URLSearchParams({ - query: query || "", - token: env.FREESOUND_API_KEY, - page: page.toString(), - page_size: pageSize.toString(), - sort: sortParam, - fields: - "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", - }); - - // Always apply sound effect filters (since we're primarily a sound effects search) - if (type === "effects" || !type) { - params.append("filter", "duration:[* TO 30.0]"); - params.append("filter", `avg_rating:[${min_rating} TO *]`); - - // Filter by license if commercial_only is true - if (commercial_only) { - params.append( - "filter", - 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' - ); - } - - params.append( - "filter", - "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" - ); - } - - const response = await fetch(`${baseUrl}?${params.toString()}`); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Freesound API error:", response.status, errorText); - return NextResponse.json( - { error: "Failed to search sounds" }, - { status: response.status } - ); - } - - const rawData = await response.json(); - - const freesoundValidation = freesoundResponseSchema.safeParse(rawData); - if (!freesoundValidation.success) { - console.error( - "Invalid Freesound API response:", - freesoundValidation.error - ); - return NextResponse.json( - { error: "Invalid response from Freesound API" }, - { status: 502 } - ); - } - - const data = freesoundValidation.data; - - const transformedResults = data.results.map((result) => ({ - id: result.id, - name: result.name, - description: result.description, - url: result.url, - previewUrl: - result.previews?.["preview-hq-mp3"] || - result.previews?.["preview-lq-mp3"], - downloadUrl: result.download, - duration: result.duration, - filesize: result.filesize, - type: result.type, - channels: result.channels, - bitrate: result.bitrate, - bitdepth: result.bitdepth, - samplerate: result.samplerate, - username: result.username, - tags: result.tags, - license: result.license, - created: result.created, - downloads: result.num_downloads || 0, - rating: result.avg_rating || 0, - ratingCount: result.num_ratings || 0, - })); - - const responseData = { - count: data.count, - next: data.next, - previous: data.previous, - results: transformedResults, - query: query || "", - type: type || "effects", - page, - pageSize, - sort, - minRating: min_rating, - }; - - 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 searching sounds:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +/** + * Handle GET requests to search Freesound and return a normalized search response. + * + * Validates query parameters, applies rate limiting and sound-effect filters, queries the Freesound API, transforms results into the API's response shape, and returns the formatted JSON payload or a JSON error object. + * + * @param request - The incoming NextRequest containing URL query parameters (q, type, page, page_size, sort, min_rating, commercial_only) and client headers used for rate limiting. + * @returns A JSON object with search metadata and an array of transformed sound results when successful; otherwise a JSON error object with an `error` field and optional `details` or `message` fields describing the failure. +export async function GET(request: NextRequest) { + try { + 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 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 } + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + // Use score sorting for search queries, downloads for top sounds + const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; + + const params = new URLSearchParams({ + query: query || "", + token: env.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); + + // Always apply sound effect filters (since we're primarily a sound effects search) + if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + + // Filter by license if commercial_only is true + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' + ); + } + + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" + ); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status } + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 } + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map((result) => ({ + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + })); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + 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 searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts index 9a497f65e..e89833f00 100644 --- a/apps/web/src/app/api/transcribe/route.ts +++ b/apps/web/src/app/api/transcribe/route.ts @@ -1,189 +1,202 @@ -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 } - ); - } -} +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(), +}); + +/** + * Handle POST requests to transcribe a file and return structured transcription results. + * + * Validates rate limits and request body, forwards the transcription request to the configured + * transcription service, validates the service response, and returns a JSON payload with + * `text`, `segments`, and `language` on success. + * + * On error the response is a JSON object containing an `error` string and often a `message` + * with an appropriate HTTP status code (examples: 400 for invalid input, 429 for rate limit, + * 502 for upstream transcription errors, 503 for missing configuration, 500 for internal errors). + * + * @returns A JSON response with the transcription `{ text, segments, language }` on success; otherwise a JSON error object with `error` and optional `message`. + */ +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 } + ); + } +} \ No newline at end of file diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts index 0200e255e..45c05cfdf 100644 --- a/apps/web/src/app/api/waitlist/export/route.ts +++ b/apps/web/src/app/api/waitlist/export/route.ts @@ -1,83 +1,94 @@ -import { NextRequest, NextResponse } from "next/server"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { db, exportWaitlist, eq } from "@opencut/db"; -import { randomUUID } from "crypto"; -import { - exportWaitlistSchema, - exportWaitlistResponseSchema, -} from "@/lib/schemas/waitlist"; - -const requestSchema = exportWaitlistSchema; -const responseSchema = exportWaitlistResponseSchema; - -export async function POST(request: NextRequest) { - try { - 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 }); - } - - const body = await request.json().catch(() => null); - if (!body) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: parsed.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { email } = parsed.data; - - const existing = await db - .select({ id: exportWaitlist.id }) - .from(exportWaitlist) - .where(eq(exportWaitlist.email, email)) - .limit(1); - - if (existing.length > 0) { - const responseData = { success: true, alreadySubscribed: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } - - await db.insert(exportWaitlist).values({ - id: randomUUID(), - email, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const responseData = { success: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } catch (error) { - console.error("Waitlist API error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { db, exportWaitlist, eq } from "@opencut/db"; +import { randomUUID } from "crypto"; +import { + exportWaitlistSchema, + exportWaitlistResponseSchema, +} from "@/lib/schemas/waitlist"; + +const requestSchema = exportWaitlistSchema; +const responseSchema = exportWaitlistResponseSchema; + +/** + * Handle POST requests to add an email to the export waitlist while enforcing per-IP rate limits. + * + * Validates JSON input, checks for an existing waitlist entry, inserts a new entry when needed, + * and returns a JSON response indicating success or an error condition. + * + * @returns A JSON response object: + * - `{ success: true, alreadySubscribed: true }` when the email is already on the waitlist + * - `{ success: true }` when a new waitlist entry is created + * - `{ error: string, ... }` with details and an appropriate HTTP status for invalid input, rate limit exceeded, or internal errors + */ +export async function POST(request: NextRequest) { + try { + 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 }); + } + + const body = await request.json().catch(() => null); + if (!body) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { email } = parsed.data; + + const existing = await db + .select({ id: exportWaitlist.id }) + .from(exportWaitlist) + .where(eq(exportWaitlist.email, email)) + .limit(1); + + if (existing.length > 0) { + const responseData = { success: true, alreadySubscribed: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } + + await db.insert(exportWaitlist).values({ + id: randomUUID(), + email, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const responseData = { success: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } catch (error) { + console.error("Waitlist API error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 246e8b6ff..a6d35515d 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -20,6 +20,11 @@ import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; import { PropertyGroup } from "./properties-panel/property-item"; +/** + * Renders the Export button and its associated popover, enabling export actions when a project is active. + * + * @returns A JSX element containing the Export button trigger and, when an active project exists, the ExportPopover; the button is disabled and non-interactive when no project is available. + */ export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const { activeProject } = useProjectStore(); @@ -54,7 +59,7 @@ export function ExportButton() { Export
-
+
@@ -268,6 +273,13 @@ function ExportPopover({ ); } +/** + * Render an error view for a failed export with actions to copy the error and retry. + * + * @param error - The error message to display and to copy to the clipboard when the user activates Copy + * @param onRetry - Callback invoked when the user activates Retry + * @returns A JSX element showing the failure message and action buttons for copying the error and retrying the export + */ function ExportError({ error, onRetry, @@ -287,9 +299,7 @@ function ExportError({

Export failed

-

- {error} -

+

{error}

@@ -314,4 +324,4 @@ function ExportError({
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx index ff4cde57a..12ca14858 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/layout-guide-overlay.tsx @@ -1,27 +1,39 @@ -"use client"; - -import { useEditorStore } from "@/stores/editor-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( -
- TikTok layout guide -
- ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = useEditorStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return ; - - return null; -} +"use client"; + +import { useEditorStore } from "@/stores/editor-store"; +import Image from "next/image"; + +/** + * Renders a non-interactive, full-size overlay showing the TikTok layout guide. + * + * @returns A JSX element containing an absolutely positioned, pointer-events-none container with the TikTok layout guide image filling its bounds. + */ +function TikTokGuide() { + return ( +
+ TikTok layout guide +
+ ); +} + +/** + * Displays a layout guide overlay based on the editor store's selected platform. + * + * Renders the TikTokGuide component when `layoutGuide.platform` is `"tiktok"`, otherwise renders `null`. + * + * @returns The overlay JSX element for the selected platform, or `null` when no guide applies. + */ +export function LayoutGuideOverlay() { + const { layoutGuide } = useEditorStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return ; + + return null; +} \ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index e2bfdff72..2b377338a 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -9,6 +9,14 @@ import { } from "@/components/ui/tooltip"; import { useEffect, useRef, useState } from "react"; +/** + * Renders a vertical, scrollable sidebar of icon tabs with tooltips and edge fades. + * + * Highlights the active tab, updates the active tab when a tab is clicked, and shows + * top/bottom gradient overlays when the scroll position is not at the respective edge. + * + * @returns The rendered tab bar element. + */ export function TabBar() { const { activeTab, setActiveTab } = useMediaPanelStore(); const scrollRef = useRef(null); @@ -30,7 +38,7 @@ export function TabBar() { checkScrollPosition(); element.addEventListener("scroll", checkScrollPosition); - + const resizeObserver = new ResizeObserver(checkScrollPosition); resizeObserver.observe(element); @@ -42,7 +50,7 @@ export function TabBar() { return (
-
@@ -78,20 +86,33 @@ export function TabBar() { ); })}
- +
); } -function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) { +/** + * Renders a directional gradient overlay that visually fades content at an edge. + * + * @param direction - Which edge to place the gradient on; `"top"` positions it at the top, `"bottom"` positions it at the bottom. + * @param show - Whether the overlay is visible. + * @returns A div element that displays a top or bottom gradient overlay when `show` is `true`. + */ +function FadeOverlay({ + direction, + show, +}: { + direction: "top" | "bottom"; + show: boolean; +}) { return ( -
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 fbe5b4183..37166e2fc 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -32,6 +32,13 @@ export const languages: Language[] = [ const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted"; +/** + * UI panel that generates caption text elements from the project's timeline audio while preserving user privacy. + * + * Renders language selection, progress and error UI, and a privacy consent dialog. When triggered, it extracts audio from the timeline, performs client-side zero-knowledge encryption, uploads the encrypted audio for transcription, and inserts the resulting caption text elements into a new text track on the timeline. + * + * @returns The component's JSX element. + */ export function Captions() { const [selectedCountry, setSelectedCountry] = useState("auto"); const [isProcessing, setIsProcessing] = useState(false); @@ -188,7 +195,10 @@ export function Captions() { }; return ( - + ); -} +} \ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 07a44a7cb..ab468bd93 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -68,6 +68,13 @@ function MediaItemWithContextMenu({ ); } +/** + * Renders the media gallery and editor panel with upload, drag-and-drop, view modes, sorting, previews, and item operations. + * + * Displays upload controls and view/sort toggles, provides a drag-and-drop and file-picker upload surface, and shows media items as either a grid or list with previews and per-item actions (delete, export placeholder). + * + * @returns The React element representing the media view panel. + */ export function MediaView() { const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); @@ -141,7 +148,7 @@ export function MediaView() { }; const filteredMediaItems = useMemo(() => { - let filtered = mediaFiles.filter((item) => { + const filtered = mediaFiles.filter((item) => { if (item.ephemeral) return false; return true; }); @@ -542,4 +549,4 @@ function ListView({ ))}
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index f67450cc2..a1c09a7fc 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useMemo } from "react"; +import type { CSSProperties } from "react"; import { useStickersStore } from "@/stores/stickers-store"; import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; @@ -85,6 +86,17 @@ export function StickersView() { ); } +/** + * Renders a responsive grid of stickers for the provided icon names. + * + * Renders one StickerItem per entry in `icons`, laying out items responsively and optionally capping their maximum size. + * + * @param icons - Array of sticker icon identifiers (e.g., "prefix:name") + * @param onAdd - Callback invoked with an icon name when that sticker is requested to be added + * @param addingSticker - Icon identifier that is currently in an "adding"/loading state, or `null` + * @param capSize - When true, constrains sticker items to a capped max size for a denser grid + * @returns A React element containing the sticker grid + */ function StickerGrid({ icons, onAdd, @@ -96,17 +108,19 @@ function StickerGrid({ addingSticker: string | null; capSize?: boolean; }) { + const gridStyle: CSSProperties & Record = { + gridTemplateColumns: capSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" + : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", + "--sticker-min": "96px", + }; + + if (capSize) { + gridStyle["--sticker-max"] = "160px"; + } + return ( -
+
{icons.map((iconName) => ( setShowCollectionItems(true), 350); return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); } + setShowCollectionItems(false); }, [isInCollection]); return ( @@ -343,6 +364,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { - - -
- Panel Presets -
- - {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( - handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" - > -
-
- - {PRESET_LABELS[preset]} - - {activePreset === preset && ( -
- )} -
-

- {PRESET_DESCRIPTIONS[preset]} -

-
- - - ))} - - - ); -} +"use client"; + +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; +import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; + +const PRESET_LABELS: Record = { + default: "Default", + media: "Media", + inspector: "Inspector", + "vertical-preview": "Vertical Preview", +}; + +const PRESET_DESCRIPTIONS: Record = { + default: "Media, preview, and inspector on top row, timeline on bottom", + media: "Full height media on left, preview and inspector on top row", + inspector: "Full height inspector on right, media and preview on top row", + "vertical-preview": "Full height preview on right for vertical videos", +}; + +/** + * Renders a dropdown for selecting and resetting panel presets. + * + * Displays each preset with a label and description, highlights the currently active preset, and provides a per-preset reset button. + * + * @returns The React element for the panel preset selector dropdown. + */ +export function PanelPresetSelector() { + const { activePreset, setActivePreset, resetPreset } = usePanelStore(); + + const handlePresetChange = (preset: PanelPreset) => { + setActivePreset(preset); + }; + + const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { + event.stopPropagation(); + resetPreset(preset); + }; + + return ( + + + + + +
+ Panel Presets +
+ + {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( + handlePresetChange(preset)} + className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" + > +
+
+ + {PRESET_LABELS[preset]} + + {activePreset === preset && ( +
+ )} +
+

+ {PRESET_DESCRIPTIONS[preset]} +

+
+ + + ))} + + + ); +} \ No newline at end of file diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 36f6a2ba0..d78f24abd 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -38,6 +38,11 @@ interface ActiveElement { mediaItem: MediaFile | null; } +/** + * Render the timeline preview area: a canvas-backed preview with fullscreen mode, toolbar playback controls, layout guide, and drag-to-reposition support for text elements. + * + * @returns The React element for the preview panel that hosts the rendered timeline canvas, fullscreen preview, and associated controls. + */ export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaFiles } = useMediaStore(); @@ -436,11 +441,15 @@ export function PreviewPanel() { } catch {} } playingSourcesRef.current.clear(); - void scheduleNow(); + scheduleNow().catch((error) => { + console.error("Failed to reschedule audio after seek", error); + }); }; // Apply volume/mute changes immediately - void ensureAudioGraph(); + ensureAudioGraph().catch((error) => { + console.error("Failed to ensure audio graph", error); + }); // Start/stop on play state changes for (const src of playingSourcesRef.current) { @@ -450,7 +459,9 @@ export function PreviewPanel() { } playingSourcesRef.current.clear(); if (isPlaying) { - void scheduleNow(); + scheduleNow().catch((error) => { + console.error("Failed to start audio playback", error); + }); } window.addEventListener("playback-seek", onSeek as EventListener); @@ -502,8 +513,8 @@ export function PreviewPanel() { mainCtx.putImageData(cachedFrame, 0, 0); // Pre-render nearby frames in background - if (!isPlaying) { - // Only during scrubbing to avoid interfering with playback + if (isPlaying) { + // Small lookahead while playing preRenderNearbyFrames( currentTime, tracks, @@ -536,10 +547,10 @@ export function PreviewPanel() { return tempCtx.getImageData(0, 0, displayWidth, displayHeight); }, currentScene?.id, - 3 + 1 ); } else { - // Small lookahead while playing + // Only during scrubbing to avoid interfering with playback preRenderNearbyFrames( currentTime, tracks, @@ -572,7 +583,7 @@ export function PreviewPanel() { return tempCtx.getImageData(0, 0, displayWidth, displayHeight); }, currentScene?.id, - 1 + 3 ); } return; @@ -608,14 +619,14 @@ export function PreviewPanel() { } } else { const c = offscreenCanvasRef.current as OffscreenCanvas; - // @ts-ignore width/height exist on OffscreenCanvas in modern browsers + // @ts-expect-error width/height exist on OffscreenCanvas in modern browsers if ( (c as unknown as { width: number }).width !== displayWidth || (c as unknown as { height: number }).height !== displayHeight ) { - // @ts-ignore + // @ts-expect-error (c as unknown as { width: number }).width = displayWidth; - // @ts-ignore + // @ts-expect-error (c as unknown as { height: number }).height = displayHeight; } } @@ -671,7 +682,9 @@ export function PreviewPanel() { } }; - void draw(); + draw().catch((error) => { + console.error("Failed to render preview frame", error); + }); }, [ activeElements, currentTime, @@ -1131,4 +1144,4 @@ function PreviewToolbar({
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 150f95124..c1150867e 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -8,6 +8,15 @@ import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; import { SquareSlashIcon } from "lucide-react"; +/** + * Render the properties panel for selected timeline elements. + * + * Renders a scrollable panel containing property editors for each selected element: + * text elements use TextProperties; media elements use AudioProperties for audio files + * and MediaProperties for non-audio files. When no elements are selected, renders an empty placeholder view. + * + * @returns The React element for the properties panel or the empty placeholder view + */ export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); const { mediaFiles } = useMediaStore(); @@ -38,7 +47,7 @@ export function PropertiesPanel() { return (
- +
); } @@ -67,4 +76,4 @@ function EmptyView() {
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index 4ea28bb24..ec3bf32d4 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,89 @@ import { MediaElement } from "@/types/timeline"; +import { PanelBaseView } from "../panel-base-view"; +import { Button } from "@/components/ui/button"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Sparkles, FlipHorizontal, FlipVertical } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; -export function MediaProperties({ element }: { element: MediaElement }) { - return
Media properties
; -} +/** + * Render transform controls for a media element's properties. + * + * Displays a "Transform" tab with buttons to toggle horizontal and vertical mirroring + * for the provided media element. + * + * @param element - The media element whose flip state (`flipH`, `flipV`) is shown and toggled + * @param trackId - Identifier of the track that contains the element (currently unused by the component) + * @returns The properties panel UI containing the Transform tab with flip controls + */ +export function MediaProperties({ + element, +}: { + element: MediaElement; + trackId: string; +}) { + const toggleSelectedMediaElements = useTimelineStore( + (state) => state.toggleSelectedMediaElements + ); + + const toggleFlipH = () => { + toggleSelectedMediaElements("flipH"); + }; + + const toggleFlipV = () => { + toggleSelectedMediaElements("flipV"); + }; + + return ( + , + content: ( +
+
+ + + + + + Toggle horizontal mirror + + + + + + + + Toggle vertical mirror + + +
+
+ ), + }, + ]} + /> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index deaee1573..f3ac81483 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -27,6 +27,13 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +/** + * Renders a properties panel for editing a text element within a timeline track. + * + * @param element - The text element being edited; controls are bound to this element's properties. + * @param trackId - Identifier of the timeline track that contains the element; used when applying updates. + * @returns The properties panel UI bound to the provided text element and track. + */ export function TextProperties({ element, trackId, @@ -131,7 +138,7 @@ export function TextProperties({ label: t.label, content: t.value === "transform" ? ( -
+
) : (