"use client"; import { createContext, useContext } from "react"; import { useEditor } from "@/editor/use-editor"; import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform"; import { AudioVolumeLine } from "./audio-volume-line"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useKeyframeDrag, type KeyframeDragState, } from "@/timeline/hooks/element/use-keyframe-drag"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { useKeyframeBoxSelect } from "@/timeline/hooks/element/use-keyframe-box-select"; import { SelectionBox } from "@/selection/selection-box"; import { getElementKeyframes } from "@/animation"; import { canElementHaveAudio, canElementBeHidden, hasElementEffects, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/timeline"; import { getTrackHeight } from "./track-layout"; import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import type { SelectionBoxBounds } from "@/selection/types"; import type { TimelineElement as TimelineElementType, TimelineTrack, ElementDragView, VideoElement, ImageElement, AudioElement, } from "@/timeline"; import type { MediaAsset } from "@/media/types"; import { mediaSupportsAudio } from "@/media/media-utils"; import { canToggleSourceAudio, getSourceAudioActionLabel, isSourceAudioSeparated, } from "@/timeline/audio-separation"; import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { getActionDefaultShortcuts, type TAction, type TActionWithOptionalArgs, invokeAction, } from "@/actions"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { resolveStickerId } from "@/stickers"; import { buildGraphicPreviewUrl } from "@/graphics"; import Image from "next/image"; import { ScissorIcon, Delete02Icon, Copy01Icon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, VolumeOffIcon, VolumeMute02Icon, Search01Icon, Exchange01Icon, KeyframeIcon, MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import { useMemo, type ComponentProps, type ReactNode } from "react"; import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types"; import { cn } from "@/utils/ui"; import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; import { getTrackTypeForElementType } from "@/timeline/placement/compatibility"; import { useTimelineStore } from "@/timeline/timeline-store"; import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; import { getExpandedRows, getExpansionHeight, type ExpandedRow, } from "./expanded-layout"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; const PixelsPerSecondContext = createContext(null); const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: MediaTime; offsetPx: number; keyframes: SelectedKeyframeRef[]; } export function buildKeyframeIndicator({ keyframe, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, }: { keyframe: ElementKeyframe; trackId: string; elementId: string; displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; }): { time: MediaTime; offsetPx: number; keyframeRef: SelectedKeyframeRef; } { const keyframeRef = { trackId, elementId, propertyPath: keyframe.propertyPath, keyframeId: keyframe.id, }; const keyframeLeft = timelineTimeToSnappedPixels({ time: displayedStartTime + keyframe.time, zoomLevel, }); return { time: keyframe.time, offsetPx: keyframeLeft - elementLeft, keyframeRef, }; } export function getKeyframeIndicators({ keyframes, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, elementWidth, }: { keyframes: ElementKeyframe[]; trackId: string; elementId: string; displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; elementWidth: number; }): KeyframeIndicator[] { if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { return []; } const keyframesByTime = new Map(); for (const keyframe of keyframes) { const indicator = buildKeyframeIndicator({ keyframe, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, }); const existingIndicator = keyframesByTime.get(indicator.time); if (!existingIndicator) { keyframesByTime.set(indicator.time, { time: indicator.time, offsetPx: indicator.offsetPx, keyframes: [indicator.keyframeRef], }); continue; } existingIndicator.keyframes.push(indicator.keyframeRef); } return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); } export function getDisplayShortcut({ action }: { action: TAction }) { const defaultShortcuts = getActionDefaultShortcuts({ action }); if (!defaultShortcuts?.length) { return ""; } return uppercase({ string: defaultShortcuts[0].replace("+", " "), }); } interface TimelineElementProps { element: TimelineElementType; track: TimelineTrack; zoomLevel: number; isSelected: boolean; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => void; onElementMouseDown: (params: { event: React.MouseEvent; element: TimelineElementType; }) => void; onElementClick: (params: { event: React.MouseEvent; element: TimelineElementType; }) => void; dragView: ElementDragView; isDropTarget?: boolean; } export function TimelineElement({ element, track, zoomLevel, isSelected, onResizeStart, onElementMouseDown, onElementClick, dragView, isDropTarget = false, }: TimelineElementProps) { const mediaAssets = useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); const { renderElement } = useElementPreview({ trackId: track.id, elementId: element.id, fallback: element, }); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); const isCurrentElementSelected = selectedElements.some( (selected) => selected.elementId === element.id && selected.trackId === track.id, ); const isDragging = dragView.kind === "dragging"; const dragTimeOffset = isDragging ? dragView.memberTimeOffsets.get(element.id) : undefined; const isBeingDragged = dragTimeOffset !== undefined; const dragOffsetY = isDragging && isBeingDragged ? dragView.currentMouseY - dragView.startMouseY : 0; const elementStartTime = isDragging && isBeingDragged ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; const elementWidth = timelineTimeToPixels({ time: displayedDuration, zoomLevel, }); const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel }); const elementLeft = timelineTimeToSnappedPixels({ time: displayedStartTime, zoomLevel, }); const keyframeIndicators = isSelected ? getKeyframeIndicators({ keyframes: getElementKeyframes({ animations: element.animations }), trackId: track.id, elementId: element.id, displayedStartTime, zoomLevel, elementLeft, elementWidth, }) : []; const { keyframeDragState, handleKeyframeMouseDown, handleKeyframeClick, getVisualOffsetPx, } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); const elementKeyframes = getElementKeyframes({ animations: element.animations, }); const isExpanded = useTimelineStore((s) => s.expandedElementIds.has(element.id), ); const toggleElementExpanded = useTimelineStore( (s) => s.toggleElementExpanded, ); const expandedRows = useMemo( () => isExpanded ? getExpandedRows({ animations: element.animations }) : [], [isExpanded, element.animations], ); const { containerRef: expandedLanesRef, selectionBox: keyframeSelectionBox, isBoxSelecting: isKeyframeBoxSelecting, handleExpandedAreaMouseDown, handleExpandedAreaClick, } = useKeyframeBoxSelect({ trackId: track.id, elementId: element.id, rows: expandedRows, keyframes: elementKeyframes, displayedStartTime, zoomLevel, elementLeft, }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { requestRevealMedia(element.mediaId); } }; const isMuted = canElementHaveAudio(element) && isElementMuted({ element }); const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && canToggleSourceAudio(element, mediaAsset); const sourceAudioLabel = element.type === "video" ? getSourceAudioActionLabel({ element }) : "Extract audio"; const isElementSourceAudioSeparated = element.type === "video" && isSourceAudioSeparated({ element }); const hasKeyframes = elementKeyframes.length > 0; const expansionHeight = getExpansionHeight({ rows: expandedRows }); const baseTrackHeight = getTrackHeight({ type: track.type }); const expandedContent = isExpanded && expandedRows.length > 0 ? ( ) : null; return (
0 ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: isDragging && isBeingDragged ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} > 0} baseTrackHeight={baseTrackHeight} expandedContent={expandedContent} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} onResizeStart={onResizeStart} isDropTarget={isDropTarget} /> {isSelected && (
)}
} > Split {selectedElements.length === 1 && ( } > Duplicate )} {canElementHaveAudio(element) && hasAudio && ( 1} isCurrentElementSelected={isCurrentElementSelected} isMuted={isMuted} /> )} {canToggleCurrentSourceAudio && ( } onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction("toggle-source-audio"); }} > {sourceAudioLabel} )} {canElementBeHidden(element) && ( 1} isCurrentElementSelected={isCurrentElementSelected} /> )} {hasKeyframes && ( } onClick={(event: React.MouseEvent) => { event.stopPropagation(); toggleElementExpanded(element.id); }} > {isExpanded ? "Collapse keyframes" : "Expand keyframes"} )} {selectedElements.length === 1 && hasMediaId(element) && ( <> } onClick={(event: React.MouseEvent) => handleRevealInMedia({ event }) } > Reveal media } disabled > Replace media )} 1} isCurrentElementSelected={isCurrentElementSelected} elementType={element.type} selectedCount={selectedElements.length} />
); } function ElementInner({ element, displayElement, track, isSelected, isExpanded, baseTrackHeight, expandedContent, onElementClick, onElementMouseDown, onResizeStart, isDropTarget = false, }: { element: TimelineElementType; displayElement?: TimelineElementType; track: TimelineTrack; isSelected: boolean; isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; onElementClick: (params: { event: React.MouseEvent; element: TimelineElementType; }) => void; onElementMouseDown: (params: { event: React.MouseEvent; element: TimelineElementType; }) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => void; isDropTarget?: boolean; }) { const visibleElement = displayElement ?? element; const isReducedOpacity = (canElementBeHidden(visibleElement) && visibleElement.hidden) || isDropTarget; return (
{isSelected && ( <> )}
); } function ResizeHandle({ side, element, track, onResizeStart, }: { side: "left" | "right"; element: TimelineElementType; track: TimelineTrack; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => void; }) { const isLeft = side === "left"; return ( ); } function KeyframeIndicators({ indicators, dragState, displayedStartTime, elementLeft, onKeyframeMouseDown, onKeyframeClick, getVisualOffsetPx, }: { indicators: KeyframeIndicator[]; dragState: KeyframeDragState; displayedStartTime: MediaTime; elementLeft: number; onKeyframeMouseDown: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; }) => void; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = indicators.flatMap( (indicator) => indicator.keyframes, ); return indicators.map((indicator) => { const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); const isBeingDragged = indicator.keyframes.some((keyframe) => dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, indicatorOffsetPx: indicator.offsetPx, isBeingDragged, displayedStartTime, elementLeft, }); return ( ); }); } function ExpandedKeyframeLanes({ rows, keyframes, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, keyframeDragState, onKeyframeMouseDown, onKeyframeClick, getVisualOffsetPx, containerRef, onLaneMouseDown, onLaneClick, selectionBox, isBoxSelecting, }: { rows: ExpandedRow[]; keyframes: ElementKeyframe[]; trackId: string; elementId: string; displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; keyframeDragState: KeyframeDragState; onKeyframeMouseDown: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; }) => void; containerRef: React.RefObject; onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { bounds: SelectionBoxBounds; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = useMemo( () => [...keyframes] .sort( (a, b) => a.time - b.time || a.propertyPath.localeCompare(b.propertyPath), ) .map((kf) => ({ trackId, elementId, propertyPath: kf.propertyPath, keyframeId: kf.id, })), [keyframes, trackId, elementId], ); return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus.
{rows.map((row) => { const laneKeyframes = keyframes.filter( (kf) => kf.propertyPath === row.propertyPath, ); return (
{laneKeyframes.map((kf) => { const keyframeRef: SelectedKeyframeRef = { trackId, elementId, propertyPath: row.propertyPath, keyframeId: kf.id, }; const isBeingDragged = keyframeDragState.draggingKeyframeIds.has( kf.id, ); const kfLeft = timelineTimeToSnappedPixels({ time: displayedStartTime + kf.time, zoomLevel, }); const offsetPx = kfLeft - elementLeft; const visualOffset = getVisualOffsetPx({ indicatorTime: kf.time, indicatorOffsetPx: offsetPx, isBeingDragged, displayedStartTime, elementLeft, }); const isSelected = isKeyframeSelected({ keyframe: keyframeRef, }); return ( ); })}
); })} {selectionBox && }
); } interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; } function TextElementContent({ element, }: { element: Extract; }) { return (
{typeof element.params.content === "string" ? element.params.content : ""}
); } function EffectElementContent({ element, }: { element: Extract; }) { return (
{element.name}
); } function StickerElementContent({ element, }: { element: Extract; }) { return (
{element.name} {element.name}
); } function GraphicElementContent({ element, }: { element: Extract; }) { return (
{element.name} {element.name}
); } function AudioElementContent({ element, trackId, }: { element: AudioElement; trackId: string; }) { const pixelsPerSecond = useContext(PixelsPerSecondContext); if (pixelsPerSecond === null) { throw new Error( "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider", ); } 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 sourceFile = element.sourceType === "upload" ? mediaAsset?.file : undefined; const sourceKey = element.sourceType === "upload" ? buildWaveformSourceKey({ kind: "media", id: element.mediaId }) : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl }); const mediaLabel = mediaAsset?.name ?? element.name; const gainSamples = useMemo( () => buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT, }), [element], ); if (audioBuffer || audioUrl || sourceFile) { return (
); } return (
{element.name}
); } function EffectsButton({ element, track, }: { element: VideoElement | ImageElement; track: TimelineTrack; }) { const editor = useEditor(); const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); setActiveTab({ elementType: element.type, tabId: "effects" }); }; 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 ( {element.name} ); } const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; return ( <>
) : 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 ( } > Copy ); } function MuteMenuItem({ isMultipleSelected, isCurrentElementSelected, isMuted, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; isMuted: boolean; }) { const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isMuted ? ( ) : ( ); }; return ( {isMuted ? "Unmute" : "Mute"} ); } function VisibilityMenuItem({ element, isMultipleSelected, isCurrentElementSelected, }: { element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; }) { const isHidden = canElementBeHidden(element) && element.hidden; const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isHidden ? ( ) : ( ); }; return ( {isHidden ? "Show" : "Hide"} ); } function DeleteMenuItem({ isMultipleSelected, isCurrentElementSelected, elementType, selectedCount, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; elementType: TimelineElementType["type"]; selectedCount: number; }) { return ( } > {isMultipleSelected && isCurrentElementSelected ? `Delete ${selectedCount} elements` : `Delete ${elementType === "text" ? "text" : "clip"}`} ); } function ActionMenuItem({ action, children, ...props }: Omit, "onClick" | "textRight"> & { action: TActionWithOptionalArgs; children: ReactNode; }) { return ( { event.stopPropagation(); invokeAction(action); }} textRight={getDisplayShortcut({ action })} {...props} > {children} ); }