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 (
-
-
-
- );
-}
-
-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 (
+
+
+
+ );
+}
+
+/**
+ * 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 }) {
@@ -609,4 +631,4 @@ function StickerItem({
);
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index 6e92c6167..98d5591e2 100644
--- a/apps/web/src/components/editor/panel-base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -1,85 +1,104 @@
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/lib/utils";
-
-interface PanelBaseViewProps {
- children?: React.ReactNode;
- defaultTab?: string;
- value?: string;
- onValueChange?: (value: string) => void;
- tabs?: {
- value: string;
- label: string;
- icon?: React.ReactNode;
- content: React.ReactNode;
- }[];
- className?: string;
- ref?: React.RefObject;
-}
-
-function ViewContent({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function PanelBaseView({
- children,
- defaultTab,
- value,
- onValueChange,
- tabs,
- className = "",
- ref,
-}: PanelBaseViewProps) {
- return (
-
- {!tabs || tabs.length === 0 ? (
-
{children}
- ) : (
-
-
-
-
- {tabs.map((tab) => (
-
- {tab.icon ? (
-
- {tab.icon}
-
- ) : null}
- {tab.label}
-
- ))}
-
-
-
-
- {tabs.map((tab) => (
-
- {tab.content}
-
- ))}
-
- )}
-
- );
-}
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
+
+interface PanelBaseViewProps {
+ children?: React.ReactNode;
+ defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
+ tabs?: {
+ value: string;
+ label: string;
+ icon?: React.ReactNode;
+ content: React.ReactNode;
+ }[];
+ className?: string;
+ ref?: React.RefObject;
+}
+
+/**
+ * Renders a vertically scrollable content area with standard padding.
+ *
+ * @param children - Content to render inside the scrollable area.
+ * @param className - Additional CSS classes applied to the inner content container.
+ * @returns The scrollable content element containing the provided `children`.
+ */
+function ViewContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Render a full-height panel that displays either a simple scrollable content area or a tabs-based layout.
+ *
+ * @param children - Content to render inside the panel or inside the active tab's content area
+ * @param defaultTab - The tab value selected initially when uncontrolled
+ * @param value - Controlled current tab value; when provided, panel uses controlled tab state
+ * @param onValueChange - Callback invoked with the new tab value when the active tab changes
+ * @param tabs - Array of tab descriptors (value, label, optional icon, and content) to render a tabbed interface; if omitted or empty, `children` is rendered instead
+ * @param className - Additional CSS class names applied to the outer container and passed to inner content wrappers
+ * @param ref - Ref forwarded to the outer container div
+ * @returns A React element containing either the scrollable content area or the tabbed layout with each tab's content
+ */
+export function PanelBaseView({
+ children,
+ defaultTab,
+ value,
+ onValueChange,
+ tabs,
+ className = "",
+ ref,
+}: PanelBaseViewProps) {
+ return (
+
+ {!tabs || tabs.length === 0 ? (
+
{children}
+ ) : (
+
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.icon ? (
+
+ {tab.icon}
+
+ ) : null}
+ {tab.label}
+
+ ))}
+
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.content}
+
+ ))}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
index af22a1719..c9614de0d 100644
--- a/apps/web/src/components/editor/panel-preset-selector.tsx
+++ b/apps/web/src/components/editor/panel-preset-selector.tsx
@@ -1,91 +1,98 @@
-"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",
-};
-
-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]}
-
-
- handleResetPreset(preset, e)}
- title={`Reset ${PRESET_LABELS[preset]} 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]}
+
+
+ handleResetPreset(preset, e)}
+ title={`Reset ${PRESET_LABELS[preset]} 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" ? (
-
+
) : (
);
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index ca47d3ca9..f4ae97837 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -32,6 +32,19 @@ import {
} from "../../ui/context-menu";
import { useMediaPanelStore } from "../media-panel/store";
+/**
+ * Render an interactive timeline element (media clip or text) with visual content, selection/resize handles, drag positioning, and a context menu for edit actions.
+ *
+ * @param element - Timeline element data (type, mediaId, duration, startTime, trimStart, trimEnd, hidden, muted, flip flags, name/content).
+ * @param track - Track metadata that determines styling and track-specific behavior.
+ * @param zoomLevel - Zoom factor applied to compute the element's width and horizontal position.
+ * @param isSelected - Whether the element is currently selected; controls resize handles and selection visuals.
+ * @param onElementMouseDown - Optional callback invoked on mouse down with the event and the element.
+ * @param onElementClick - Optional callback invoked on click with the event and the element.
+ * @returns A React element representing the timeline element UI, including media/text rendering, overlays, resize handles, drag positioning, and context menu actions.
+ *
+ * @public
+ */
export function TimelineElement({
element,
track,
@@ -190,12 +203,16 @@ export function TimelineElement({
}`}
>
);
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
index 1351a4575..4c6b4cf4e 100644
--- a/apps/web/src/components/editor/timeline/timeline-marker.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx
@@ -1,67 +1,78 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-
-interface TimelineMarkerProps {
- time: number;
- zoomLevel: number;
- interval: number;
- isMainMarker: boolean;
-}
-
-export function TimelineMarker({
- time,
- zoomLevel,
- interval,
- isMainMarker,
-}: TimelineMarkerProps) {
- return (
-
-
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes
- .toString()
- .padStart(2, "0")}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
-
-
- );
-}
+"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+interface TimelineMarkerProps {
+ time: number;
+ zoomLevel: number;
+ interval: number;
+ isMainMarker: boolean;
+}
+
+/**
+ * Renders a timeline marker positioned horizontally and labeled with a formatted time.
+ *
+ * The marker's horizontal position is derived from `time`, a pixels-per-second constant, and `zoomLevel`.
+ *
+ * @param time - Marker time in seconds used for positioning and label formatting
+ * @param zoomLevel - Horizontal scaling factor applied to the marker position
+ * @param interval - Threshold that controls label precision: if `interval >= 1` whole seconds are shown; otherwise one decimal place is used for sub-second precision
+ * @param isMainMarker - When true, applies emphasis styling to the marker and its label
+ * @returns The marker element to render in the timeline
+ */
+export function TimelineMarker({
+ time,
+ zoomLevel,
+ interval,
+ isMainMarker,
+}: TimelineMarkerProps) {
+ return (
+
+
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes
+ .toString()
+ .padStart(2, "0")}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ }
+ return `${secs.toFixed(1)}s`;
+ };
+ return formatTime(time);
+ })()}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 176452cde..6f1f58aff 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -6,6 +6,13 @@ import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
import Image from "next/image";
+/**
+ * Renders the site footer containing the brand, social icons, and navigational links.
+ *
+ * The footer animates its opacity from 0 to 1 on mount. External links open in a new tab with rel="noopener noreferrer".
+ *
+ * @returns The footer React element
+ */
export function Footer() {
return (
-
@@ -127,4 +134,4 @@ export function Footer() {
);
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
index b24687a67..9e8b21d86 100644
--- a/apps/web/src/components/icons.tsx
+++ b/apps/web/src/components/icons.tsx
@@ -164,6 +164,13 @@ export function DataBuddyIcon({
);
}
+/**
+ * Renders a decorative "Socials" SVG icon sized to the given dimensions.
+ *
+ * @param className - Optional CSS class applied to the root SVG element.
+ * @param size - Width and height of the SVG in pixels (defaults to 32).
+ * @returns An SVG element containing the Socials decorative icon.
+ */
export function SocialsIcon({
className = "",
size = 32,
@@ -181,19 +188,60 @@ export function SocialsIcon({
className={className}
>
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
);
}
+/**
+ * Renders a compact "transition up" SVG icon.
+ *
+ * @param className - Optional CSS class applied to the outer SVG element
+ * @param size - Optional width and height of the SVG in pixels (defaults to 16)
+ * @returns An SVG element depicting a rounded container with an upward arrow glyph
+ */
export function TransitionUpIcon({
className = "",
size = 16,
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index 689c3ee3d..8b380a781 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -212,6 +212,14 @@ function ShortcutItem({
);
}
+/**
+ * Renders a clickable, key-like button that displays a shortcut part and starts recording a new binding when clicked.
+ *
+ * @param children - Content shown inside the key button (typically the key text)
+ * @param isRecording - Whether this key is currently being edited; affects the button's visual state and title
+ * @param onStartRecording - Callback invoked when the button is clicked to begin recording a new shortcut
+ * @returns The button element that displays the key part and triggers `onStartRecording` when activated
+ */
function EditableShortcutKey({
children,
isRecording,
diff --git a/apps/web/src/components/language-select.tsx b/apps/web/src/components/language-select.tsx
index 43ed632d6..e98b92f6a 100644
--- a/apps/web/src/components/language-select.tsx
+++ b/apps/web/src/components/language-select.tsx
@@ -1,204 +1,232 @@
-import { useState, useRef, useEffect } from "react";
-import { ChevronDown, Globe } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { motion } from "framer-motion";
-import ReactCountryFlag from "react-country-flag";
-
-export interface Language {
- code: string;
- name: string;
- flag?: string;
-}
-
-interface LanguageSelectProps {
- selectedCountry: string;
- onSelect: (country: string) => void;
- containerRef: React.RefObject
;
- 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 (
- onSelect({ code: language.code, e })}
- >
- {language.code === "auto" ? (
-
- ) : (
-
- )}
- {language.name}
-
- );
-}
+import { useState, useRef, useEffect } from "react";
+import { ChevronDown, Globe } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { motion } from "framer-motion";
+import ReactCountryFlag from "react-country-flag";
+
+export interface Language {
+ code: string;
+ name: string;
+ flag?: string;
+}
+
+interface LanguageSelectProps {
+ selectedCountry: string;
+ onSelect: (country: string) => void;
+ containerRef: React.RefObject;
+ languages: Language[];
+}
+
+/**
+ * Preloads country flag SVGs for the given languages by rendering them off-screen.
+ *
+ * @param languages - Array of language objects whose flags should be rendered to prime the browser's cache
+ * @returns The hidden container element that contains the rendered flag icons
+ */
+function FlagPreloader({ languages }: { languages: Language[] }) {
+ return (
+
+ {languages.map((language) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Renders a compact, animated language selector that expands to a scrollable list of languages.
+ *
+ * Renders a toggle button that shows the currently selected language (or "Auto") and expands into
+ * a list of selectable languages with flag icons. Clicking an item calls `onSelect` with the chosen
+ * country code and collapses the list. Clicking outside the expanded list collapses it with a closing
+ * animation.
+ *
+ * @param selectedCountry - The currently selected country code (use `"auto"` for automatic selection).
+ * @param onSelect - Callback invoked with the selected country code when the user chooses a language.
+ * @param containerRef - Optional ref to the outer container element (provided for external positioning or measurements).
+ * @param languages - Available languages to display, each with a `code` and `name`.
+ * @returns The language selector UI component.
+ */
+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 ? (
+
+
+ {languages.map((language) => (
+
+ ))}
+
+ ) : (
+
+
+ {selectedCountry === "auto" ? (
+
+ ) : (
+
+ )}
+
+ {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Render a selectable language button showing a flag (or globe) and the language name.
+ *
+ * @param language - The language option to display (code, name, optional flag).
+ * @param onSelect - Callback invoked when the button is clicked; called with `{ code, e }` where `code` is the language code and `e` is the click event.
+ * @param selectedCountry - Currently selected language code (provided for context; not used for rendering selection state here).
+ * @returns The button element that displays the language and triggers `onSelect` when activated.
+ */
+function LanguageButton({
+ language,
+ onSelect,
+ selectedCountry,
+}: {
+ language: Language;
+ onSelect: ({
+ code,
+ e,
+ }: {
+ code: string;
+ e: React.MouseEvent;
+ }) => void;
+ selectedCountry: string;
+}) {
+ return (
+ onSelect({ code: language.code, e })}
+ >
+ {language.code === "auto" ? (
+
+ ) : (
+
+ )}
+ {language.name}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
index 8302a19c6..25083846c 100644
--- a/apps/web/src/components/theme-toggle.tsx
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -1,25 +1,32 @@
-"use client";
-
-import { Button } from "./ui/button";
-import { Sun, Moon } from "lucide-react";
-import { useTheme } from "next-themes";
-
-interface ThemeToggleProps {
- className?: string;
-}
-
-export function ThemeToggle({ className }: ThemeToggleProps) {
- const { theme, setTheme } = useTheme();
-
- return (
- setTheme(theme === "dark" ? "light" : "dark")}
- >
-
- {theme === "dark" ? "Light" : "Dark"}
-
- );
-}
+"use client";
+
+import { Button } from "./ui/button";
+import { Sun, Moon } from "lucide-react";
+import { useTheme } from "next-themes";
+
+interface ThemeToggleProps {
+ className?: string;
+}
+
+/**
+ * Renders a button that toggles the application theme between light and dark.
+ *
+ * The button displays a Sun icon and an accessible label that indicates which theme will be activated when pressed.
+ *
+ * @returns The theme toggle button element
+ */
+export function ThemeToggle({ className }: ThemeToggleProps) {
+ const { theme, setTheme } = useTheme();
+
+ return (
+ setTheme(theme === "dark" ? "light" : "dark")}
+ >
+
+ {theme === "dark" ? "Light" : "Dark"}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/ui/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx
index 404831f6a..4aaf24e0d 100644
--- a/apps/web/src/components/ui/editable-timecode.tsx
+++ b/apps/web/src/components/ui/editable-timecode.tsx
@@ -1,138 +1,156 @@
-"use client";
-
-import { useState, useRef, useEffect } from "react";
-import { cn } from "@/lib/utils";
-import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time";
-import { DEFAULT_FPS } from "@/stores/project-store";
-
-interface EditableTimecodeProps {
- time: number;
- duration?: number;
- format?: TimeCode;
- fps?: number;
- onTimeChange?: (time: number) => void;
- className?: string;
- disabled?: boolean;
-}
-
-export function EditableTimecode({
- time,
- duration,
- format = "HH:MM:SS:FF",
- fps = DEFAULT_FPS,
- onTimeChange,
- className,
- disabled = false,
-}: EditableTimecodeProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [inputValue, setInputValue] = useState("");
- const [hasError, setHasError] = useState(false);
- const inputRef = useRef(null);
- const enterPressedRef = useRef(false);
-
- const formattedTime = formatTimeCode(time, format, fps);
-
- const startEditing = () => {
- if (disabled) return;
- setIsEditing(true);
- setInputValue(formattedTime);
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const cancelEditing = () => {
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const applyEdit = () => {
- const parsedTime = parseTimeCode(inputValue, format, fps);
-
- if (parsedTime === null) {
- setHasError(true);
- return;
- }
-
- // Clamp time to valid range
- const clampedTime = Math.max(
- 0,
- duration ? Math.min(duration, parsedTime) : parsedTime
- );
-
- onTimeChange?.(clampedTime);
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- e.preventDefault();
- enterPressedRef.current = true;
- applyEdit();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelEditing();
- }
- };
-
- const handleInputChange = (e: React.ChangeEvent) => {
- setInputValue(e.target.value);
- setHasError(false);
- };
-
- const handleBlur = () => {
- // Only apply edit if Enter wasn't pressed (to avoid double processing)
- if (!enterPressedRef.current && isEditing) {
- applyEdit();
- }
- };
-
- // Focus input when entering edit mode
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- if (isEditing) {
- return (
-
- );
- }
-
- return (
-
- {formattedTime}
-
- );
-}
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time";
+import { DEFAULT_FPS } from "@/stores/project-store";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration?: number;
+ format?: TimeCode;
+ fps?: number;
+ onTimeChange?: (time: number) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+/**
+ * Render an editable timecode control that toggles between display and inline edit modes.
+ *
+ * Renders the given `time` formatted according to `format` and `fps`. When clicked (unless `disabled`),
+ * the control becomes an inline text input where the user can enter a new timecode. Submitting the input
+ * (Enter or blur) parses the value using the same `format` and `fps`, clamps it to the range [0, duration] if
+ * `duration` is provided, and calls `onTimeChange` with the resulting time. Pressing Escape cancels edits.
+ * Invalid input is indicated via error styling and will not trigger `onTimeChange`.
+ *
+ * @param time - The current time in seconds (or the unit expected by the project's time utilities).
+ * @param duration - Optional maximum time; parsed values greater than this will be clamped to `duration`.
+ * @param format - Timecode format string used for display and parsing (default: `"HH:MM:SS:FF"`).
+ * @param fps - Frames per second used for frame fields in the timecode (default: DEFAULT_FPS).
+ * @param onTimeChange - Callback invoked with the new clamped time when a valid edit is applied.
+ * @param className - Optional additional class names applied to the rendered element or input.
+ * @param disabled - If true, the control is not editable and clicking will not enter edit mode.
+ * @returns A JSX element that displays the formatted timecode and supports inline editing.
+ */
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps = DEFAULT_FPS,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef(null);
+ const enterPressedRef = useRef(false);
+
+ const formattedTime = formatTimeCode(time, format, fps);
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode(inputValue, format, fps);
+
+ if (parsedTime === null) {
+ setHasError(true);
+ return;
+ }
+
+ // Clamp time to valid range
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime
+ );
+
+ onTimeChange?.(clampedTime);
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ // Only apply edit if Enter wasn't pressed (to avoid double processing)
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ // Focus input when entering edit mode
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {formattedTime}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 7bb112748..708db8403 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -13,6 +13,14 @@ interface FontPickerProps {
className?: string;
}
+/**
+ * Renders a font selection dropdown whose items are styled with their respective font families.
+ *
+ * @param defaultValue - Optional font to select initially
+ * @param onValueChange - Optional callback invoked with the selected `FontFamily` when the selection changes
+ * @param className - Optional additional CSS classes applied to the trigger element
+ * @returns A React element that renders a font picker dropdown
+ */
export function FontPicker({
defaultValue,
onValueChange,
@@ -20,7 +28,9 @@ export function FontPicker({
}: FontPickerProps) {
return (
-
+
@@ -37,4 +47,4 @@ export function FontPicker({
);
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
index cae9f8c9a..cb8fbf5ef 100644
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ b/apps/web/src/components/ui/input-with-back.tsx
@@ -1,88 +1,101 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
-
-
setIsExpanded(!isExpanded)}
- >
-
-
-
-
-
-
-
- onChange?.(e.target.value)}
- />
-
-
-
- );
-}
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ArrowLeft, Search } from "lucide-react";
+import { motion } from "motion/react";
+import { useState, useEffect } from "react";
+
+interface InputWithBackProps {
+ isExpanded: boolean;
+ setIsExpanded: (isExpanded: boolean) => void;
+ placeholder?: string;
+ value?: string;
+ onChange?: (value: string) => void;
+ disableAnimation?: boolean;
+}
+
+/**
+ * Render an input field with an animated back/close button that toggles an expanded state.
+ *
+ * The back button is positioned relative to the component and the input shifts when expanded.
+ *
+ * @param isExpanded - Whether the input area is expanded.
+ * @param setIsExpanded - Callback to update the expanded state; invoked with the new boolean state when the back button is clicked.
+ * @param placeholder - Optional input placeholder text (defaults to "Search anything").
+ * @param value - Optional controlled input value.
+ * @param onChange - Optional change handler called with the updated input string when the value changes.
+ * @param disableAnimation - When true, disables animated transitions.
+ * @returns A JSX element containing the back button and the input field.
+ */
+export function InputWithBack({
+ isExpanded,
+ setIsExpanded,
+ placeholder = "Search anything",
+ value,
+ onChange,
+ disableAnimation = false,
+}: InputWithBackProps) {
+ const [containerRef, setContainerRef] = useState(null);
+ const [buttonOffset, setButtonOffset] = useState(-60);
+
+ const smoothTransition = {
+ duration: disableAnimation ? 0 : 0.35,
+ ease: [0.25, 0.1, 0.25, 1] as const,
+ };
+
+ useEffect(() => {
+ if (containerRef) {
+ const rect = containerRef.getBoundingClientRect();
+ setButtonOffset(-rect.left - 48);
+ }
+ }, [containerRef]);
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+
+ onChange?.(e.target.value)}
+ />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-edge-auto-scroll.ts b/apps/web/src/hooks/use-edge-auto-scroll.ts
index d3cf8127b..4c6eb5e94 100644
--- a/apps/web/src/hooks/use-edge-auto-scroll.ts
+++ b/apps/web/src/hooks/use-edge-auto-scroll.ts
@@ -1,101 +1,116 @@
-import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject;
- tracksScrollRef: React.RefObject;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-// Provides smooth edge auto-scrolling for horizontal timeline interactions.
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(
- 0,
- viewportWidth - edgeThreshold - mouseXRelative
- );
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = maxScrollSpeed * intensity;
- }
-
- if (scrollSpeed !== 0) {
- const newScrollLeft = Math.max(
- 0,
- Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
- );
- rulerViewport.scrollLeft = newScrollLeft;
- tracksViewport.scrollLeft = newScrollLeft;
- }
-
- rafRef.current = requestAnimationFrame(step);
- };
-
- rafRef.current = requestAnimationFrame(step);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- };
- }, [
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold,
- maxScrollSpeed,
- ]);
-}
+import { useEffect, useRef } from "react";
+
+interface UseEdgeAutoScrollParams {
+ isActive: boolean;
+ getMouseClientX: () => number;
+ rulerScrollRef: React.RefObject;
+ tracksScrollRef: React.RefObject;
+ contentWidth: number;
+ edgeThreshold?: number;
+ maxScrollSpeed?: number;
+}
+
+/**
+ * Enables edge-triggered horizontal auto-scrolling for a timeline by synchronizing two scrollable viewports.
+ *
+ * When active, moves both the ruler and tracks viewports left or right while the mouse is within
+ * `edgeThreshold` pixels of the respective edge. Scroll speed scales with proximity to the edge,
+ * up to `maxScrollSpeed`, and scrolling is clamped within the available content range computed
+ * from `contentWidth` and the ruler's intrinsic width.
+ *
+ * @param isActive - If `true`, start auto-scrolling behavior; if `false`, stop and cancel any ongoing scrolling.
+ * @param getMouseClientX - Function that returns the current mouse X coordinate in client (viewport) space.
+ * @param rulerScrollRef - Ref to the primary scrollable ruler viewport element.
+ * @param tracksScrollRef - Ref to the secondary scrollable tracks viewport element; kept in sync with the ruler.
+ * @param contentWidth - Desired content width used to compute the maximum horizontal scroll extent.
+ * @param edgeThreshold - Distance in pixels from an edge within which auto-scrolling begins (default: 100).
+ * @param maxScrollSpeed - Maximum horizontal scroll delta applied per step when the mouse is at the edge (default: 15).
+ */
+export function useEdgeAutoScroll({
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold = 100,
+ maxScrollSpeed = 15,
+}: UseEdgeAutoScrollParams): void {
+ const rafRef = useRef(null);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ return;
+ }
+
+ const step = () => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) {
+ rafRef.current = requestAnimationFrame(step);
+ return;
+ }
+
+ const viewportRect = rulerViewport.getBoundingClientRect();
+ const mouseX = getMouseClientX();
+ const mouseXRelative = mouseX - viewportRect.left;
+
+ const viewportWidth = rulerViewport.clientWidth;
+ const intrinsicContentWidth = rulerViewport.scrollWidth;
+ const effectiveContentWidth = Math.max(
+ contentWidth,
+ intrinsicContentWidth
+ );
+ const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
+
+ let scrollSpeed = 0;
+
+ if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
+ const edgeDistance = Math.max(0, mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = -maxScrollSpeed * intensity;
+ } else if (
+ mouseXRelative > viewportWidth - edgeThreshold &&
+ rulerViewport.scrollLeft < scrollMax
+ ) {
+ const edgeDistance = Math.max(
+ 0,
+ viewportWidth - edgeThreshold - mouseXRelative
+ );
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = maxScrollSpeed * intensity;
+ }
+
+ if (scrollSpeed !== 0) {
+ const newScrollLeft = Math.max(
+ 0,
+ Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
+ );
+ rulerViewport.scrollLeft = newScrollLeft;
+ tracksViewport.scrollLeft = newScrollLeft;
+ }
+
+ rafRef.current = requestAnimationFrame(step);
+ };
+
+ rafRef.current = requestAnimationFrame(step);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, [
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold,
+ maxScrollSpeed,
+ ]);
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
index cce187f1b..240643ae4 100644
--- a/apps/web/src/hooks/use-frame-cache.ts
+++ b/apps/web/src/hooks/use-frame-cache.ts
@@ -26,6 +26,12 @@ const __sharedFrameCache: Map =
__frameCacheGlobal.__sharedFrameCache ?? new Map();
__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache;
+/**
+ * Provides utilities to manage a per-frame render cache for a video/timeline editor.
+ *
+ * @param options - Configuration for the frame cache. `maxCacheSize` limits stored frames (default 300). `cacheResolution` sets frames-per-second bucketing used for cache keys (default 30).
+ * @returns An object with methods to query, read, write, invalidate, and pre-render cached frames, plus the current `cacheSize`.
+ */
export function useFrameCache(options: FrameCacheOptions = {}) {
const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps
@@ -49,6 +55,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
trimStart: number;
trimEnd: number;
mediaId?: string;
+ flipH?: boolean;
+ flipV?: boolean;
// Text-specific properties
content?: string;
fontSize?: number;
@@ -85,6 +93,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
mediaId: mediaElement.mediaId,
+ flipH: mediaElement.flipH ?? false,
+ flipV: mediaElement.flipV ?? false,
});
} else if (element.type === "text") {
const textElement = element as TextElement;
@@ -268,7 +278,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
activeProject: TProject | null,
renderFunction: (time: number) => Promise,
sceneId?: string,
- range: number = 3 // seconds
+ range = 3 // seconds
) => {
const framesToPreRender: number[] = [];
@@ -345,4 +355,4 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
preRenderNearbyFrames,
cacheSize: frameCacheRef.current.size,
};
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-highlight-scroll.ts b/apps/web/src/hooks/use-highlight-scroll.ts
index 241bffb25..b6fda3d2f 100644
--- a/apps/web/src/hooks/use-highlight-scroll.ts
+++ b/apps/web/src/hooks/use-highlight-scroll.ts
@@ -1,36 +1,44 @@
-import { useEffect, useState, useRef } from "react";
-
-export function useHighlightScroll(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000
-) {
- const [highlightedId, setHighlightedId] = useState(null);
- const elementRefs = useRef>(new Map());
-
- const registerElement = (id: string, element: HTMLElement | null) => {
- if (element) {
- elementRefs.current.set(id, element);
- } else {
- elementRefs.current.delete(id);
- }
- };
-
- useEffect(() => {
- if (!highlightId) return;
-
- setHighlightedId(highlightId);
-
- const target = elementRefs.current.get(highlightId);
- target?.scrollIntoView({ block: "center" });
-
- const timeout = setTimeout(() => {
- setHighlightedId(null);
- onClearHighlight();
- }, highlightDuration);
-
- return () => clearTimeout(timeout);
- }, [highlightId, onClearHighlight, highlightDuration]);
-
- return { highlightedId, registerElement };
-}
+import { useEffect, useState, useRef } from "react";
+
+/**
+ * Tracks and applies a temporary highlight to an element by id: scrolls it into view and clears the highlight after a timeout.
+ *
+ * @param highlightId - The id of the element to highlight; pass `null` to skip highlighting.
+ * @param onClearHighlight - Callback invoked when the highlight duration ends and the highlight is cleared.
+ * @param highlightDuration - Time in milliseconds before the highlight is cleared.
+ * @returns An object containing `highlightedId` (the current highlighted id or `null`) and `registerElement` (a function to register or unregister elements by id)
+ */
+export function useHighlightScroll(
+ highlightId: string | null,
+ onClearHighlight: () => void,
+ highlightDuration = 1000
+) {
+ const [highlightedId, setHighlightedId] = useState(null);
+ const elementRefs = useRef>(new Map());
+
+ const registerElement = (id: string, element: HTMLElement | null) => {
+ if (element) {
+ elementRefs.current.set(id, element);
+ } else {
+ elementRefs.current.delete(id);
+ }
+ };
+
+ useEffect(() => {
+ if (!highlightId) return;
+
+ setHighlightedId(highlightId);
+
+ const target = elementRefs.current.get(highlightId);
+ target?.scrollIntoView({ block: "center" });
+
+ const timeout = setTimeout(() => {
+ setHighlightedId(null);
+ onClearHighlight();
+ }, highlightDuration);
+
+ return () => clearTimeout(timeout);
+ }, [highlightId, onClearHighlight, highlightDuration]);
+
+ return { highlightedId, registerElement };
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts
index 0f8db198b..626ecd4d9 100644
--- a/apps/web/src/hooks/use-infinite-scroll.ts
+++ b/apps/web/src/hooks/use-infinite-scroll.ts
@@ -1,35 +1,45 @@
-import { useRef, useCallback } from "react";
-
-interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
-}
-
-export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
-}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef(null);
-
- const handleScroll = useCallback(
- (event: React.UIEvent) => {
- if (!enabled) return;
-
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
-
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled]
- );
-
- return { scrollAreaRef, handleScroll };
-}
+import { useRef, useCallback } from "react";
+
+interface UseInfiniteScrollOptions {
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
+}
+
+/**
+ * Provides refs and a scroll handler to trigger loading more items when the user scrolls near the bottom of a container.
+ *
+ * @param onLoadMore - Callback invoked to load the next page or batch of items
+ * @param hasMore - Whether additional items are available to load
+ * @param isLoading - Whether a load operation is currently in progress; prevents duplicate loads
+ * @param threshold - Distance in pixels from the bottom of the container at which `onLoadMore` is triggered (default: 200)
+ * @param enabled - When false, disables the scroll handler so no loading is triggered (default: true)
+ * @returns An object containing `scrollAreaRef` (ref for the scrollable container) and `handleScroll` (the scroll event handler)
+ */
+export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
+}: UseInfiniteScrollOptions) {
+ const scrollAreaRef = useRef(null);
+
+ const handleScroll = useCallback(
+ (event: React.UIEvent) => {
+ if (!enabled) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled]
+ );
+
+ return { scrollAreaRef, handleScroll };
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
index db9a7e8eb..8c3c967c0 100644
--- a/apps/web/src/hooks/use-sound-search.ts
+++ b/apps/web/src/hooks/use-sound-search.ts
@@ -1,156 +1,165 @@
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-/**
- * Custom hook for searching sound effects with race condition protection.
- * Uses global Zustand store to persist search state across tab switches.
- * - Debounced search (300ms)
- * - Race condition protection with cleanup
- * - Proper error handling
- */
-
-export function useSoundSearch(query: string, commercialOnly: boolean) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
-
- // Load more function for infinite scroll
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
-
- try {
- setLoadingMore(true);
- const nextPage = currentPage + 1;
-
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
-
- if (query.trim()) {
- searchParams.set("q", query);
- }
-
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`
- );
-
- if (response.ok) {
- const data = await response.json();
-
- // Append to appropriate array based on whether we have a query
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
-
- setCurrentPage(nextPage);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- } else {
- setSearchError(`Load more failed: ${response.status}`);
- }
- } catch (err) {
- setSearchError(err instanceof Error ? err.message : "Load more failed");
- } finally {
- setLoadingMore(false);
- }
- };
-
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults([]);
- setSearchError(null);
- setLastSearchQuery("");
- // Don't reset pagination here - top sounds pagination is managed by prefetcher
- return;
- }
-
- // If we already searched for this query and have results, don't search again
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
-
- let ignore = false;
-
- const timeoutId = setTimeout(async () => {
- try {
- setSearching(true);
- setSearchError(null);
- resetPagination();
-
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
- );
-
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults(data.results);
- setLastSearchQuery(query);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- setCurrentPage(1);
- } else {
- setSearchError(`Search failed: ${response.status}`);
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError(err instanceof Error ? err.message : "Search failed");
- }
- } finally {
- if (!ignore) {
- setSearching(false);
- }
- }
- }, 300);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
-}
+import { useEffect } from "react";
+import { useSoundsStore } from "@/stores/sounds-store";
+
+/**
+ * Searches sound effects and exposes search results, pagination, and loading state from the global store.
+ *
+ * Performs a debounced (300ms) search with race-condition protection and persists state in a shared store.
+ *
+ * @param query - The search query string to filter sound effects; an empty or whitespace-only string clears results.
+ * @param commercialOnly - If true, restricts results to commercial-only sounds.
+ * @returns An object containing:
+ * - `results`: the current array of search results,
+ * - `isLoading`: whether an initial search is in progress,
+ * - `error`: the current search error message or `null`,
+ * - `loadMore`: a function to fetch the next page of results,
+ * - `hasNextPage`: whether more pages are available,
+ * - `isLoadingMore`: whether a "load more" operation is in progress,
+ * - `totalCount`: the total number of matching results.
+ */
+
+export function useSoundSearch(query: string, commercialOnly: boolean) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
+
+ // Load more function for infinite scroll
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
+
+ try {
+ setLoadingMore(true);
+ const nextPage = currentPage + 1;
+
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
+
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
+
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+
+ // Append to appropriate array based on whether we have a query
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
+
+ setCurrentPage(nextPage);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ } else {
+ setSearchError(`Load more failed: ${response.status}`);
+ }
+ } catch (err) {
+ setSearchError(err instanceof Error ? err.message : "Load more failed");
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults([]);
+ setSearchError(null);
+ setLastSearchQuery("");
+ // Don't reset pagination here - top sounds pagination is managed by prefetcher
+ return;
+ }
+
+ // If we already searched for this query and have results, don't search again
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
+
+ let ignore = false;
+
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching(true);
+ setSearchError(null);
+ resetPagination();
+
+ const response = await fetch(
+ `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
+ );
+
+ if (!ignore) {
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults(data.results);
+ setLastSearchQuery(query);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ setCurrentPage(1);
+ } else {
+ setSearchError(`Search failed: ${response.status}`);
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError(err instanceof Error ? err.message : "Search failed");
+ }
+ } finally {
+ if (!ignore) {
+ setSearching(false);
+ }
+ }
+ }, 300);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ query,
+ lastSearchQuery,
+ searchResults.length,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ resetPagination,
+ ]);
+
+ return {
+ results: searchResults,
+ isLoading: isSearching,
+ error: searchError,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ };
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/editor-utils.ts b/apps/web/src/lib/editor-utils.ts
index f041aa5ec..a959f28ff 100644
--- a/apps/web/src/lib/editor-utils.ts
+++ b/apps/web/src/lib/editor-utils.ts
@@ -1,46 +1,47 @@
-import { CanvasSize } from "@/types/editor";
-
-const DEFAULT_CANVAS_PRESETS = [
- { name: "16:9", width: 1920, height: 1080 },
- { name: "9:16", width: 1080, height: 1920 },
- { name: "1:1", width: 1080, height: 1080 },
- { name: "4:3", width: 1440, height: 1080 },
-];
-
-/**
- * Helper function to find the best matching canvas preset for an aspect ratio
- * @param aspectRatio The target aspect ratio to match
- * @returns The best matching canvas size
- */
-export function findBestCanvasPreset(aspectRatio: number): CanvasSize {
- // Calculate aspect ratio for each preset and find the closest match
- let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
- let smallestDifference = Math.abs(
- aspectRatio - bestMatch.width / bestMatch.height
- );
-
- for (const preset of DEFAULT_CANVAS_PRESETS) {
- const presetAspectRatio = preset.width / preset.height;
- const difference = Math.abs(aspectRatio - presetAspectRatio);
-
- if (difference < smallestDifference) {
- smallestDifference = difference;
- bestMatch = preset;
- }
- }
-
- // If the difference is still significant (> 0.1), create a custom size
- // based on the media aspect ratio with a reasonable resolution
- const bestAspectRatio = bestMatch.width / bestMatch.height;
- if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
- // Create custom dimensions based on the aspect ratio
- if (aspectRatio > 1) {
- // Landscape - use 1920 width
- return { width: 1920, height: Math.round(1920 / aspectRatio) };
- }
- // Portrait or square - use 1080 height
- return { width: Math.round(1080 * aspectRatio), height: 1080 };
- }
-
- return { width: bestMatch.width, height: bestMatch.height };
-}
+import { CanvasSize } from "@/types/editor";
+
+const DEFAULT_CANVAS_PRESETS = [
+ { name: "16:9", width: 1920, height: 1080 },
+ { name: "9:16", width: 1080, height: 1920 },
+ { name: "1:1", width: 1080, height: 1080 },
+ { name: "4:3", width: 1440, height: 1080 },
+];
+
+/**
+ * Selects the closest predefined canvas preset for a given aspect ratio or returns a reasonable custom size when no close preset exists.
+ *
+ * @param aspectRatio - Target aspect ratio expressed as width divided by height
+ * @returns The chosen canvas size; one of the predefined presets if a preset's aspect ratio is within 0.1 of `aspectRatio`, otherwise a custom size (landscape: width 1920 and height rounded to 1920 / aspectRatio; portrait/square: height 1080 and width rounded to 1080 * aspectRatio)
+ */
+export function findBestCanvasPreset(aspectRatio: number): CanvasSize {
+ // Calculate aspect ratio for each preset and find the closest match
+ let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
+ let smallestDifference = Math.abs(
+ aspectRatio - bestMatch.width / bestMatch.height
+ );
+
+ for (const preset of DEFAULT_CANVAS_PRESETS) {
+ const presetAspectRatio = preset.width / preset.height;
+ const difference = Math.abs(aspectRatio - presetAspectRatio);
+
+ if (difference < smallestDifference) {
+ smallestDifference = difference;
+ bestMatch = preset;
+ }
+ }
+
+ // If the difference is still significant (> 0.1), create a custom size
+ // based on the media aspect ratio with a reasonable resolution
+ const bestAspectRatio = bestMatch.width / bestMatch.height;
+ if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
+ // Create custom dimensions based on the aspect ratio
+ if (aspectRatio > 1) {
+ // Landscape - use 1920 width
+ return { width: 1920, height: Math.round(1920 / aspectRatio) };
+ }
+ // Portrait or square - use 1080 height
+ return { width: Math.round(1080 * aspectRatio), height: 1080 };
+ }
+
+ return { width: bestMatch.width, height: bestMatch.height };
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index 52c587360..ee540fe37 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -41,15 +41,25 @@ interface AudioElement {
muted: boolean;
}
+/**
+ * Produce a single mixed stereo AudioBuffer containing all audible audio items placed on the timeline.
+ *
+ * @param tracks - Timeline tracks to read audio elements from; muted tracks or muted elements are ignored.
+ * @param mediaFiles - Media library entries used to locate and decode audio files referenced by timeline elements.
+ * @param duration - Total output duration in seconds; determines the length of the returned buffer.
+ * @param sampleRate - Output sample rate in Hz (defaults to 44100).
+ * @returns The mixed stereo AudioBuffer spanning `duration` seconds, or `null` if no audio elements were found.
+ */
async function createTimelineAudioBuffer(
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
duration: number,
- sampleRate: number = 44100
+ sampleRate = 44_100
): Promise {
// Get Web Audio context
- const audioContext = new (window.AudioContext ||
- (window as any).webkitAudioContext)();
+ const audioContext = new (
+ window.AudioContext || (window as any).webkitAudioContext
+ )();
// Collect all audio elements from timeline
const audioElements: AudioElement[] = [];
@@ -298,4 +308,4 @@ export function getExportMimeType(format: "mp4" | "webm"): string {
export function getExportFileExtension(format: "mp4" | "webm"): string {
return `.${format}`;
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts
index 9d1bcb972..ffe4b152f 100644
--- a/apps/web/src/lib/iconify-api.ts
+++ b/apps/web/src/lib/iconify-api.ts
@@ -1,4 +1,3 @@
-
export const ICONIFY_HOSTS = [
"https://api.iconify.design",
"https://api.simplesvg.com",
@@ -97,9 +96,18 @@ export async function getCollection(
}
}
+/**
+ * Search for icons matching the given query and optional filters.
+ *
+ * @param query - Text query to search icon names and tags
+ * @param limit - Maximum number of icons to return
+ * @param prefixes - Optional array of collection prefixes to restrict the search to
+ * @param category - Optional collection category to filter results
+ * @returns An `IconSearchResult` containing matching icons, total count, pagination fields, and a map of collections; if the search fails, returns an empty result with zero totals
+ */
export async function searchIcons(
query: string,
- limit: number = 64,
+ limit = 64,
prefixes?: string[],
category?: string
): Promise {
@@ -239,4 +247,4 @@ export function getCategoriesFromCollections(
}
});
return Array.from(categories).sort();
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
index b2650b7cf..7b2f97c72 100644
--- a/apps/web/src/lib/timeline-renderer.ts
+++ b/apps/web/src/lib/timeline-renderer.ts
@@ -1,4 +1,4 @@
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack, MediaElement } from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import type { BlurIntensity } from "@/types/project";
import { videoCache } from "./video-cache";
@@ -35,6 +35,14 @@ async function getImageElement(
return img;
}
+/**
+ * Render a single timeline frame onto the given canvas 2D context.
+ *
+ * Draws the configured background (solid color or CSS gradient), optionally renders a blurred cover layer
+ * from an active media item when backgroundType is "blur", and then draws all active track elements
+ * (videos and images are scaled to fit/cover and drawn centered with `flipH`/`flipV` applied; text is drawn
+ * at its position with rotation, opacity, font settings, alignment, and optional text background).
+ */
export async function renderTimelineFrame({
ctx,
time,
@@ -109,6 +117,10 @@ export async function renderTimelineFrame({
if (bgCandidate && bgCandidate.mediaItem) {
const { element, mediaItem } = bgCandidate;
try {
+ const mediaElement = element as MediaElement;
+ const flipH = !!mediaElement.flipH;
+ const flipV = !!mediaElement.flipV;
+
if (mediaItem.type === "video") {
const localTime = time - element.startTime + element.trimStart;
const frame = await videoCache.getFrameAt(
@@ -125,11 +137,11 @@ export async function renderTimelineFrame({
);
const drawW = mediaW * coverScale;
const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
ctx.save();
ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
+ ctx.translate(canvasWidth / 2, canvasHeight / 2);
+ ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
+ ctx.drawImage(frame.canvas, -drawW / 2, -drawH / 2, drawW, drawH);
ctx.restore();
}
} else if (mediaItem.type === "image") {
@@ -148,11 +160,11 @@ export async function renderTimelineFrame({
);
const drawW = mediaW * coverScale;
const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
ctx.save();
ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(img, drawX, drawY, drawW, drawH);
+ ctx.translate(canvasWidth / 2, canvasHeight / 2);
+ ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
ctx.restore();
}
} catch {
@@ -182,10 +194,14 @@ export async function renderTimelineFrame({
);
const drawW = mediaW * containScale;
const drawH = mediaH * containScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
-
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
+ const mediaElement = element as MediaElement;
+ const flipH = !!mediaElement.flipH;
+ const flipV = !!mediaElement.flipV;
+ ctx.save();
+ ctx.translate(canvasWidth / 2, canvasHeight / 2);
+ ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
+ ctx.drawImage(frame.canvas, -drawW / 2, -drawH / 2, drawW, drawH);
+ ctx.restore();
} catch (error) {
console.warn(
`Failed to render video frame for ${mediaItem.name}:`,
@@ -214,9 +230,14 @@ export async function renderTimelineFrame({
);
const drawW = mediaW * containScale;
const drawH = mediaH * containScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.drawImage(img, drawX, drawY, drawW, drawH);
+ const mediaElement = element as MediaElement;
+ const flipH = !!mediaElement.flipH;
+ const flipV = !!mediaElement.flipV;
+ ctx.save();
+ ctx.translate(canvasWidth / 2, canvasHeight / 2);
+ ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
+ ctx.restore();
}
}
if (element.type === "text") {
@@ -276,4 +297,4 @@ export async function renderTimelineFrame({
ctx.restore();
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/transcription-utils.ts b/apps/web/src/lib/transcription-utils.ts
index 6388a3b6d..845206e08 100644
--- a/apps/web/src/lib/transcription-utils.ts
+++ b/apps/web/src/lib/transcription-utils.ts
@@ -1,13 +1,21 @@
-import { env } from "@/env";
-
-export function isTranscriptionConfigured() {
- const missingVars = [];
-
- if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID");
- if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID");
- if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY");
- if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME");
- if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL");
-
- return { configured: missingVars.length === 0, missingVars };
-}
+import { env } from "@/env";
+
+/**
+ * Checks whether all required transcription-related environment variables are present.
+ *
+ * Checks for CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME,
+ * and MODAL_TRANSCRIPTION_URL and reports which (if any) are missing.
+ *
+ * @returns An object with `configured` set to `true` if all required environment variables are present, `false` otherwise, and `missingVars` containing the names of any missing variables.
+ */
+export function isTranscriptionConfigured() {
+ const missingVars = [];
+
+ if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID");
+ if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID");
+ if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY");
+ if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME");
+ if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL");
+
+ return { configured: missingVars.length === 0, missingVars };
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/zk-encryption.ts b/apps/web/src/lib/zk-encryption.ts
index 587348074..c1a641523 100644
--- a/apps/web/src/lib/zk-encryption.ts
+++ b/apps/web/src/lib/zk-encryption.ts
@@ -1,71 +1,83 @@
-/**
- * True zero-knowledge encryption utilities
- * Keys are generated randomly in the browser and never derived from server secrets
- */
-
-export interface ZeroKnowledgeEncryptionResult {
- encryptedData: ArrayBuffer;
- key: ArrayBuffer;
- iv: ArrayBuffer;
-}
-
-/**
- * Encrypt data with a randomly generated key (true zero-knowledge)
- */
-export async function encryptWithRandomKey(
- data: ArrayBuffer
-): Promise {
- // Generate a truly random 256-bit key
- const key = crypto.getRandomValues(new Uint8Array(32));
-
- // Generate random IV
- const iv = crypto.getRandomValues(new Uint8Array(12));
-
- // Import the key for encryption
- const cryptoKey = await crypto.subtle.importKey(
- "raw",
- key,
- { name: "AES-GCM" },
- false,
- ["encrypt"]
- );
-
- // Encrypt the data
- const encryptedResult = await crypto.subtle.encrypt(
- { name: "AES-GCM", iv },
- cryptoKey,
- data
- );
-
- // For AES-GCM, we need to append the authentication tag
- // The encrypted result contains both ciphertext and tag
- return {
- encryptedData: encryptedResult,
- key: key.buffer,
- iv: iv.buffer,
- };
-}
-
-/**
- * Convert ArrayBuffer to base64 string for transmission
- */
-export function arrayBufferToBase64(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer);
- let binary = "";
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
-}
-
-/**
- * Convert base64 string back to ArrayBuffer
- */
-export function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes.buffer;
-}
+/**
+ * True zero-knowledge encryption utilities
+ * Keys are generated randomly in the browser and never derived from server secrets
+ */
+
+export interface ZeroKnowledgeEncryptionResult {
+ encryptedData: ArrayBuffer;
+ key: ArrayBuffer;
+ iv: ArrayBuffer;
+}
+
+/**
+ * Encrypts plaintext bytes using a freshly generated 256-bit AES-GCM key.
+ *
+ * @param data - The plaintext to encrypt as an ArrayBuffer
+ * @returns An object containing:
+ * - `encryptedData`: ciphertext including the AES-GCM authentication tag,
+ * - `key`: the raw 32-byte (256-bit) encryption key as an ArrayBuffer,
+ * - `iv`: the 12-byte initialization vector as an ArrayBuffer
+ */
+export async function encryptWithRandomKey(
+ data: ArrayBuffer
+): Promise {
+ // Generate a truly random 256-bit key
+ const key = crypto.getRandomValues(new Uint8Array(32));
+
+ // Generate random IV
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+
+ // Import the key for encryption
+ const cryptoKey = await crypto.subtle.importKey(
+ "raw",
+ key,
+ { name: "AES-GCM" },
+ false,
+ ["encrypt"]
+ );
+
+ // Encrypt the data
+ const encryptedResult = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ cryptoKey,
+ data
+ );
+
+ // For AES-GCM, we need to append the authentication tag
+ // The encrypted result contains both ciphertext and tag
+ return {
+ encryptedData: encryptedResult,
+ key: key.buffer,
+ iv: iv.buffer,
+ };
+}
+
+/**
+ * Encode an ArrayBuffer as a Base64 string.
+ *
+ * @param buffer - The binary data to encode
+ * @returns The Base64-encoded representation of `buffer`
+ */
+export function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+/**
+ * Decode a Base64-encoded string into an ArrayBuffer.
+ *
+ * @param base64 - The Base64-encoded input string
+ * @returns The decoded bytes as an ArrayBuffer
+ */
+export function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
\ No newline at end of file
diff --git a/apps/web/src/stores/text-properties-store.ts b/apps/web/src/stores/text-properties-store.ts
index b3ce3689f..77cea398b 100644
--- a/apps/web/src/stores/text-properties-store.ts
+++ b/apps/web/src/stores/text-properties-store.ts
@@ -1,33 +1,39 @@
-import { create } from "zustand";
-import { persist } from "zustand/middleware";
-
-export type TextPropertiesTab = "transform" | "style";
-
-export interface TextPropertiesTabMeta {
- value: TextPropertiesTab;
- label: string;
-}
-
-export const TEXT_PROPERTIES_TABS: ReadonlyArray = [
- { value: "transform", label: "Transform" },
- { value: "style", label: "Style" },
-] as const;
-
-export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
- return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
-}
-
-interface TextPropertiesState {
- activeTab: TextPropertiesTab;
- setActiveTab: (tab: TextPropertiesTab) => void;
-}
-
-export const useTextPropertiesStore = create()(
- persist(
- (set) => ({
- activeTab: "transform",
- setActiveTab: (tab) => set({ activeTab: tab }),
- }),
- { name: "text-properties" }
- )
-);
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export type TextPropertiesTab = "transform" | "style";
+
+export interface TextPropertiesTabMeta {
+ value: TextPropertiesTab;
+ label: string;
+}
+
+export const TEXT_PROPERTIES_TABS: ReadonlyArray = [
+ { value: "transform", label: "Transform" },
+ { value: "style", label: "Style" },
+] as const;
+
+/**
+ * Checks whether a string corresponds to a valid text properties tab.
+ *
+ * @param value - The string to test for membership in the known text properties tabs
+ * @returns `true` if `value` is a valid TextPropertiesTab, `false` otherwise
+ */
+export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
+ return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
+}
+
+interface TextPropertiesState {
+ activeTab: TextPropertiesTab;
+ setActiveTab: (tab: TextPropertiesTab) => void;
+}
+
+export const useTextPropertiesStore = create()(
+ persist(
+ (set) => ({
+ activeTab: "transform",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ }),
+ { name: "text-properties" }
+ )
+);
\ No newline at end of file