summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/components/editor/panels/assets
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/components/editor/panels/assets')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx516
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx67
2 files changed, 272 insertions, 311 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 4c6922ff..8814dd4d 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import {
+ type MediaSortKey,
+ type MediaSortOrder,
+ type MediaViewMode,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import { cn } from "@/utils/ui";
import {
@@ -50,8 +55,15 @@ export function MediaView() {
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
- const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
- useAssetsPanelStore();
+ const {
+ mediaViewMode,
+ setMediaViewMode,
+ highlightMediaId,
+ clearHighlight,
+ mediaSortBy,
+ mediaSortOrder,
+ setMediaSort,
+ } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
@@ -59,10 +71,6 @@ export function MediaView() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
- "name",
- );
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const processFiles = async ({ files }: { files: FileList }) => {
if (!files || files.length === 0) return;
@@ -121,27 +129,12 @@ export function MediaView() {
});
};
- const addElementAtTime = ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }): boolean => {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const element = buildElementFromMedia({
- mediaId: asset.id,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- });
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- return true;
+ const handleSort = ({ key }: { key: SortKey }) => {
+ if (mediaSortBy === key) {
+ setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setMediaSort(key, "asc");
+ }
};
const filteredMediaItems = useMemo(() => {
@@ -151,7 +144,7 @@ export function MediaView() {
let valueA: string | number;
let valueB: string | number;
- switch (sortBy) {
+ switch (mediaSortBy) {
case "name":
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
@@ -172,154 +165,13 @@ export function MediaView() {
return 0;
}
- if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
- if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
+ if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1;
+ if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
- }, [mediaFiles, sortBy, sortOrder]);
-
- const previewComponents = useMemo(() => {
- const previews = new Map<string, React.ReactNode>();
-
- filteredMediaItems.forEach((item) => {
- previews.set(item.id, <MediaPreview item={item} />);
- previews.set(
- `compact-${item.id}`,
- <MediaPreview item={item} variant="compact" />,
- );
- });
-
- return previews;
- }, [filteredMediaItems]);
-
- const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
- const renderCompactPreview = (item: MediaAsset) =>
- previewComponents.get(`compact-${item.id}`);
-
- const mediaActions = (
- <div>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- size="icon"
- variant="ghost"
- onClick={() =>
- setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
- }
- disabled={isProcessing}
- className="items-center justify-center"
- >
- {mediaViewMode === "grid" ? (
- <HugeiconsIcon icon={LeftToRightListDashIcon} />
- ) : (
- <HugeiconsIcon icon={GridViewIcon} />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>
- {mediaViewMode === "grid"
- ? "Switch to list view"
- : "Switch to grid view"}
- </p>
- </TooltipContent>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="ghost"
- disabled={isProcessing}
- className="items-center justify-center"
- >
- <HugeiconsIcon icon={SortingOneNineIcon} />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <SortMenuItem
- label="Name"
- sortKey="name"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Type"
- sortKey="type"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Duration"
- sortKey="duration"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="File size"
- sortKey="size"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- </DropdownMenuContent>
- </DropdownMenu>
- <TooltipContent>
- <p>
- Sort by {sortBy} (
- {sortOrder === "asc" ? "ascending" : "descending"})
- </p>
- </TooltipContent>
- </Tooltip>
- </Tooltip>
- </TooltipProvider>
- <Button
- variant="outline"
- onClick={openFilePicker}
- disabled={isProcessing}
- size="sm"
- className="items-center justify-center gap-1.5 ml-1.5"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- );
+ }, [mediaFiles, mediaSortBy, mediaSortOrder]);
return (
<>
@@ -327,8 +179,18 @@ export function MediaView() {
<PanelView
title="Assets"
- actions={mediaActions}
- className={isDragOver ? "bg-accent/30" : ""}
+ actions={
+ <MediaActions
+ mediaViewMode={mediaViewMode}
+ setMediaViewMode={setMediaViewMode}
+ isProcessing={isProcessing}
+ sortBy={mediaSortBy}
+ sortOrder={mediaSortOrder}
+ onSort={handleSort}
+ onImport={openFilePicker}
+ />
+ }
+ className={cn(isDragOver && "bg-accent/30")}
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -338,21 +200,11 @@ export function MediaView() {
progress={progress}
onClick={openFilePicker}
/>
- ) : mediaViewMode === "grid" ? (
- <GridView
- items={filteredMediaItems}
- renderPreview={renderPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
) : (
- <ListView
+ <MediaItemList
items={filteredMediaItems}
- renderPreview={renderCompactPreview}
+ mode={mediaViewMode}
onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
@@ -362,6 +214,67 @@ export function MediaView() {
);
}
+function MediaAssetDraggable({
+ item,
+ preview,
+ isHighlighted,
+ variant,
+ isRounded,
+}: {
+ item: MediaAsset;
+ preview: React.ReactNode;
+ isHighlighted: boolean;
+ variant: "card" | "compact";
+ isRounded?: boolean;
+}) {
+ const editor = useEditor();
+
+ const addElementAtTime = ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => {
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: asset.id,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <DraggableItem
+ name={item.name}
+ preview={preview}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ addElementAtTime({ asset: item, startTime: currentTime })
+ }
+ variant={variant}
+ isRounded={isRounded}
+ isHighlighted={isHighlighted}
+ />
+ );
+}
+
function MediaItemWithContextMenu({
item,
children,
@@ -387,107 +300,41 @@ function MediaItemWithContextMenu({
);
}
-function GridView({
+function MediaItemList({
items,
- renderPreview,
+ mode,
onRemove,
- onAddToTimeline,
highlightedId,
registerElement,
}: {
items: MediaAsset[];
- renderPreview: (item: MediaAsset) => React.ReactNode;
+ mode: MediaViewMode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- onAddToTimeline: ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const isGrid = mode === "grid";
+
return (
<div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
+ className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")}
+ style={
+ isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined
+ }
>
{items.map((item) => (
- <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <div key={item.id} ref={(element) => registerElement(item.id, element)}>
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
- <DraggableItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: "media",
- mediaType: item.type,
- name: item.name,
- ...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
- }),
- }}
- shouldShowPlusOnDrag={false}
- onAddToTimeline={({ currentTime }) =>
- onAddToTimeline({ asset: item, startTime: currentTime })
- }
- isRounded={false}
- variant="card"
- isHighlighted={highlightedId === item.id}
- />
- </MediaItemWithContextMenu>
- </div>
- ))}
- </div>
- );
-}
-
-function ListView({
- items,
- renderPreview,
- onRemove,
- onAddToTimeline,
- highlightedId,
- registerElement,
-}: {
- items: MediaAsset[];
- renderPreview: (item: MediaAsset) => React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- onAddToTimeline: ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }) => boolean;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
-}) {
- return (
- <div className="space-y-1">
- {items.map((item) => (
- <div key={item.id} ref={(el) => registerElement(item.id, el)}>
- <MediaItemWithContextMenu item={item} onRemove={onRemove}>
- <DraggableItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: "media",
- mediaType: item.type,
- name: item.name,
- ...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
- }),
- }}
- shouldShowPlusOnDrag={false}
- onAddToTimeline={({ currentTime }) =>
- onAddToTimeline({ asset: item, startTime: currentTime })
+ <MediaAssetDraggable
+ item={item}
+ preview={
+ <MediaPreview
+ item={item}
+ variant={isGrid ? "grid" : "compact"}
+ />
}
- variant="compact"
+ variant={isGrid ? "card" : "compact"}
+ isRounded={isGrid ? false : undefined}
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
@@ -497,11 +344,11 @@ function ListView({
);
}
-const formatDuration = ({ duration }: { duration: number }) => {
+export function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
-};
+}
function MediaDurationBadge({ duration }: { duration?: number }) {
if (!duration) return null;
@@ -619,6 +466,119 @@ function MediaPreview({
);
}
+function MediaActions({
+ mediaViewMode,
+ setMediaViewMode,
+ isProcessing,
+ sortBy,
+ sortOrder,
+ onSort,
+ onImport,
+}: {
+ mediaViewMode: MediaViewMode;
+ setMediaViewMode: (mode: MediaViewMode) => void;
+ isProcessing: boolean;
+ sortBy: MediaSortKey;
+ sortOrder: MediaSortOrder;
+ onSort: ({ key }: { key: MediaSortKey }) => void;
+ onImport: () => void;
+}) {
+ return (
+ <div className="flex gap-1.5">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ onClick={() =>
+ setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <DropdownMenu>
+ <TooltipTrigger asChild>
+ <DropdownMenuTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ <HugeiconsIcon icon={SortingOneNineIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={onImport}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Import
+ </Button>
+ </div>
+ );
+}
+
function SortMenuItem({
label,
sortKey,
@@ -627,10 +587,10 @@ function SortMenuItem({
onSort,
}: {
label: string;
- sortKey: "name" | "type" | "duration" | "size";
- currentSortBy: string;
- currentSortOrder: "asc" | "desc";
- onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
+ sortKey: MediaSortKey;
+ currentSortBy: MediaSortKey;
+ currentSortOrder: MediaSortOrder;
+ onSort: ({ key }: { key: MediaSortKey }) => void;
}) {
const isActive = currentSortBy === sortKey;
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
index 7e10ac9d..d46e9d1f 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -16,6 +16,7 @@ import {
Section,
SectionContent,
SectionHeader,
+ SectionTitle,
} from "@/components/editor/panels/properties/section";
import { Label } from "@/components/ui/label";
import {
@@ -23,25 +24,46 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { useState } from "react";
-export function SettingsView() {
- const [open, setOpen] = useState(false);
+const ORIGINAL_PRESET_VALUE = "original";
+export function findPresetIndexByAspectRatio({
+ presets,
+ targetAspectRatio,
+}: {
+ presets: Array<{ width: number; height: number }>;
+ targetAspectRatio: string;
+}) {
+ for (let index = 0; index < presets.length; index++) {
+ const preset = presets[index];
+ const presetAspectRatio = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ if (presetAspectRatio === targetAspectRatio) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+export function SettingsView() {
return (
<PanelView contentClassName="px-0" hideHeader>
<div className="flex flex-col">
- <Section hasBorderTop={false}>
+ <Section showTopBorder={false}>
<SectionContent>
<ProjectInfoContent />
</SectionContent>
</Section>
- <Popover open={open} onOpenChange={setOpen}>
+ <Popover>
<Section className="cursor-pointer">
<PopoverTrigger asChild>
<div>
- <SectionHeader title="Background">
- <div className="size-4 rounded-sm bg-red-500" />
+ <SectionHeader
+ trailing={<div className="size-4 rounded-sm bg-red-500" />}
+ >
+ <SectionTitle>Background</SectionTitle>
</SectionHeader>
</div>
</PopoverTrigger>
@@ -60,26 +82,6 @@ function ProjectInfoContent() {
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
- const findPresetIndexByAspectRatio = ({
- presets,
- targetAspectRatio,
- }: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
- }) => {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
- };
-
const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
@@ -87,12 +89,11 @@ function ProjectInfoContent() {
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
- const originalPresetValue = "original";
const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+ presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === originalPresetValue) {
+ if (value === ORIGINAL_PRESET_VALUE) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
@@ -106,7 +107,7 @@ function ProjectInfoContent() {
}
};
- const handleFpsChange = (value: string) => {
+ const handleFpsChange = ({ value }: { value: string }) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
};
@@ -129,7 +130,7 @@ function ProjectInfoContent() {
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
- <SelectItem value={originalPresetValue}>Original</SelectItem>
+ <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
@@ -148,7 +149,7 @@ function ProjectInfoContent() {
<Label>Frame rate</Label>
<Select
value={activeProject.settings.fps.toString()}
- onValueChange={handleFpsChange}
+ onValueChange={(value) => handleFpsChange({ value })}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select a frame rate" />