diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/timeline-element.tsx | 470 |
1 files changed, 250 insertions, 220 deletions
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 ( <div - className="relative h-full cursor-pointer" - style={{ marginInline: ELEMENT_RING_WIDTH_PX }} + className="absolute top-0 bottom-0" + style={{ + left: `${ELEMENT_RING_WIDTH_PX}px`, + right: `${ELEMENT_RING_WIDTH_PX}px`, + }} > <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - getTrackClasses({ type: track.type }), - opacityClass, - )} + className="absolute inset-0 rounded-sm" style={ isSelected ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, } : undefined } > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer flex flex-col" - onClick={(event) => { - closeClipEffects(); - onElementClick(event, element); - }} - onMouseDown={(event) => onElementMouseDown(event, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getElementClasses({ type: track.type }), + isReducedOpacity && "opacity-50", + )} > - <div className="flex flex-1 min-h-0 items-center overflow-hidden"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - /> - </div> - </button> - </div> - - {element.type !== "audio" && element.type !== "effect" && ( - <div className="sticky left-1 mt-1 ml-1 w-fit"> - <EffectsButton - element={element as VisualElement} - trackId={track.id} - /> + <button + type="button" + tabIndex={-1} + className="absolute inset-0 size-full flex flex-col" + onClick={(event) => onElementClick(event, element)} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent element={element} track={track} /> + </div> + </button> </div> - )} + </div> {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<TimelineElementType, { type: "text" }>; +}) { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); +} + +function EffectElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "effect" }>; +}) { + return ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-4 shrink-0 text-white" + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); } -type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; +function StickerElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "sticker" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} -export function renderTiledMedia({ +function GraphicElementContent({ element, - imageUrl, - track, }: { - element: VisualElement; - imageUrl: string | undefined; - track: ElementContentProps["track"]; -}): ReactNode { - if (!imageUrl) { + element: Extract<TimelineElementType, { type: "graphic" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={buildGraphicPreviewUrl({ + definitionId: element.definitionId, + params: element.params, + size: 20, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +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 ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> + <div className="relative size-full"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + color={ELEMENT_TYPE_CONFIG.audio.waveformColor} + /> + <MediaElementHeader name={mediaLabel} hasFade={false} /> + </div> ); } - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - return ( - <div - className="absolute inset-0" - style={{ - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> + <span className="text-foreground/80 truncate text-xs">{element.name}</span> ); } 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 ( - <Button - variant="text" - size="icon" - className={cn("rounded-sm !size-5 bg-black/50 text-white", className)} - onClick={handleClick} + <button + type="button" + className="flex shrink-0 justify-center text-white cursor-pointer" onMouseDown={(event) => event.stopPropagation()} + onClick={handleClick} > - <HugeiconsIcon icon={MagicWand05Icon} /> - </Button> + <HugeiconsIcon icon={MagicWand05Icon} size={12} /> + </button> ); } -const ELEMENT_CONTENT_RENDERERS: Record< - TimelineElementType["type"], - ElementContentRenderer -> = { - text: ({ element }) => { - const textElement = element as Extract< - TimelineElementType, - { type: "text" } - >; - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white"> - {textElement.content} - </span> - </div> - ); - }, - effect: ({ element }) => ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white ml-1">{element.name}</span> - </div> - ), - sticker: ({ element }) => { - const stickerElement = element as Extract< - TimelineElementType, - { type: "sticker" } - >; - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: stickerElement.stickerId, - options: { width: 20, height: 20 }, - })} - alt={stickerElement.name} - className="size-5 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white"> - {stickerElement.name} - </span> - </div> - ); - }, - 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; +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); - if (audioBuffer || audioUrl) { - return ( - <div className="flex size-full items-center gap-2"> - <div className="min-w-0 flex-1"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + if (!imageUrl) { return ( <span className="text-foreground/80 truncate text-xs"> - {audioElement.name} + {element.name} </span> ); - }, - 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(), - })} + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + <MediaElementHeader + name={mediaAsset?.name} + leading={ + hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> </> ); } +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( + <div + className={cn( + "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", + hasFade && "from-black/30 to-transparent", + )} + > + {leading && <div className="pl-1">{leading}</div>} + {name && ( + <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> + {name} + </span> + )} + </div> + ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return <TextElementContent element={element} />; + case "effect": + return <EffectElementContent element={element} />; + case "sticker": + return <StickerElementContent element={element} />; + case "graphic": + return <GraphicElementContent element={element} />; + case "audio": + return <AudioElementContent element={element} />; + case "video": + case "image": + return <TiledMediaContent element={element} track={track} />; + } +} + function CopyMenuItem() { return ( <ActionMenuItem @@ -886,7 +916,7 @@ function ActionMenuItem({ children, ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TAction; + action: TActionWithOptionalArgs; children: ReactNode; }) { return ( |
