📝 Add docstrings to `feature/mirroring`
Docstrings generation was requested by @joepduin. * https://github.com/OpenCut-app/OpenCut/pull/638#issuecomment-3444277885 The following files were modified: * `apps/web/src/app/api/get-upload-url/route.ts` * `apps/web/src/app/api/sounds/search/route.ts` * `apps/web/src/app/api/transcribe/route.ts` * `apps/web/src/app/api/waitlist/export/route.ts` * `apps/web/src/components/editor/export-button.tsx` * `apps/web/src/components/editor/layout-guide-overlay.tsx` * `apps/web/src/components/editor/media-panel/tabbar.tsx` * `apps/web/src/components/editor/media-panel/views/captions.tsx` * `apps/web/src/components/editor/media-panel/views/media.tsx` * `apps/web/src/components/editor/media-panel/views/stickers.tsx` * `apps/web/src/components/editor/panel-base-view.tsx` * `apps/web/src/components/editor/panel-preset-selector.tsx` * `apps/web/src/components/editor/preview-panel.tsx` * `apps/web/src/components/editor/properties-panel/index.tsx` * `apps/web/src/components/editor/properties-panel/media-properties.tsx` * `apps/web/src/components/editor/properties-panel/text-properties.tsx` * `apps/web/src/components/editor/timeline/timeline-element.tsx` * `apps/web/src/components/editor/timeline/timeline-marker.tsx` * `apps/web/src/components/footer.tsx` * `apps/web/src/components/icons.tsx` * `apps/web/src/components/keyboard-shortcuts-help.tsx` * `apps/web/src/components/language-select.tsx` * `apps/web/src/components/theme-toggle.tsx` * `apps/web/src/components/ui/editable-timecode.tsx` * `apps/web/src/components/ui/font-picker.tsx` * `apps/web/src/components/ui/input-with-back.tsx` * `apps/web/src/hooks/use-edge-auto-scroll.ts` * `apps/web/src/hooks/use-frame-cache.ts` * `apps/web/src/hooks/use-highlight-scroll.ts` * `apps/web/src/hooks/use-infinite-scroll.ts` * `apps/web/src/hooks/use-sound-search.ts` * `apps/web/src/lib/editor-utils.ts` * `apps/web/src/lib/export.ts` * `apps/web/src/lib/iconify-api.ts` * `apps/web/src/lib/timeline-renderer.ts` * `apps/web/src/lib/transcription-utils.ts` * `apps/web/src/lib/zk-encryption.ts` * `apps/web/src/stores/text-properties-store.ts`
This commit is contained in:
parent
bee7aba960
commit
65cc22bf7f
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<TransitionUpIcon className="z-50" />
|
||||
<span className="text-[0.875rem] z-50">Export</span>
|
||||
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
|
||||
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
|
||||
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -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({
|
|||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-red-400">Export failed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -314,4 +324,4 @@ function ExportError({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import Image from "next/image";
|
||||
|
||||
function TikTokGuide() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<Image
|
||||
src="/platform-guides/tiktok-blueprint.png"
|
||||
alt="TikTok layout guide"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGuideOverlay() {
|
||||
const { layoutGuide } = useEditorStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
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 (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<Image
|
||||
src="/platform-guides/tiktok-blueprint.png"
|
||||
alt="TikTok layout guide"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex relative">
|
||||
<div
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
|
||||
>
|
||||
|
|
@ -78,20 +86,33 @@ export function TabBar() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<FadeOverlay direction="top" show={showTopFade} />
|
||||
<FadeOverlay direction="bottom" show={showBottomFade} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "top-0 bg-gradient-to-b from-panel to-transparent"
|
||||
? "top-0 bg-gradient-to-b from-panel to-transparent"
|
||||
: "bottom-0 bg-gradient-to-t from-panel to-transparent"
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex flex-col justify-between h-full"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
|
|
@ -300,4 +310,4 @@ export function Captions() {
|
|||
</div>
|
||||
</BaseView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
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" as any]: "96px",
|
||||
...(capSize ? ({ ["--sticker-max"]: "160px" } as any) : {}),
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{icons.map((iconName) => (
|
||||
<StickerItem
|
||||
key={iconName}
|
||||
|
|
@ -161,6 +175,14 @@ function EmptyView({ message }: { message: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the stickers panel for a given category, handling browsing, searching, collection views, and adding stickers to the timeline.
|
||||
*
|
||||
* This component coordinates sticker-related UI state and interactions (search input with debounce, collection listing and pagination, recent stickers, loading states, and adding a sticker to the timeline) and renders the appropriate subviews (recent, collection, search results, or browse collections) based on the current view mode.
|
||||
*
|
||||
* @param category - The sticker category to display (`"all" | "general" | "brands" | "emoji"`).
|
||||
* @returns The React element for the stickers content view.
|
||||
*/
|
||||
function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
|
@ -297,9 +319,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
setShowCollectionItems(false);
|
||||
const timer = setTimeout(() => setShowCollectionItems(true), 350);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowCollectionItems(false);
|
||||
}
|
||||
setShowCollectionItems(false);
|
||||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
|
|
@ -343,6 +364,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
|
||||
>
|
||||
|
|
@ -609,4 +631,4 @@ function StickerItem({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ViewContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
value,
|
||||
onValueChange,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: PanelBaseViewProps) {
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-3.5 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.icon ? (
|
||||
<span className="inline-flex items-center mr-1">
|
||||
{tab.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3.5" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ViewContent className={className}>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={cn("h-full flex flex-col", className)} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-3.5 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.icon ? (
|
||||
<span className="inline-flex items-center mr-1">
|
||||
{tab.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3.5" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ViewContent className={className}>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<PanelPreset, string> = {
|
||||
default: "Default",
|
||||
media: "Media",
|
||||
inspector: "Inspector",
|
||||
"vertical-preview": "Vertical Preview",
|
||||
};
|
||||
|
||||
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 px-2 flex items-center gap-1 text-xs"
|
||||
title="Panel Presets"
|
||||
>
|
||||
<LayoutPanelTop className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
Panel Presets
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onClick={() => handlePresetChange(preset)}
|
||||
className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PRESET_LABELS[preset]}
|
||||
</span>
|
||||
{activePreset === preset && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
|
||||
{PRESET_DESCRIPTIONS[preset]}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
|
||||
onClick={(e) => handleResetPreset(preset, e)}
|
||||
title={`Reset ${PRESET_LABELS[preset]} preset`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
"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<PanelPreset, string> = {
|
||||
default: "Default",
|
||||
media: "Media",
|
||||
inspector: "Inspector",
|
||||
"vertical-preview": "Vertical Preview",
|
||||
};
|
||||
|
||||
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 px-2 flex items-center gap-1 text-xs"
|
||||
title="Panel Presets"
|
||||
>
|
||||
<LayoutPanelTop className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
Panel Presets
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onClick={() => handlePresetChange(preset)}
|
||||
className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PRESET_LABELS[preset]}
|
||||
</span>
|
||||
{activePreset === preset && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
|
||||
{PRESET_DESCRIPTIONS[preset]}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
|
||||
onClick={(e) => handleResetPreset(preset, e)}
|
||||
title={`Reset ${PRESET_LABELS[preset]} preset`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,43 +513,7 @@ export function PreviewPanel() {
|
|||
mainCtx.putImageData(cachedFrame, 0, 0);
|
||||
|
||||
// Pre-render nearby frames in background
|
||||
if (!isPlaying) {
|
||||
// Only during scrubbing to avoid interfering with playback
|
||||
preRenderNearbyFrames(
|
||||
currentTime,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
async (time: number) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = displayWidth;
|
||||
tempCanvas.height = displayHeight;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
if (!tempCtx)
|
||||
throw new Error("Failed to create temp canvas context");
|
||||
|
||||
await renderTimelineFrame({
|
||||
ctx: tempCtx,
|
||||
time,
|
||||
canvasWidth: displayWidth,
|
||||
canvasHeight: displayHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? undefined
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
|
||||
},
|
||||
currentScene?.id,
|
||||
3
|
||||
);
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
// Small lookahead while playing
|
||||
preRenderNearbyFrames(
|
||||
currentTime,
|
||||
|
|
@ -574,6 +549,42 @@ export function PreviewPanel() {
|
|||
currentScene?.id,
|
||||
1
|
||||
);
|
||||
} else {
|
||||
// Only during scrubbing to avoid interfering with playback
|
||||
preRenderNearbyFrames(
|
||||
currentTime,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
async (time: number) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = displayWidth;
|
||||
tempCanvas.height = displayHeight;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
if (!tempCtx)
|
||||
throw new Error("Failed to create temp canvas context");
|
||||
|
||||
await renderTimelineFrame({
|
||||
ctx: tempCtx,
|
||||
time,
|
||||
canvasWidth: displayWidth,
|
||||
canvasHeight: displayHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? undefined
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
|
||||
},
|
||||
currentScene?.id,
|
||||
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({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div key={elementId}>
|
||||
<MediaProperties element={element} />
|
||||
<MediaProperties element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,4 +76,4 @@ function EmptyView() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <div className="space-y-4 p-5">Media properties</div>;
|
||||
}
|
||||
/**
|
||||
* 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 (
|
||||
<PanelBaseView
|
||||
tabs={[
|
||||
{
|
||||
value: "transform",
|
||||
label: "Transform",
|
||||
icon: <Sparkles className="size-4" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={element.flipH ? "default" : "outline"}
|
||||
onClick={toggleFlipH}
|
||||
aria-pressed={element.flipH ?? false}
|
||||
aria-label="Toggle horizontal mirror"
|
||||
>
|
||||
<FlipHorizontal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Toggle horizontal mirror
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={element.flipV ? "default" : "outline"}
|
||||
onClick={toggleFlipV}
|
||||
aria-pressed={element.flipV ?? false}
|
||||
aria-label="Toggle vertical mirror"
|
||||
>
|
||||
<FlipVertical className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Toggle vertical mirror
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" ? (
|
||||
<div className="space-y-6"></div>
|
||||
<div className="space-y-6" />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Textarea
|
||||
|
|
@ -367,4 +374,4 @@ export function TextProperties({
|
|||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
|||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`}
|
||||
className={
|
||||
"absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0"
|
||||
}
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
transform: `scale(${(element as MediaElement).flipH ? -1 : 1}, ${(element as MediaElement).flipV ? -1 : 1})`,
|
||||
transformOrigin: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||
|
|
@ -384,4 +401,4 @@ export function TimelineElement({
|
|||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-4",
|
||||
isMainMarker
|
||||
? "border-l border-muted-foreground/40"
|
||||
: "border-l border-muted-foreground/20"
|
||||
)}
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 left-1 text-[0.6rem]",
|
||||
isMainMarker
|
||||
? "text-muted-foreground font-medium"
|
||||
: "text-muted-foreground/70"
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
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);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"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 (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-4",
|
||||
isMainMarker
|
||||
? "border-l border-muted-foreground/40"
|
||||
: "border-l border-muted-foreground/20"
|
||||
)}
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 left-1 text-[0.6rem]",
|
||||
isMainMarker
|
||||
? "text-muted-foreground font-medium"
|
||||
: "text-muted-foreground/70"
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
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);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<motion.footer
|
||||
|
|
@ -19,10 +26,10 @@ export function Footer() {
|
|||
{/* Brand Section */}
|
||||
<div className="md:col-span-1 max-w-sm">
|
||||
<div className="flex justify-start items-center gap-2 mb-4">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="OpenCut"
|
||||
width={24}
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="OpenCut"
|
||||
width={24}
|
||||
height={24}
|
||||
className="invert dark:invert-0"
|
||||
/>
|
||||
|
|
@ -127,4 +134,4 @@ export function Footer() {
|
|||
</div>
|
||||
</motion.footer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
<g opacity="0.5">
|
||||
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" fill="#FFFC00"/>
|
||||
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" fill="white"/>
|
||||
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" stroke="black" strokeWidth="7"/>
|
||||
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" stroke="black" strokeWidth="7"/>
|
||||
<path
|
||||
d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z"
|
||||
fill="#FFFC00"
|
||||
/>
|
||||
<path
|
||||
d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z"
|
||||
stroke="black"
|
||||
strokeWidth="7"
|
||||
/>
|
||||
<path
|
||||
d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z"
|
||||
stroke="black"
|
||||
strokeWidth="7"
|
||||
/>
|
||||
</g>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M133.5 4H321.5C334.48 4 345 14.5205 345 27.5V215.5C345 228.48 334.48 239 321.5 239H133.5C120.52 239 110 228.48 110 215.5V27.5C110 14.5205 120.52 4 133.5 4Z" fill="#010101"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M261.497 99.4086C271.784 106.758 284.388 111.083 297.999 111.083V84.9035C295.423 84.9045 292.854 84.6357 290.333 84.1016V104.709C276.723 104.709 264.121 100.384 253.831 93.0345V146.459C253.831 173.185 232.155 194.85 205.417 194.85C195.44 194.85 186.167 191.835 178.464 186.665C187.256 195.65 199.516 201.224 213.08 201.224C239.821 201.224 261.498 179.56 261.498 152.833L261.497 99.4086ZM270.953 72.9965C265.696 67.2559 262.244 59.8365 261.497 51.634V48.267H254.233C256.061 58.6916 262.298 67.5981 270.953 72.9965ZM195.376 166.157C192.438 162.308 190.851 157.598 190.858 152.756C190.858 140.533 200.773 130.622 213.005 130.622C215.285 130.621 217.551 130.97 219.723 131.659V104.894C217.185 104.547 214.622 104.399 212.061 104.454V125.286C209.887 124.597 207.62 124.247 205.34 124.249C193.107 124.249 183.193 134.159 183.193 146.384C183.193 155.027 188.149 162.512 195.376 166.157Z" fill="#EE1D52"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M253.831 93.0345C264.121 100.384 276.723 104.709 290.333 104.709V84.1016C282.736 82.4848 276.011 78.5162 270.953 72.9965C262.298 67.5981 256.061 58.6916 254.233 48.267H235.152V152.832C235.108 165.022 225.21 174.892 213.004 174.892C205.811 174.892 199.422 171.465 195.376 166.157C188.149 162.512 183.193 155.027 183.193 146.384C183.193 134.159 193.107 124.249 205.34 124.249C207.683 124.249 209.942 124.614 212.061 125.286V104.454C185.792 104.996 164.665 126.449 164.665 152.833C164.665 166.003 169.926 177.942 178.464 186.665C186.167 191.835 195.44 194.85 205.417 194.85C232.155 194.85 253.831 173.185 253.831 146.459V93.0345Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M290.333 84.1016V78.5293C283.482 78.5396 276.766 76.6229 270.953 72.9965C276.099 78.6269 282.874 82.5087 290.333 84.1016ZM254.233 48.267C254.058 47.2708 253.924 46.2679 253.831 45.2608V41.8938H227.485V146.459C227.443 158.648 217.545 168.518 205.339 168.518C201.754 168.518 198.372 167.669 195.376 166.157C199.422 171.465 205.811 174.892 213.004 174.892C225.21 174.892 235.108 165.022 235.152 152.832V48.267H254.233ZM212.061 104.454L212.061 98.5212C209.859 98.2202 207.64 98.0698 205.418 98.071C178.676 98.071 157 119.736 157 146.459C157 163.214 165.518 177.979 178.464 186.665C169.926 177.942 164.666 166.002 164.666 152.832C164.666 126.449 185.791 104.996 212.061 104.454Z" fill="#69C9D0"/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M133.5 4H321.5C334.48 4 345 14.5205 345 27.5V215.5C345 228.48 334.48 239 321.5 239H133.5C120.52 239 110 228.48 110 215.5V27.5C110 14.5205 120.52 4 133.5 4Z"
|
||||
fill="#010101"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M261.497 99.4086C271.784 106.758 284.388 111.083 297.999 111.083V84.9035C295.423 84.9045 292.854 84.6357 290.333 84.1016V104.709C276.723 104.709 264.121 100.384 253.831 93.0345V146.459C253.831 173.185 232.155 194.85 205.417 194.85C195.44 194.85 186.167 191.835 178.464 186.665C187.256 195.65 199.516 201.224 213.08 201.224C239.821 201.224 261.498 179.56 261.498 152.833L261.497 99.4086ZM270.953 72.9965C265.696 67.2559 262.244 59.8365 261.497 51.634V48.267H254.233C256.061 58.6916 262.298 67.5981 270.953 72.9965ZM195.376 166.157C192.438 162.308 190.851 157.598 190.858 152.756C190.858 140.533 200.773 130.622 213.005 130.622C215.285 130.621 217.551 130.97 219.723 131.659V104.894C217.185 104.547 214.622 104.399 212.061 104.454V125.286C209.887 124.597 207.62 124.247 205.34 124.249C193.107 124.249 183.193 134.159 183.193 146.384C183.193 155.027 188.149 162.512 195.376 166.157Z"
|
||||
fill="#EE1D52"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M253.831 93.0345C264.121 100.384 276.723 104.709 290.333 104.709V84.1016C282.736 82.4848 276.011 78.5162 270.953 72.9965C262.298 67.5981 256.061 58.6916 254.233 48.267H235.152V152.832C235.108 165.022 225.21 174.892 213.004 174.892C205.811 174.892 199.422 171.465 195.376 166.157C188.149 162.512 183.193 155.027 183.193 146.384C183.193 134.159 193.107 124.249 205.34 124.249C207.683 124.249 209.942 124.614 212.061 125.286V104.454C185.792 104.996 164.665 126.449 164.665 152.833C164.665 166.003 169.926 177.942 178.464 186.665C186.167 191.835 195.44 194.85 205.417 194.85C232.155 194.85 253.831 173.185 253.831 146.459V93.0345Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M290.333 84.1016V78.5293C283.482 78.5396 276.766 76.6229 270.953 72.9965C276.099 78.6269 282.874 82.5087 290.333 84.1016ZM254.233 48.267C254.058 47.2708 253.924 46.2679 253.831 45.2608V41.8938H227.485V146.459C227.443 158.648 217.545 168.518 205.339 168.518C201.754 168.518 198.372 167.669 195.376 166.157C199.422 171.465 205.811 174.892 213.004 174.892C225.21 174.892 235.108 165.022 235.152 152.832V48.267H254.233ZM212.061 104.454L212.061 98.5212C209.859 98.2202 207.64 98.0698 205.418 98.071C178.676 98.071 157 119.736 157 146.459C157 163.214 165.518 177.979 178.464 186.665C169.926 177.942 164.666 166.002 164.666 152.832C164.666 126.449 185.791 104.996 212.061 104.454Z"
|
||||
fill="#69C9D0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>;
|
||||
languages: Language[];
|
||||
}
|
||||
|
||||
function FlagPreloader({ languages }: { languages: Language[] }) {
|
||||
return (
|
||||
<div className="absolute -top-[9999px] left-0 pointer-events-none">
|
||||
{languages.map((language) => (
|
||||
<ReactCountryFlag
|
||||
key={language.code}
|
||||
countryCode={language.code}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>(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<HTMLButtonElement>;
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect(code);
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-9">
|
||||
<FlagPreloader languages={languages} />
|
||||
<motion.button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute w-full h-full flex flex-col overflow-hidden items-start justify-between z-10 rounded-lg px-3 cursor-pointer",
|
||||
"!bg-foreground/10 backdrop-blur-md text-foreground py-0",
|
||||
"transition-[color,box-shadow] focus:border-ring focus:ring-ring/50 focus:ring-[1px]"
|
||||
)}
|
||||
initial={{
|
||||
height: collapsedHeight,
|
||||
scale: 1,
|
||||
}}
|
||||
animate={{
|
||||
height: expanded ? expandHeight : collapsedHeight,
|
||||
scale: isTapping ? [1, 0.985, 1] : 1,
|
||||
}}
|
||||
transition={{
|
||||
height: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },
|
||||
scale: { duration: 0.6, ease: "easeOut" },
|
||||
}}
|
||||
onClick={expand}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{!expanded ? (
|
||||
<div
|
||||
className="flex items-center justify-between w-full"
|
||||
style={{
|
||||
height: collapsedHeight,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCountry === "auto" ? (
|
||||
<Globe className="!size-[1.05rem]" />
|
||||
) : (
|
||||
<ReactCountryFlag
|
||||
countryCode={selectedCountry}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
)}
|
||||
<span className="pt-[0.05rem]">
|
||||
{selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 my-2.5 w-full overflow-y-auto scrollbar-hidden">
|
||||
<LanguageButton
|
||||
language={{ code: "auto", name: "Auto" }}
|
||||
onSelect={handleSelect}
|
||||
selectedCountry={selectedCountry}
|
||||
/>
|
||||
{languages.map((language) => (
|
||||
<LanguageButton
|
||||
key={language.code}
|
||||
language={language}
|
||||
onSelect={handleSelect}
|
||||
selectedCountry={selectedCountry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.div
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none z-20 mt-0.5"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: expanded ? 0 : 1 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground size-4" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageButton({
|
||||
language,
|
||||
onSelect,
|
||||
selectedCountry,
|
||||
}: {
|
||||
language: Language;
|
||||
onSelect: ({
|
||||
code,
|
||||
e,
|
||||
}: {
|
||||
code: string;
|
||||
e: React.MouseEvent<HTMLButtonElement>;
|
||||
}) => void;
|
||||
selectedCountry: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 cursor-pointer text-foreground hover:text-foreground/75"
|
||||
onClick={(e) => onSelect({ code: language.code, e })}
|
||||
>
|
||||
{language.code === "auto" ? (
|
||||
<Globe className="!size-[1.0rem]" />
|
||||
) : (
|
||||
<ReactCountryFlag
|
||||
countryCode={language.code}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
)}
|
||||
<span className="pt-[0.05rem]">{language.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
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<HTMLDivElement>;
|
||||
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 (
|
||||
<div className="absolute -top-[9999px] left-0 pointer-events-none">
|
||||
{languages.map((language) => (
|
||||
<ReactCountryFlag
|
||||
key={language.code}
|
||||
countryCode={language.code}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLButtonElement>(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<HTMLButtonElement>;
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect(code);
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-9">
|
||||
<FlagPreloader languages={languages} />
|
||||
<motion.button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute w-full h-full flex flex-col overflow-hidden items-start justify-between z-10 rounded-lg px-3 cursor-pointer",
|
||||
"!bg-foreground/10 backdrop-blur-md text-foreground py-0",
|
||||
"transition-[color,box-shadow] focus:border-ring focus:ring-ring/50 focus:ring-[1px]"
|
||||
)}
|
||||
initial={{
|
||||
height: collapsedHeight,
|
||||
scale: 1,
|
||||
}}
|
||||
animate={{
|
||||
height: expanded ? expandHeight : collapsedHeight,
|
||||
scale: isTapping ? [1, 0.985, 1] : 1,
|
||||
}}
|
||||
transition={{
|
||||
height: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },
|
||||
scale: { duration: 0.6, ease: "easeOut" },
|
||||
}}
|
||||
onClick={expand}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{expanded ? (
|
||||
<div className="flex flex-col gap-2 my-2.5 w-full overflow-y-auto scrollbar-hidden">
|
||||
<LanguageButton
|
||||
language={{ code: "auto", name: "Auto" }}
|
||||
onSelect={handleSelect}
|
||||
selectedCountry={selectedCountry}
|
||||
/>
|
||||
{languages.map((language) => (
|
||||
<LanguageButton
|
||||
key={language.code}
|
||||
language={language}
|
||||
onSelect={handleSelect}
|
||||
selectedCountry={selectedCountry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center justify-between w-full"
|
||||
style={{
|
||||
height: collapsedHeight,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCountry === "auto" ? (
|
||||
<Globe className="!size-[1.05rem]" />
|
||||
) : (
|
||||
<ReactCountryFlag
|
||||
countryCode={selectedCountry}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
)}
|
||||
<span className="pt-[0.05rem]">
|
||||
{selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.div
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none z-20 mt-0.5"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: expanded ? 0 : 1 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground size-4" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLButtonElement>;
|
||||
}) => void;
|
||||
selectedCountry: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 cursor-pointer text-foreground hover:text-foreground/75"
|
||||
onClick={(e) => onSelect({ code: language.code, e })}
|
||||
>
|
||||
{language.code === "auto" ? (
|
||||
<Globe className="!size-[1.0rem]" />
|
||||
) : (
|
||||
<ReactCountryFlag
|
||||
countryCode={language.code}
|
||||
svg
|
||||
style={{ width: "1.05rem", height: "1.05rem" }}
|
||||
/>
|
||||
)}
|
||||
<span className="pt-[0.05rem]">{language.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
className="h-7"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="!size-[1.1rem]" />
|
||||
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
"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 (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
className="h-7"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="!size-[1.1rem]" />
|
||||
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"text-xs font-mono bg-transparent border-none outline-none",
|
||||
"focus:bg-background focus:border focus:border-primary focus:px-1 focus:rounded",
|
||||
"tabular-nums text-primary",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className
|
||||
)}
|
||||
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||
placeholder={formattedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEditing}
|
||||
className={cn(
|
||||
"text-xs font-mono tabular-nums text-primary cursor-pointer",
|
||||
"hover:bg-muted/50 hover:rounded px-1 -mx-1 transition-colors",
|
||||
disabled && "cursor-default hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
title={disabled ? undefined : "Click to edit time"}
|
||||
>
|
||||
{formattedTime}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
"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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"text-xs font-mono bg-transparent border-none outline-none",
|
||||
"focus:bg-background focus:border focus:border-primary focus:px-1 focus:rounded",
|
||||
"tabular-nums text-primary",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className
|
||||
)}
|
||||
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||
placeholder={formattedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEditing}
|
||||
className={cn(
|
||||
"text-xs font-mono tabular-nums text-primary cursor-pointer",
|
||||
"hover:bg-muted/50 hover:rounded px-1 -mx-1 transition-colors",
|
||||
disabled && "cursor-default hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
title={disabled ? undefined : "Click to edit time"}
|
||||
>
|
||||
{formattedTime}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||
<SelectTrigger className={`w-full bg-panel-accent h-8 text-xs ${className || ""}`}>
|
||||
<SelectTrigger
|
||||
className={`w-full bg-panel-accent h-8 text-xs ${className || ""}`}
|
||||
>
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -37,4 +47,4 @@ export function FontPicker({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<div ref={setContainerRef} className="relative w-full">
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-75 transition-opacity z-10"
|
||||
initial={{
|
||||
x: isExpanded ? 0 : buttonOffset,
|
||||
opacity: isExpanded ? 1 : 0.5,
|
||||
}}
|
||||
animate={{
|
||||
x: isExpanded ? 0 : buttonOffset,
|
||||
opacity: isExpanded ? 1 : 0.5,
|
||||
}}
|
||||
transition={smoothTransition}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!size-9 rounded-full bg-panel-accent"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
style={{ marginLeft: "0px", paddingLeft: "0px" }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{
|
||||
marginLeft: isExpanded ? 50 : 0,
|
||||
}}
|
||||
animate={{
|
||||
marginLeft: isExpanded ? 50 : 0,
|
||||
}}
|
||||
transition={smoothTransition}
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-9 bg-panel-accent w-full"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"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<HTMLDivElement | null>(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 (
|
||||
<div ref={setContainerRef} className="relative w-full">
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-75 transition-opacity z-10"
|
||||
initial={{
|
||||
x: isExpanded ? 0 : buttonOffset,
|
||||
opacity: isExpanded ? 1 : 0.5,
|
||||
}}
|
||||
animate={{
|
||||
x: isExpanded ? 0 : buttonOffset,
|
||||
opacity: isExpanded ? 1 : 0.5,
|
||||
}}
|
||||
transition={smoothTransition}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!size-9 rounded-full bg-panel-accent"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
style={{ marginLeft: "0px", paddingLeft: "0px" }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{
|
||||
marginLeft: isExpanded ? 50 : 0,
|
||||
}}
|
||||
animate={{
|
||||
marginLeft: isExpanded ? 50 : 0,
|
||||
}}
|
||||
transition={smoothTransition}
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-9 bg-panel-accent w-full"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +1,116 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseEdgeAutoScrollParams {
|
||||
isActive: boolean;
|
||||
getMouseClientX: () => number;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
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<number | null>(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<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
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<number | null>(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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ const __sharedFrameCache: Map<number, CachedFrame> =
|
|||
__frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>();
|
||||
__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<ImageData>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,44 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function useHighlightScroll(
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
highlightDuration = 1000
|
||||
) {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(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<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(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 };
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<AudioBuffer | null> {
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IconSearchResult> {
|
||||
|
|
@ -239,4 +247,4 @@ export function getCategoriesFromCollections(
|
|||
}
|
||||
});
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<ZeroKnowledgeEncryptionResult> {
|
||||
// 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<ZeroKnowledgeEncryptionResult> {
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -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<TextPropertiesTabMeta> = [
|
||||
{ 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<TextPropertiesState>()(
|
||||
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<TextPropertiesTabMeta> = [
|
||||
{ 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<TextPropertiesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "transform",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" }
|
||||
)
|
||||
);
|
||||
Loading…
Reference in New Issue