summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
committercashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
commitc555f1eaebff90749bdc543592cfa80a060b29e2 (patch)
treee6a04adb212871063653b66c17eb1b8c17c7d22f /apps/web/src/components
parentdf82306a86d14bca0149e6d756ba82b75659af43 (diff)
downloadopencut-c555f1eaebff90749bdc543592cfa80a060b29e2.tar.gz
feat: lens effects tab, standalone motion effects, clip/asset rename, preview popout
- New Lens category in the assets panel: Blur, Motion Blur, Zoom In/Out, Pan x4, Shake, Pulse, Spin, Custom Zoom. EffectDefinition gains a category field; tabs with an empty category are hidden, and the properties-panel 'Open effects' button opens the first non-empty one. - Motion effects work standalone: dropping them on the timeline creates an effect-track element that animates (scale/translate/rotate) every track below it, computed purely in the TS frame descriptor - no wasm changes, identical in preview and export. supportsStandalone removed. - Motion Blur: directional gaussian smear (amount + angle) reusing the existing WGSL shader with arbitrary u_direction. - Custom Zoom: start/end scale params (keyframable clip-level). - MotionOffset gains optional rotate, accumulated clip-level and applied to standalone layer quads via rotationDegrees. - Rename clips (timeline context menu) and media assets (media panel context menu) via a shared RenameDialog; asset rename persists through storageService with rollback on error. - Preview pops out into a Document Picture-in-Picture window and docks back; button hidden where the API is unsupported. - Export keeps running when the export popover is closed; only the explicit Cancel button aborts. - Captions and Adjustment tabs removed from the assets panel.
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/export-button.tsx5
-rw-r--r--apps/web/src/components/editor/panels/assets/assets-panel-store.tsx27
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx10
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx23
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx59
-rw-r--r--apps/web/src/components/editor/rename-dialog.tsx88
6 files changed, 165 insertions, 47 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index 5e724384..9b9d5b8e 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -47,15 +47,10 @@ function isExportQuality(value: string): value is ExportQuality {
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
- const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActiveOrNull());
const hasProject = !!activeProject;
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
- if (!open) {
- editor.project.cancelExport();
- editor.project.clearExportState();
- }
setIsExportPopoverOpen(open);
};
diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
index b412de7b..9e9f77c6 100644
--- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
+++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
@@ -3,17 +3,16 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
ArrowRightDoubleIcon,
- ClosedCaptionIcon,
+ CameraLensIcon,
Folder03Icon,
Happy01Icon,
HeadphonesIcon,
MagicWand05Icon,
TextIcon,
Settings01Icon,
- SlidersHorizontalIcon,
- ColorsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { effectsRegistry } from "@/effects";
export const TAB_KEYS = [
"media",
@@ -21,9 +20,8 @@ export const TAB_KEYS = [
"text",
"stickers",
"effects",
+ "lens",
"transitions",
- "captions",
- "adjustment",
"settings",
] as const;
@@ -56,18 +54,14 @@ export const tabs = {
icon: createHugeiconsIcon({ icon: MagicWand05Icon }),
label: "Effects",
},
+ lens: {
+ icon: createHugeiconsIcon({ icon: CameraLensIcon }),
+ label: "Lens",
+ },
transitions: {
icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }),
label: "Transitions",
},
- captions: {
- icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }),
- label: "Captions",
- },
- adjustment: {
- icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }),
- label: "Adjustment",
- },
settings: {
icon: createHugeiconsIcon({ icon: Settings01Icon }),
label: "Settings",
@@ -81,6 +75,13 @@ export type MediaViewMode = "grid" | "list";
export type MediaSortKey = "name" | "type" | "duration" | "size";
export type MediaSortOrder = "asc" | "desc";
+export function getDefaultEffectsTab(): Tab {
+ const hasLensEffects = effectsRegistry
+ .getAll()
+ .some((definition) => definition.category === "lens");
+ return hasLensEffects ? "lens" : "effects";
+}
+
interface AssetsPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index 09d64eed..c0204b12 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -3,7 +3,6 @@
import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
import { TabBar } from "./tabbar";
-import { Captions } from "@/subtitles/components/assets-view";
import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "@/sounds/components/assets-view";
@@ -20,18 +19,13 @@ export function AssetsPanel() {
sounds: FEATURES.sounds ? <SoundsView /> : null,
text: <TextView />,
stickers: FEATURES.stickers ? <StickersView /> : null,
- effects: FEATURES.effects ? <EffectsView /> : null,
+ effects: FEATURES.effects ? <EffectsView category="effect" /> : null,
+ lens: FEATURES.effects ? <EffectsView category="lens" /> : null,
transitions: (
<div className="text-muted-foreground p-4">
Transitions view coming soon...
</div>
),
- captions: <Captions />,
- adjustment: (
- <div className="text-muted-foreground p-4">
- Adjustment view coming soon...
- </div>
- ),
settings: <SettingsView />,
};
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index b468b5bb..9b029e3d 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -14,13 +14,22 @@ import {
useAssetsPanelStore,
} from "@/components/editor/panels/assets/assets-panel-store";
import { FEATURES } from "@/features";
+import { effectsRegistry } from "@/effects";
-const VISIBLE_TAB_KEYS = TAB_KEYS.filter((tabKey) => {
- if (tabKey === "sounds") return FEATURES.sounds;
- if (tabKey === "stickers") return FEATURES.stickers;
- if (tabKey === "effects") return FEATURES.effects;
- return true;
-});
+function getVisibleTabKeys() {
+ const effectCategories = new Set(
+ effectsRegistry.getAll().map((definition) => definition.category),
+ );
+ return TAB_KEYS.filter((tabKey) => {
+ if (tabKey === "sounds") return FEATURES.sounds;
+ if (tabKey === "stickers") return FEATURES.stickers;
+ if (tabKey === "effects")
+ return FEATURES.effects && effectCategories.has("effect");
+ if (tabKey === "lens")
+ return FEATURES.effects && effectCategories.has("lens");
+ return true;
+ });
+}
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
@@ -59,7 +68,7 @@ export function TabBar() {
ref={scrollRef}
className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
>
- {VISIBLE_TAB_KEYS.map((tabKey) => {
+ {getVisibleTabKeys().map((tabKey) => {
const tab = tabs[tabKey];
return (
<Tooltip key={tabKey} delayDuration={10}>
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 4dbed41e..049cb5f4 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -48,8 +48,10 @@ import {
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { cn } from "@/utils/ui";
+import { RenameDialog } from "@/components/editor/rename-dialog";
import {
CloudUploadIcon,
+ Edit02Icon,
GridViewIcon,
LeftToRightListDashIcon,
SortingOneNineIcon,
@@ -316,25 +318,54 @@ function MediaItemWithContextMenu({
}) => void;
}) {
const { isSelected, selectedIds } = useSelection();
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
const deleteLabel =
idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
return (
- <ContextMenu>
- <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(event: React.MouseEvent<HTMLDivElement>) =>
- onRemove({ event, ids: idsToDelete })
- }
- >
- {deleteLabel}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
+ <>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Edit02Icon} />}
+ onClick={(event: React.MouseEvent<HTMLDivElement>) => {
+ event.stopPropagation();
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </ContextMenuItem>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(event: React.MouseEvent<HTMLDivElement>) =>
+ onRemove({ event, ids: idsToDelete })
+ }
+ >
+ {deleteLabel}
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ <RenameDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ currentName={item.name}
+ title="Rename asset"
+ onConfirm={(newName) => {
+ if (!activeProject) return;
+ editor.media.renameMediaAsset({
+ projectId: activeProject.metadata.id,
+ id: item.id,
+ name: newName,
+ });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+ </>
);
}
diff --git a/apps/web/src/components/editor/rename-dialog.tsx b/apps/web/src/components/editor/rename-dialog.tsx
new file mode 100644
index 00000000..ad9833c0
--- /dev/null
+++ b/apps/web/src/components/editor/rename-dialog.tsx
@@ -0,0 +1,88 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+import { Label } from "@/components/ui/label";
+
+export function RenameDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ currentName,
+ title,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (newName: string) => void;
+ currentName: string;
+ title: string;
+}) {
+ const [name, setName] = useState(currentName);
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(currentName);
+ }
+ onOpenChange(open);
+ };
+
+ const trimmedName = name.trim();
+ const isConfirmDisabled =
+ trimmedName.length === 0 || trimmedName === currentName;
+
+ const handleConfirm = () => {
+ if (isConfirmDisabled) {
+ return;
+ }
+ onConfirm(trimmedName);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="gap-3">
+ <Label>New name</Label>
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleConfirm();
+ }
+ }}
+ onFocus={(e) => e.target.select()}
+ placeholder="Enter a new name"
+ />
+ </DialogBody>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button disabled={isConfirmDisabled} onClick={handleConfirm}>
+ Rename
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}