mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: move packages sub-folders into web app
This commit is contained in:
@@ -1,280 +1,280 @@
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } 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(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
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/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.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",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
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(transformFreesoundResult);
|
||||
|
||||
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 { webEnv } from "@/lib/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } 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(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
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/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.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",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
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(transformFreesoundResult);
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
|
||||
+1015
-1015
File diff suppressed because it is too large
Load Diff
@@ -1,369 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { BackgroundContent } from "./background";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
import { OcSquarePlusIcon } from "@opencut/ui/icons";
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
"1:1": "1:1",
|
||||
"16:9": "16:9",
|
||||
"9:16": "9:16",
|
||||
"4:3": "4:3",
|
||||
};
|
||||
|
||||
function areCanvasSizesEqual({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: TCanvasSize;
|
||||
right: TCanvasSize;
|
||||
}) {
|
||||
return left.width === right.width && left.height === right.height;
|
||||
}
|
||||
|
||||
function formatCanvasDimension({ value }: { value: number }) {
|
||||
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function parseCanvasDimension({ input }: { input: string }): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
|
||||
const rounded = Math.round(parsed);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function useCanvasDimensionDraft({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const pendingValueRef = useRef(value);
|
||||
const syncedValueRef = useRef(value);
|
||||
|
||||
if (syncedValueRef.current !== value) {
|
||||
syncedValueRef.current = value;
|
||||
pendingValueRef.current = value;
|
||||
}
|
||||
|
||||
return usePropertyDraft({
|
||||
displayValue: formatCanvasDimension({ value }),
|
||||
parse: (input) => parseCanvasDimension({ input }),
|
||||
onPreview: (nextValue) => {
|
||||
pendingValueRef.current = nextValue;
|
||||
},
|
||||
onCommit: () => {
|
||||
if (pendingValueRef.current !== value) {
|
||||
onCommit(pendingValueRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [view, setView] = useState<SettingsView>("project-info");
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
|
||||
const lastCustomCanvasSize =
|
||||
activeProject.settings.lastCustomCanvasSize ?? null;
|
||||
|
||||
const presetItems = canvasPresets.map((preset, index) => {
|
||||
const ratio = dimensionToAspectRatio(preset);
|
||||
return {
|
||||
id: index.toString(),
|
||||
label: PRESET_LABELS[ratio] ?? ratio,
|
||||
ratio,
|
||||
canvasSize: preset,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedPresetId = canvasSizeMode === "preset"
|
||||
? (presetItems.find((preset) =>
|
||||
areCanvasSizesEqual({
|
||||
left: preset.canvasSize,
|
||||
right: currentCanvasSize,
|
||||
}),
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const updateCustomCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateLastCustomCanvasSize =
|
||||
lastCustomCanvasSize === null ||
|
||||
!areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: lastCustomCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
|
||||
|
||||
if (
|
||||
!shouldUpdateCanvasSize &&
|
||||
!shouldUpdateLastCustomCanvasSize &&
|
||||
!shouldUpdateCanvasSizeMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "custom" as const }
|
||||
: {}),
|
||||
lastCustomCanvasSize: canvasSize,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectPresetCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
|
||||
|
||||
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "preset" as const }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectCustomCanvasSize = () => {
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
|
||||
});
|
||||
};
|
||||
|
||||
const widthDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.width,
|
||||
onCommit: (width) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width, height: currentCanvasSize.height },
|
||||
}),
|
||||
});
|
||||
|
||||
const heightDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.height,
|
||||
onCommit: (height) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width: currentCanvasSize.width, height },
|
||||
}),
|
||||
});
|
||||
|
||||
const isCustomSelected = canvasSizeMode === "custom";
|
||||
|
||||
return (
|
||||
<PanelView
|
||||
contentClassName="px-0"
|
||||
scrollClassName="pt-0"
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
{view === "project-info" && (
|
||||
<div className="flex flex-col">
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Name</SectionTitle>
|
||||
<span className="text-sm truncate">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader className="justify-between">
|
||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={(value) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section
|
||||
showTopBorder={false}
|
||||
collapsible
|
||||
sectionKey="settings:aspect-ratio"
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
|
||||
{presetItems.map((preset) => (
|
||||
<AspectRatioItem
|
||||
key={preset.id}
|
||||
label={preset.label}
|
||||
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onClick={() => {
|
||||
selectPresetCanvasSize({
|
||||
canvasSize: preset.canvasSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="pb-2">
|
||||
<AspectRatioItem
|
||||
key="custom"
|
||||
label="Custom"
|
||||
previewIcon={<OcSquarePlusIcon />}
|
||||
isSelected={isCustomSelected}
|
||||
onClick={selectCustomCanvasSize}
|
||||
uiOptions={
|
||||
<div className=" flex items-center gap-2 text-foreground">
|
||||
<NumberField
|
||||
value={widthDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas width"
|
||||
onFocus={widthDraft.onFocus}
|
||||
onChange={widthDraft.onChange}
|
||||
onBlur={widthDraft.onBlur}
|
||||
/>
|
||||
<NumberField
|
||||
value={heightDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas height"
|
||||
onFocus={heightDraft.onFocus}
|
||||
onChange={heightDraft.onChange}
|
||||
onBlur={heightDraft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{view === "background" && <BackgroundContent />}
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioItem({
|
||||
label,
|
||||
previewIcon,
|
||||
isSelected,
|
||||
onClick,
|
||||
uiOptions,
|
||||
}: {
|
||||
label: string;
|
||||
previewIcon: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
uiOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"px-2 py-0 flex flex-col h-fit w-full",
|
||||
!isSelected && "border border-transparent opacity-75!",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-full flex justify-between items-center h-8">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5">
|
||||
{previewIcon}
|
||||
</div>
|
||||
<span className="text-sm truncate">{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
|
||||
</div>
|
||||
</div>
|
||||
{uiOptions && isSelected && (
|
||||
<div className="w-full pb-2">{uiOptions}</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioPreview({ ratio }: { ratio?: string }) {
|
||||
if (!ratio) return null;
|
||||
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const maxSize = 16;
|
||||
const width = w >= h ? maxSize : (w / h) * maxSize;
|
||||
const height = h >= w ? maxSize : (h / w) * maxSize;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height, borderWidth: 1.5 }}
|
||||
className="rounded-xs border-current opacity-60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { BackgroundContent } from "./background";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
import { OcSquarePlusIcon } from "@/components/icons";
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
"1:1": "1:1",
|
||||
"16:9": "16:9",
|
||||
"9:16": "9:16",
|
||||
"4:3": "4:3",
|
||||
};
|
||||
|
||||
function areCanvasSizesEqual({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: TCanvasSize;
|
||||
right: TCanvasSize;
|
||||
}) {
|
||||
return left.width === right.width && left.height === right.height;
|
||||
}
|
||||
|
||||
function formatCanvasDimension({ value }: { value: number }) {
|
||||
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function parseCanvasDimension({ input }: { input: string }): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
|
||||
const rounded = Math.round(parsed);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function useCanvasDimensionDraft({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const pendingValueRef = useRef(value);
|
||||
const syncedValueRef = useRef(value);
|
||||
|
||||
if (syncedValueRef.current !== value) {
|
||||
syncedValueRef.current = value;
|
||||
pendingValueRef.current = value;
|
||||
}
|
||||
|
||||
return usePropertyDraft({
|
||||
displayValue: formatCanvasDimension({ value }),
|
||||
parse: (input) => parseCanvasDimension({ input }),
|
||||
onPreview: (nextValue) => {
|
||||
pendingValueRef.current = nextValue;
|
||||
},
|
||||
onCommit: () => {
|
||||
if (pendingValueRef.current !== value) {
|
||||
onCommit(pendingValueRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [view, setView] = useState<SettingsView>("project-info");
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
|
||||
const lastCustomCanvasSize =
|
||||
activeProject.settings.lastCustomCanvasSize ?? null;
|
||||
|
||||
const presetItems = canvasPresets.map((preset, index) => {
|
||||
const ratio = dimensionToAspectRatio(preset);
|
||||
return {
|
||||
id: index.toString(),
|
||||
label: PRESET_LABELS[ratio] ?? ratio,
|
||||
ratio,
|
||||
canvasSize: preset,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedPresetId = canvasSizeMode === "preset"
|
||||
? (presetItems.find((preset) =>
|
||||
areCanvasSizesEqual({
|
||||
left: preset.canvasSize,
|
||||
right: currentCanvasSize,
|
||||
}),
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const updateCustomCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateLastCustomCanvasSize =
|
||||
lastCustomCanvasSize === null ||
|
||||
!areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: lastCustomCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
|
||||
|
||||
if (
|
||||
!shouldUpdateCanvasSize &&
|
||||
!shouldUpdateLastCustomCanvasSize &&
|
||||
!shouldUpdateCanvasSizeMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "custom" as const }
|
||||
: {}),
|
||||
lastCustomCanvasSize: canvasSize,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectPresetCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
|
||||
|
||||
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "preset" as const }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectCustomCanvasSize = () => {
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
|
||||
});
|
||||
};
|
||||
|
||||
const widthDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.width,
|
||||
onCommit: (width) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width, height: currentCanvasSize.height },
|
||||
}),
|
||||
});
|
||||
|
||||
const heightDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.height,
|
||||
onCommit: (height) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width: currentCanvasSize.width, height },
|
||||
}),
|
||||
});
|
||||
|
||||
const isCustomSelected = canvasSizeMode === "custom";
|
||||
|
||||
return (
|
||||
<PanelView
|
||||
contentClassName="px-0"
|
||||
scrollClassName="pt-0"
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
{view === "project-info" && (
|
||||
<div className="flex flex-col">
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Name</SectionTitle>
|
||||
<span className="text-sm truncate">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader className="justify-between">
|
||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={(value) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section
|
||||
showTopBorder={false}
|
||||
collapsible
|
||||
sectionKey="settings:aspect-ratio"
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
|
||||
{presetItems.map((preset) => (
|
||||
<AspectRatioItem
|
||||
key={preset.id}
|
||||
label={preset.label}
|
||||
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onClick={() => {
|
||||
selectPresetCanvasSize({
|
||||
canvasSize: preset.canvasSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="pb-2">
|
||||
<AspectRatioItem
|
||||
key="custom"
|
||||
label="Custom"
|
||||
previewIcon={<OcSquarePlusIcon />}
|
||||
isSelected={isCustomSelected}
|
||||
onClick={selectCustomCanvasSize}
|
||||
uiOptions={
|
||||
<div className=" flex items-center gap-2 text-foreground">
|
||||
<NumberField
|
||||
value={widthDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas width"
|
||||
onFocus={widthDraft.onFocus}
|
||||
onChange={widthDraft.onChange}
|
||||
onBlur={widthDraft.onBlur}
|
||||
/>
|
||||
<NumberField
|
||||
value={heightDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas height"
|
||||
onFocus={heightDraft.onFocus}
|
||||
onChange={heightDraft.onChange}
|
||||
onBlur={heightDraft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{view === "background" && <BackgroundContent />}
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioItem({
|
||||
label,
|
||||
previewIcon,
|
||||
isSelected,
|
||||
onClick,
|
||||
uiOptions,
|
||||
}: {
|
||||
label: string;
|
||||
previewIcon: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
uiOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"px-2 py-0 flex flex-col h-fit w-full",
|
||||
!isSelected && "border border-transparent opacity-75!",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-full flex justify-between items-center h-8">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5">
|
||||
{previewIcon}
|
||||
</div>
|
||||
<span className="text-sm truncate">{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
|
||||
</div>
|
||||
</div>
|
||||
{uiOptions && isSelected && (
|
||||
<div className="w-full pb-2">{uiOptions}</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioPreview({ ratio }: { ratio?: string }) {
|
||||
if (!ratio) return null;
|
||||
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const maxSize = 16;
|
||||
const width = w >= h ? maxSize : (w / h) * maxSize;
|
||||
const height = h >= w ? maxSize : (h / w) * maxSize;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height, borderWidth: 1.5 }}
|
||||
className="rounded-xs border-current opacity-60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,303 +1,303 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
EffectElement,
|
||||
GraphicElement,
|
||||
ImageElement,
|
||||
MaskableElement,
|
||||
RetimableElement,
|
||||
StickerElement,
|
||||
TextElement,
|
||||
VisualElement,
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ArrowExpandIcon,
|
||||
RainDropIcon,
|
||||
MusicNote03Icon,
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "./tabs/transform-tab";
|
||||
import { BlendingTab } from "./tabs/blending-tab";
|
||||
import { AudioTab } from "./tabs/audio-tab";
|
||||
import { TextTab } from "./tabs/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
|
||||
import { MasksTab } from "./tabs/masks-tab";
|
||||
import { SpeedTab } from "./tabs/speed-tab";
|
||||
import { GraphicTab } from "./tabs/graphic-tab";
|
||||
import { OcShapesIcon } from "@opencut/ui/icons";
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
export type PropertiesTabDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
content: (props: TabContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export type ElementPropertiesConfig = {
|
||||
defaultTab: string;
|
||||
tabs: PropertiesTabDef[];
|
||||
};
|
||||
|
||||
function buildTransformTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "transform",
|
||||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlendingTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "blending",
|
||||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudioTab({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedTab({
|
||||
element,
|
||||
}: {
|
||||
element: RetimableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "speed",
|
||||
label: "Speed",
|
||||
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
|
||||
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasksTab({
|
||||
element,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "masks",
|
||||
label: "Masks",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClipEffectsTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<ClipEffectsTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
||||
return {
|
||||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGraphicTab({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "graphic",
|
||||
label: "Graphic",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandaloneEffectTab({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<StandaloneEffectTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextConfig({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "text",
|
||||
tabs: [
|
||||
buildTextTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getVideoConfig({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
mediaAsset: MediaAsset | undefined;
|
||||
}): ElementPropertiesConfig {
|
||||
const showAudioTab = mediaAsset?.hasAudio !== false;
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
...(showAudioTab ? [buildAudioTab({ element })] : []),
|
||||
buildSpeedTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getImageConfig({
|
||||
element,
|
||||
}: {
|
||||
element: ImageElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerConfig({
|
||||
element,
|
||||
}: {
|
||||
element: StickerElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getGraphicConfig({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "graphic",
|
||||
tabs: [
|
||||
buildGraphicTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getAudioConfig({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "audio",
|
||||
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
function getEffectConfig({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "effects",
|
||||
tabs: [buildStandaloneEffectTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPropertiesConfig({
|
||||
element,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementPropertiesConfig {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
return getTextConfig({ element });
|
||||
case "video": {
|
||||
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
|
||||
return getVideoConfig({ element, mediaAsset });
|
||||
}
|
||||
case "image":
|
||||
return getImageConfig({ element });
|
||||
case "sticker":
|
||||
return getStickerConfig({ element });
|
||||
case "graphic":
|
||||
return getGraphicConfig({ element });
|
||||
case "audio":
|
||||
return getAudioConfig({ element });
|
||||
case "effect":
|
||||
return getEffectConfig({ element });
|
||||
}
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
EffectElement,
|
||||
GraphicElement,
|
||||
ImageElement,
|
||||
MaskableElement,
|
||||
RetimableElement,
|
||||
StickerElement,
|
||||
TextElement,
|
||||
VisualElement,
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ArrowExpandIcon,
|
||||
RainDropIcon,
|
||||
MusicNote03Icon,
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "./tabs/transform-tab";
|
||||
import { BlendingTab } from "./tabs/blending-tab";
|
||||
import { AudioTab } from "./tabs/audio-tab";
|
||||
import { TextTab } from "./tabs/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
|
||||
import { MasksTab } from "./tabs/masks-tab";
|
||||
import { SpeedTab } from "./tabs/speed-tab";
|
||||
import { GraphicTab } from "./tabs/graphic-tab";
|
||||
import { OcShapesIcon } from "@/components/icons";
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
export type PropertiesTabDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
content: (props: TabContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export type ElementPropertiesConfig = {
|
||||
defaultTab: string;
|
||||
tabs: PropertiesTabDef[];
|
||||
};
|
||||
|
||||
function buildTransformTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "transform",
|
||||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlendingTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "blending",
|
||||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudioTab({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedTab({
|
||||
element,
|
||||
}: {
|
||||
element: RetimableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "speed",
|
||||
label: "Speed",
|
||||
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
|
||||
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasksTab({
|
||||
element,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "masks",
|
||||
label: "Masks",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClipEffectsTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<ClipEffectsTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
||||
return {
|
||||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGraphicTab({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "graphic",
|
||||
label: "Graphic",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandaloneEffectTab({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<StandaloneEffectTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextConfig({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "text",
|
||||
tabs: [
|
||||
buildTextTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getVideoConfig({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
mediaAsset: MediaAsset | undefined;
|
||||
}): ElementPropertiesConfig {
|
||||
const showAudioTab = mediaAsset?.hasAudio !== false;
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
...(showAudioTab ? [buildAudioTab({ element })] : []),
|
||||
buildSpeedTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getImageConfig({
|
||||
element,
|
||||
}: {
|
||||
element: ImageElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerConfig({
|
||||
element,
|
||||
}: {
|
||||
element: StickerElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getGraphicConfig({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "graphic",
|
||||
tabs: [
|
||||
buildGraphicTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getAudioConfig({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "audio",
|
||||
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
function getEffectConfig({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "effects",
|
||||
tabs: [buildStandaloneEffectTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPropertiesConfig({
|
||||
element,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementPropertiesConfig {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
return getTextConfig({ element });
|
||||
case "video": {
|
||||
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
|
||||
return getVideoConfig({ element, mediaAsset });
|
||||
}
|
||||
case "image":
|
||||
return getImageConfig({ element });
|
||||
case "sticker":
|
||||
return getStickerConfig({ element });
|
||||
case "graphic":
|
||||
return getGraphicConfig({ element });
|
||||
case "audio":
|
||||
return getAudioConfig({ element });
|
||||
case "effect":
|
||||
return getEffectConfig({ element });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,230 +1,230 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/lib/rendering";
|
||||
import type { ElementType } from "@/lib/timeline";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
|
||||
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!isPreviewActive) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const {
|
||||
onPointerLeave,
|
||||
onOpenChange: handleBlendModeOpenChange,
|
||||
markCommitted,
|
||||
} = useMenuPreview();
|
||||
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
markCommitted();
|
||||
};
|
||||
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:blending`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blending</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
opacity.commitValue({ value: DEFAULTS.element.opacity })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULTS.element.opacity,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Blend mode" className="w-1/2">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@/components/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/lib/rendering";
|
||||
import type { ElementType } from "@/lib/timeline";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
|
||||
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!isPreviewActive) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const {
|
||||
onPointerLeave,
|
||||
onOpenChange: handleBlendModeOpenChange,
|
||||
markCommitted,
|
||||
} = useMenuPreview();
|
||||
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
markCommitted();
|
||||
};
|
||||
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:blending`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blending</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
opacity.commitValue({ value: DEFAULTS.element.opacity })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULTS.element.opacity,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Blend mode" className="w-1/2">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ import {
|
||||
VolumeOffIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export function OcVercelIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="18"
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Vercel</title>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OcMarbleIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<title>Marble</title>
|
||||
<path fill="#202027" d="M0 0h256v256H0z" />
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M116.032 94.016q1.408.256 6.272 9.856 4.856 9.472 11.904 24.576l4.096 8.576a42.4 42.4 0 0 0 4.992-6.784q2.304-3.96 5.76-10.624 3.96-7.56 5.504-9.728 6.144-9.344 13.312-19.84.896-1.408 3.2-1.92a4.8 4.8 0 0 1 1.28-.128q3.072 0 6.784 2.56 3.84 2.56 4.992 5.248a4.8 4.8 0 0 1 .256 1.92q0 1.28-.128 1.92l-.64 2.944q-.256 1.664-.512 3.968a3200 3200 0 0 0-5.888 46.848 56 56 0 0 0-.512 8.064l-.256 4.096a32 32 0 0 0-.128 2.944q0 1.672-.384 2.304-.384.64-1.408.64-.768 0-2.304-.384-4.096-.896-6.272-3.2-2.176-2.432-2.432-6.656-.128-2.176-.128-6.784 0-4.736.256-14.464l.128-7.04.256-10.112q-1.152 1.024-5.888 8.064a328 328 0 0 0-9.088 14.464q-4.48 7.56-5.888 11.648-1.28 3.2-2.56 4.736-1.152 1.536-2.816 1.536-1.536 0-3.968-1.408-6.912-4.224-8.448-11.648a336 336 0 0 0-6.016-23.68 40 40 0 0 0-2.56-6.272q-1.792-3.968-2.304-4.992l-1.664-3.328q-18.68 24.832-25.344 45.568-1.28 4.352-2.432 6.272-1.152 1.792-2.688 1.792-2.432 0-6.912-4.352Q72 158.144 72 154.304q0-2.04.768-4.096a108 108 0 0 1 16-30.08l3.968-5.248a432 432 0 0 0 12.16-16.64q3.072-4.48 8.192-4.48.896 0 2.944.256"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OcDataBuddyIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 8 8"
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
<title>Data Buddy</title>
|
||||
<path d="M0 0h8v8H0z" />
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1 1h1v6H1zm1 0h4v1H2zm4 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zM2 6h4v1H2zm1-3h1v1H3zm1 1h1v1H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./brand";
|
||||
export * from "./ui";
|
||||
File diff suppressed because one or more lines are too long
@@ -1,69 +1,69 @@
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
export const SITE_INFO = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
url: SITE_URL,
|
||||
openGraphImage: "/open-graph/default.jpg",
|
||||
twitterImage: "/open-graph/default.jpg",
|
||||
favicon: "/favicon.ico",
|
||||
};
|
||||
|
||||
export type ExternalTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
icon: React.ElementType;
|
||||
};
|
||||
|
||||
export const EXTERNAL_TOOLS: ExternalTool[] = [
|
||||
{
|
||||
name: "Marble",
|
||||
description:
|
||||
"Modern headless CMS for content management and the blog for OpenCut",
|
||||
url: "https://marblecms.com?utm_source=opencut",
|
||||
icon: OcMarbleIcon,
|
||||
},
|
||||
{
|
||||
name: "Databuddy",
|
||||
description: "GDPR compliant analytics and user insights for OpenCut",
|
||||
url: "https://databuddy.cc?utm_source=opencut",
|
||||
icon: OcDataBuddyIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
x: "https://x.com/opencutapp",
|
||||
github: "https://github.com/OpenCut-app/OpenCut",
|
||||
discord: "https://discord.com/invite/Mu3acKZvCp",
|
||||
};
|
||||
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [
|
||||
{
|
||||
name: "Fal.ai",
|
||||
url: "https://fal.ai?utm_source=opencut",
|
||||
logo: "/logos/others/fal.svg",
|
||||
description: "Generative image, video, and audio models all in one place.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com?utm_source=opencut",
|
||||
logo: "/logos/others/vercel.svg",
|
||||
description: "Platform where we deploy and host OpenCut.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
];
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
export const SITE_INFO = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
url: SITE_URL,
|
||||
openGraphImage: "/open-graph/default.jpg",
|
||||
twitterImage: "/open-graph/default.jpg",
|
||||
favicon: "/favicon.ico",
|
||||
};
|
||||
|
||||
export type ExternalTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
icon: React.ElementType;
|
||||
};
|
||||
|
||||
export const EXTERNAL_TOOLS: ExternalTool[] = [
|
||||
{
|
||||
name: "Marble",
|
||||
description:
|
||||
"Modern headless CMS for content management and the blog for OpenCut",
|
||||
url: "https://marblecms.com?utm_source=opencut",
|
||||
icon: OcMarbleIcon,
|
||||
},
|
||||
{
|
||||
name: "Databuddy",
|
||||
description: "GDPR compliant analytics and user insights for OpenCut",
|
||||
url: "https://databuddy.cc?utm_source=opencut",
|
||||
icon: OcDataBuddyIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
x: "https://x.com/opencutapp",
|
||||
github: "https://github.com/OpenCut-app/OpenCut",
|
||||
discord: "https://discord.com/invite/Mu3acKZvCp",
|
||||
};
|
||||
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [
|
||||
{
|
||||
name: "Fal.ai",
|
||||
url: "https://fal.ai?utm_source=opencut",
|
||||
logo: "/logos/others/fal.svg",
|
||||
description: "Generative image, video, and audio models all in one place.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com?utm_source=opencut",
|
||||
logo: "/logos/others/vercel.svg",
|
||||
description: "Platform where we deploy and host OpenCut.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { betterAuth, type RateLimit } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { db } from "@/lib/db";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: webEnv.BETTER_AUTH_SECRET,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
rateLimit: {
|
||||
storage: "secondary-storage",
|
||||
customStorage: {
|
||||
get: async (key) => {
|
||||
const value = await redis.get(key);
|
||||
return value as RateLimit | undefined;
|
||||
},
|
||||
set: async (key, value) => {
|
||||
await redis.set(key, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
appName: "OpenCut",
|
||||
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
import { betterAuth, type RateLimit } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { db } from "@/lib/db";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: webEnv.BETTER_AUTH_SECRET,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
rateLimit: {
|
||||
storage: "secondary-storage",
|
||||
customStorage: {
|
||||
get: async (key) => {
|
||||
const value = await redis.get(key);
|
||||
return value as RateLimit | undefined;
|
||||
},
|
||||
set: async (key, value) => {
|
||||
await redis.set(key, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
appName: "OpenCut",
|
||||
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const webEnvSchema = z.object({
|
||||
// Node
|
||||
NODE_ENV: z.enum(["development", "production", "test"]),
|
||||
ANALYZE: z.string().optional(),
|
||||
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
|
||||
|
||||
// Public
|
||||
NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
|
||||
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
|
||||
|
||||
// Server
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.startsWith("postgres://")
|
||||
.or(z.string().startsWith("postgresql://")),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
UPSTASH_REDIS_REST_URL: z.url(),
|
||||
UPSTASH_REDIS_REST_TOKEN: z.string(),
|
||||
MARBLE_WORKSPACE_KEY: z.string(),
|
||||
FREESOUND_CLIENT_ID: z.string(),
|
||||
FREESOUND_API_KEY: z.string(),
|
||||
CLOUDFLARE_ACCOUNT_ID: z.string(),
|
||||
R2_ACCESS_KEY_ID: z.string(),
|
||||
R2_SECRET_ACCESS_KEY: z.string(),
|
||||
R2_BUCKET_NAME: z.string(),
|
||||
MODAL_TRANSCRIPTION_URL: z.url(),
|
||||
});
|
||||
|
||||
export type WebEnv = z.infer<typeof webEnvSchema>;
|
||||
|
||||
export const webEnv = webEnvSchema.parse(process.env);
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const baseRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
||||
analytics: true,
|
||||
prefix: "rate-limit",
|
||||
});
|
||||
|
||||
export async function checkRateLimit({ request }: { request: Request }) {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
return { success, limited: !success };
|
||||
}
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const baseRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
||||
analytics: true,
|
||||
prefix: "rate-limit",
|
||||
});
|
||||
|
||||
export async function checkRateLimit({ request }: { request: Request }) {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
return { success, limited: !success };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user