From c555f1eaebff90749bdc543592cfa80a060b29e2 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 9 Aug 2026 02:37:37 +0200 Subject: 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. --- apps/web/src/components/editor/export-button.tsx | 5 -- .../editor/panels/assets/assets-panel-store.tsx | 27 +++---- .../src/components/editor/panels/assets/index.tsx | 10 +-- .../src/components/editor/panels/assets/tabbar.tsx | 23 ++++-- .../editor/panels/assets/views/assets.tsx | 59 +++++++++++---- apps/web/src/components/editor/rename-dialog.tsx | 88 ++++++++++++++++++++++ 6 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/editor/rename-dialog.tsx (limited to 'apps/web/src/components/editor') 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 ? : null, text: , stickers: FEATURES.stickers ? : null, - effects: FEATURES.effects ? : null, + effects: FEATURES.effects ? : null, + lens: FEATURES.effects ? : null, transitions: (
Transitions view coming soon...
), - captions: , - adjustment: ( -
- Adjustment view coming soon... -
- ), settings: , }; 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 ( 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 ( - - {children} - - Export clips - ) => - onRemove({ event, ids: idsToDelete }) - } - > - {deleteLabel} - - - + <> + + {children} + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + setIsRenameDialogOpen(true); + }} + > + Rename + + Export clips + ) => + onRemove({ event, ids: idsToDelete }) + } + > + {deleteLabel} + + + + { + 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 ( + + + + {title} + + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleConfirm(); + } + }} + onFocus={(e) => e.target.select()} + placeholder="Enter a new name" + /> + + + + + + + + + ); +} -- cgit v1.3.1