From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../editor/panels/timeline/timeline-element.tsx | 472 +++++++++++---------- 1 file changed, 251 insertions(+), 221 deletions(-) (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx') diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 9121f6c2..13a31375 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -2,7 +2,7 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import AudioWaveform from "./audio-waveform"; +import { AudioWaveform } from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { useKeyframeDrag, @@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; import { - getTrackClasses, + getElementClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, + hasElementEffects, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; +import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; import { ContextMenu, ContextMenuContent, @@ -30,14 +32,22 @@ import { import type { TimelineElement as TimelineElementType, TimelineTrack, - VisualElement, ElementDragState, -} from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; + VideoElement, + ImageElement, + AudioElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { resolveStickerId } from "@/lib/stickers"; +import { buildGraphicPreviewUrl } from "@/lib/graphics"; import Image from "next/image"; import { ScissorIcon, @@ -56,13 +66,16 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; -import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import type { + SelectedKeyframeRef, + ElementKeyframe, +} from "@/lib/animation/types"; import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; -import { usePropertiesStore } from "@/stores/properties-store"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; +const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: number; @@ -195,16 +208,15 @@ export function TimelineElement({ dragState, isDropTarget = false, }: TimelineElementProps) { - const editor = useEditor(); + const mediaAssets = useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const { requestRevealMedia } = useAssetsPanelStore(); + const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? - null; + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -394,60 +406,46 @@ function ElementInner({ }) => void; isDropTarget?: boolean; }) { - const opacityClass = - (canElementBeHidden(element) && element.hidden) || isDropTarget - ? "opacity-50" - : ""; - const closeClipEffects = usePropertiesStore( - (state) => state.closeClipEffects, - ); - + const isReducedOpacity = + (canElementBeHidden(element) && element.hidden) || isDropTarget; return (
- -
- - {element.type !== "audio" && element.type !== "effect" && ( -
- +
- )} +
{isSelected && ( <> @@ -535,8 +533,8 @@ function KeyframeIndicators({ const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); - const isBeingDragged = indicator.keyframes.some((kf) => - dragState.draggingKeyframeIds.has(kf.keyframeId), + const isBeingDragged = indicator.keyframes.some((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, @@ -581,212 +579,244 @@ function KeyframeIndicators({ interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; - isSelected: boolean; } -interface ElementContentRendererProps extends ElementContentProps { - mediaAssets: MediaAsset[]; +function TextElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.content} +
+ ); +} + +function EffectElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ + {element.name} +
+ ); } -type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; +function StickerElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} -export function renderTiledMedia({ +function GraphicElementContent({ element, - imageUrl, - track, }: { - element: VisualElement; - imageUrl: string | undefined; - track: ElementContentProps["track"]; -}): ReactNode { - if (!imageUrl) { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +function AudioElementContent({ element }: { element: AudioElement }) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const mediaLabel = mediaAsset?.name ?? element.name; + + if (audioBuffer || audioUrl) { return ( - - {element.name} - +
+ + +
); } - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - return ( -
+ {element.name} ); } function EffectsButton({ element, - trackId, - className, + track, }: { - element: VisualElement; - trackId: string; - className?: string; + element: VideoElement | ImageElement; + track: TimelineTrack; }) { - const openClipEffects = usePropertiesStore((state) => state.openClipEffects); - const { selectElement } = useElementSelection(); - - if (!element.effects?.length) { - return null; - } + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); - selectElement({ elementId: element.id, trackId }); - openClipEffects({ elementId: element.id, trackId }); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); }; return ( - + + ); } -const ELEMENT_CONTENT_RENDERERS: Record< - TimelineElementType["type"], - ElementContentRenderer -> = { - text: ({ element }) => { - const textElement = element as Extract< - TimelineElementType, - { type: "text" } - >; - return ( -
- - {textElement.content} - -
- ); - }, - effect: ({ element }) => ( -
- - {element.name} -
- ), - sticker: ({ element }) => { - const stickerElement = element as Extract< - TimelineElementType, - { type: "sticker" } - >; - return ( -
- {stickerElement.name} - - {stickerElement.name} - -
- ); - }, - audio: ({ element, mediaAssets }) => { - const audioElement = element as Extract< - TimelineElementType, - { type: "audio" } - >; - const audioBuffer = - audioElement.sourceType === "library" ? audioElement.buffer : undefined; - const audioUrl = - audioElement.sourceType === "library" - ? audioElement.sourceUrl - : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; - - if (audioBuffer || audioUrl) { - return ( -
-
- -
-
- ); - } +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + if (!imageUrl) { return ( - {audioElement.name} + {element.name} ); - }, - video: ({ element, track, mediaAssets }) => { - const videoElement = element as Extract< - TimelineElementType, - { type: "video" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === videoElement.mediaId, - ); - return renderTiledMedia({ - element: videoElement, - imageUrl: mediaAsset?.thumbnailUrl, - track, - }); - }, - image: ({ element, track, mediaAssets }) => { - const imageElement = element as Extract< - TimelineElementType, - { type: "image" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === imageElement.mediaId, - ); - return renderTiledMedia({ - element: imageElement, - imageUrl: mediaAsset?.url, - track, - }); - }, -}; + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; -function ElementContent({ element, track, isSelected }: ElementContentProps) { - const editor = useEditor(); - const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( <> - {renderer({ - element, - track, - isSelected, - mediaAssets: editor.media.getAssets(), - })} +
+ + ) : null + } + hasFade={true} + /> ); } +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( +
+ {leading &&
{leading}
} + {name && ( + + {name} + + )} +
+ ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return ; + case "effect": + return ; + case "sticker": + return ; + case "graphic": + return ; + case "audio": + return ; + case "video": + case "image": + return ; + } +} + function CopyMenuItem() { return ( , "onClick" | "textRight"> & { - action: TAction; + action: TActionWithOptionalArgs; children: ReactNode; }) { return ( -- cgit v1.3.1