diff options
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/audio-constants.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/audio-state.ts | 100 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/bookmarks.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/defaults.ts | 77 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drag.ts | 42 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drop-utils.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 241 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/ripple-utils.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/selection-hit-testing.ts | 133 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/snap-utils.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-element-update.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-utils.ts | 29 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/types.ts | 304 |
14 files changed, 803 insertions, 141 deletions
diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts new file mode 100644 index 00000000..44592df4 --- /dev/null +++ b/apps/web/src/lib/timeline/audio-constants.ts @@ -0,0 +1,2 @@ +export const VOLUME_DB_MIN = -60; +export const VOLUME_DB_MAX = 20; diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts new file mode 100644 index 00000000..90d34668 --- /dev/null +++ b/apps/web/src/lib/timeline/audio-state.ts @@ -0,0 +1,100 @@ +import { hasKeyframesForPath } from "@/lib/animation/keyframe-query"; +import { resolveNumberAtTime } from "@/lib/animation/resolve"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; +import type { TimelineElement } from "./types"; +const DEFAULT_STEP_SECONDS = 1 / 60; + +export type AudioCapableElement = Extract< + TimelineElement, + { type: "audio" | "video" } +>; + +export function clampDb(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value)); +} + +export function dBToLinear(db: number): number { + return Math.pow(10, clampDb(db) / 20); +} + +export function hasAnimatedVolume({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return hasKeyframesForPath({ + animations: element.animations, + propertyPath: "volume", + }); +} + +export function resolveEffectiveAudioGain({ + element, + trackMuted = false, + localTime, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + localTime: number; +}): number { + if (trackMuted || element.muted === true) { + return 0; + } + + const resolvedDb = resolveNumberAtTime({ + baseValue: element.volume ?? 0, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + + return dBToLinear(resolvedDb); +} + +export function buildAudioGainAutomation({ + element, + trackMuted = false, + fromLocalTime, + toLocalTime, + stepSeconds = DEFAULT_STEP_SECONDS, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + fromLocalTime: number; + toLocalTime: number; + stepSeconds?: number; +}): Array<{ localTime: number; gain: number }> { + const startTime = Math.max(0, fromLocalTime); + const endTime = Math.max(startTime, toLocalTime); + const safeStep = + Number.isFinite(stepSeconds) && stepSeconds > 0 + ? stepSeconds + : DEFAULT_STEP_SECONDS; + const points: Array<{ localTime: number; gain: number }> = []; + + for (let localTime = startTime; localTime < endTime; localTime += safeStep) { + points.push({ + localTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime, + }), + }); + } + + points.push({ + localTime: endTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime: endTime, + }), + }); + + return points; +} diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts index efaa8f74..f5c034c7 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,4 +1,4 @@ -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; import { roundToFrame } from "@/lib/time"; export const BOOKMARK_TIME_EPSILON = 0.001; diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts new file mode 100644 index 00000000..7f52905f --- /dev/null +++ b/apps/web/src/lib/timeline/defaults.ts @@ -0,0 +1,77 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { TTimelineViewState } from "@/lib/project/types"; +import type { BlendMode, Transform } from "@/lib/rendering"; +import type { TextElement } from "./types"; + +const defaultTransform: Transform = { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, +}; + +const defaultOpacity = 1; +const defaultBlendMode: BlendMode = "normal"; +const defaultVolume = 0; + +const defaultTextLetterSpacing = 0; +const defaultTextLineHeight = 1.2; + +const defaultTextBackground = { + enabled: false, + color: "#000000", + cornerRadius: 0, + paddingX: 30, + paddingY: 42, + offsetX: 0, + offsetY: 0, +}; + +const defaultTextElement: Omit<TextElement, "id"> = { + type: "text", + name: "Text", + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + background: { ...defaultTextBackground }, + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + ...defaultTransform, + position: { ...defaultTransform.position }, + }, + opacity: defaultOpacity, +}; + +const defaultTimelineViewState: TTimelineViewState = { + zoomLevel: 1, + scrollLeft: 0, + playheadTime: 0, +}; + +export const DEFAULTS = { + element: { + transform: defaultTransform, + opacity: defaultOpacity, + blendMode: defaultBlendMode, + volume: defaultVolume, + }, + text: { + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + background: defaultTextBackground, + element: defaultTextElement, + }, + timeline: { + viewState: defaultTimelineViewState, + }, +}; diff --git a/apps/web/src/lib/timeline/drag.ts b/apps/web/src/lib/timeline/drag.ts new file mode 100644 index 00000000..89b1593f --- /dev/null +++ b/apps/web/src/lib/timeline/drag.ts @@ -0,0 +1,42 @@ +import type { MaskableElement, VisualElement } from "./types"; +import type { ParamValues } from "@/lib/params"; + +interface BaseDragData { + id: string; + name: string; +} + +export interface MediaDragData extends BaseDragData { + type: "media"; + mediaType: "image" | "video" | "audio"; + targetElementTypes?: MaskableElement["type"][]; +} + +export interface TextDragData extends BaseDragData { + type: "text"; + content: string; +} + +export interface StickerDragData extends BaseDragData { + type: "sticker"; + stickerId: string; +} + +export interface GraphicDragData extends BaseDragData { + type: "graphic"; + definitionId: string; + params: Partial<ParamValues>; +} + +export interface EffectDragData extends BaseDragData { + type: "effect"; + effectType: string; + targetElementTypes: VisualElement["type"][]; +} + +export type TimelineDragData = + | MediaDragData + | TextDragData + | StickerDragData + | GraphicDragData + | EffectDragData; diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index b502974d..b4f53948 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -2,10 +2,10 @@ import type { TimelineTrack, ElementType, TimelineElement, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { wouldElementOverlap } from "./element-utils"; -import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; +import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; import { isMainTrack, enforceMainTrackStart } from "./track-utils"; function findElementAtPosition({ diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 11564376..c44ae044 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,30 +1,33 @@ -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { - DEFAULT_BLEND_MODE, - DEFAULT_OPACITY, - DEFAULT_TRANSFORM, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import type { - CreateEffectElement, - CreateTimelineElement, - CreateVideoElement, - CreateImageElement, - CreateStickerElement, - CreateUploadAudioElement, - CreateLibraryAudioElement, - TextBackground, - TextElement, - TimelineElement, - TimelineTrack, - AudioElement, - VideoElement, - ImageElement, - VisualElement, - UploadAudioElement, -} from "@/types/timeline"; -import type { MediaType } from "@/types/assets"; + MASKABLE_ELEMENT_TYPES, + RETIMABLE_ELEMENT_TYPES, + VISUAL_ELEMENT_TYPES, + type CreateEffectElement, + type CreateGraphicElement, + type CreateTimelineElement, + type CreateVideoElement, + type CreateImageElement, + type CreateStickerElement, + type CreateUploadAudioElement, + type CreateLibraryAudioElement, + type TextBackground, + type TextElement, + type TimelineElement, + type TimelineTrack, + type AudioElement, + type VideoElement, + type ImageElement, + type MaskableElement, + type RetimableElement, + type VisualElement, + type UploadAudioElement, +} from "@/lib/timeline"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { MediaType } from "@/lib/media/types"; import { buildDefaultEffectInstance } from "@/lib/effects"; +import { buildDefaultGraphicInstance } from "@/lib/graphics"; +import type { ParamValues } from "@/lib/params"; import { capitalizeFirstLetter } from "@/utils/string"; export function canElementHaveAudio( @@ -36,12 +39,19 @@ export function canElementHaveAudio( export function isVisualElement( element: TimelineElement, ): element is VisualElement { - return ( - element.type === "video" || - element.type === "image" || - element.type === "text" || - element.type === "sticker" - ); + return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isMaskableElement( + element: TimelineElement, +): element is MaskableElement { + return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isRetimableElement( + element: TimelineElement, +): element is RetimableElement { + return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); } export function canElementBeHidden( @@ -50,6 +60,14 @@ export function canElementBeHidden( return isVisualElement(element); } +export function hasElementEffects({ + element, +}: { + element: TimelineElement; +}): boolean { + return isVisualElement(element) && (element.effects?.length ?? 0) > 0; +} + export function hasMediaId( element: TimelineElement, ): element is UploadAudioElement | VideoElement | ImageElement { @@ -68,55 +86,6 @@ export function requiresMediaId({ ); } -export function checkElementOverlaps({ - elements, -}: { - elements: TimelineElement[]; -}): boolean { - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - - for (let i = 0; i < sortedElements.length - 1; i++) { - const current = sortedElements[i]; - const next = sortedElements[i + 1]; - - const currentEnd = current.startTime + current.duration; - - if (currentEnd > next.startTime) return true; - } - - return false; -} - -export function resolveElementOverlaps({ - elements, -}: { - elements: TimelineElement[]; -}): TimelineElement[] { - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - const resolvedElements: TimelineElement[] = []; - - for (let i = 0; i < sortedElements.length; i++) { - const current = { ...sortedElements[i] }; - - if (resolvedElements.length > 0) { - const previous = resolvedElements[resolvedElements.length - 1]; - const previousEnd = previous.startTime + previous.duration; - - if (current.startTime < previousEnd) { - current.startTime = previousEnd; - } - } - - resolvedElements.push(current); - } - - return resolvedElements; -} - export function wouldElementOverlap({ elements, startTime, @@ -138,9 +107,8 @@ export function wouldElementOverlap({ function buildTextBackground( raw: Partial<TextBackground> | undefined, ): TextBackground { - const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color; - const enabled = - typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent"; + const color = raw?.color ?? DEFAULTS.text.element.background.color; + const enabled = raw?.enabled ?? color !== "transparent"; return { enabled, color, @@ -163,28 +131,25 @@ export function buildTextElement({ return { type: "text", - name: t.name ?? DEFAULT_TEXT_ELEMENT.name, - content: t.content ?? DEFAULT_TEXT_ELEMENT.content, + name: t.name ?? DEFAULTS.text.element.name, + content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, - fontSize: - typeof t.fontSize === "number" - ? t.fontSize - : DEFAULT_TEXT_ELEMENT.fontSize, - fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, - color: t.color ?? DEFAULT_TEXT_ELEMENT.color, + fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, + fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, + color: t.color ?? DEFAULTS.text.element.color, background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, - fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, - fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, - textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight, - transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, - opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, - blendMode: t.blendMode ?? DEFAULT_BLEND_MODE, + textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, + fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, + fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, + textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, + letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, + lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, + transform: t.transform ?? DEFAULTS.text.element.transform, + opacity: t.opacity ?? DEFAULTS.text.element.opacity, + blendMode: t.blendMode ?? DEFAULTS.element.blendMode, }; } @@ -214,10 +179,14 @@ export function buildStickerElement({ stickerId, name, startTime, + intrinsicWidth, + intrinsicHeight, }: { stickerId: string; name?: string; startTime: number; + intrinsicWidth?: number; + intrinsicHeight?: number; }): CreateStickerElement { const stickerNameFromId = stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; @@ -225,17 +194,52 @@ export function buildStickerElement({ type: "sticker", name: name ?? stickerNameFromId, stickerId, + intrinsicWidth, + intrinsicHeight, + duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +export function buildGraphicElement({ + definitionId, + name, + startTime, + params, +}: { + definitionId: string; + name?: string; + startTime: number; + params?: Partial<ParamValues>; +}): CreateGraphicElement { + const instance = buildDefaultGraphicInstance({ definitionId }); + return { + type: "graphic", + name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), + definitionId: instance.definitionId, + params: { ...instance.params, ...(params ?? {}) } as ParamValues, duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, - transform: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, }; } -export function buildVideoElement({ +function buildVideoElement({ mediaId, name, duration, @@ -257,13 +261,17 @@ export function buildVideoElement({ sourceDuration: duration, muted: false, hidden: false, - transform: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + volume: DEFAULTS.element.volume, }; } -export function buildImageElement({ +function buildImageElement({ mediaId, name, duration, @@ -283,13 +291,16 @@ export function buildImageElement({ trimStart: 0, trimEnd: 0, hidden: false, - transform: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, }; } -export function buildUploadAudioElement({ +function buildUploadAudioElement({ mediaId, name, duration, @@ -312,7 +323,7 @@ export function buildUploadAudioElement({ trimStart: 0, trimEnd: 0, sourceDuration: duration, - volume: 1, + volume: DEFAULTS.element.volume, muted: false, }; if (buffer) { @@ -375,7 +386,7 @@ export function buildLibraryAudioElement({ trimStart: 0, trimEnd: 0, sourceDuration: duration, - volume: 1, + volume: DEFAULTS.element.volume, muted: false, }; if (buffer) { @@ -407,7 +418,7 @@ export function getElementsAtTime({ return result; } -export function collectFontFamilies({ +export function getElementFontFamilies({ tracks, }: { tracks: TimelineTrack[]; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 887ff7ab..29414722 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,5 +1,7 @@ -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "./types"; +export * from "./types"; +export * from "./drag"; export * from "./track-utils"; export * from "./track-element-update"; export * from "./element-utils"; diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts index 5f37bf29..369759f3 100644 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -1,4 +1,4 @@ -import type { TimelineElement } from "@/types/timeline"; +import type { TimelineElement } from "@/lib/timeline"; export function rippleShiftElements({ elements, diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts new file mode 100644 index 00000000..ac527600 --- /dev/null +++ b/apps/web/src/lib/timeline/selection-hit-testing.ts @@ -0,0 +1,133 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; + +type TimelineElementRef = { trackId: string; elementId: string }; + +interface SelectionRectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +function getNormalizedRectangle({ + startPos, + endPos, +}: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + return { + left: Math.min(startPos.x, endPos.x), + top: Math.min(startPos.y, endPos.y), + right: Math.max(startPos.x, endPos.x), + bottom: Math.max(startPos.y, endPos.y), + }; +} + +function getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + const containerRect = container.getBoundingClientRect(); + const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + + const adjustedStart = { + x: startPos.x - containerRect.left + scrollLeft, + y: startPos.y - scrollRect.top + scrollTop, + }; + const adjustedEnd = { + x: endPos.x - containerRect.left + scrollLeft, + y: endPos.y - scrollRect.top + scrollTop, + }; + + return getNormalizedRectangle({ + startPos: adjustedStart, + endPos: adjustedEnd, + }); +} + +function isRectangleIntersecting({ + elementRectangle, + selectionRectangle, +}: { + elementRectangle: SelectionRectangle; + selectionRectangle: SelectionRectangle; +}): boolean { + return !( + elementRectangle.right < selectionRectangle.left || + elementRectangle.left > selectionRectangle.right || + elementRectangle.bottom < selectionRectangle.top || + elementRectangle.top > selectionRectangle.bottom + ); +} + +export function resolveTimelineElementIntersections({ + container, + scrollContainer, + tracks, + zoomLevel, + startPos, + currentPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + tracks: TimelineTrack[]; + zoomLevel: number; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): TimelineElementRef[] { + const selectionRectangle = getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos: currentPos, + }); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const selectedElements: TimelineElementRef[] = []; + + for (const [trackIndex, track] of tracks.entries()) { + const trackTop = getCumulativeHeightBefore({ + tracks, + trackIndex, + }); + const trackHeight = getTrackHeight({ type: track.type }); + const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; + const elementBottom = elementTop + trackHeight; + + for (const element of track.elements) { + const elementLeft = element.startTime * pixelsPerSecond; + const elementRight = elementLeft + element.duration * pixelsPerSecond; + const elementRectangle = { + left: elementLeft, + top: elementTop, + right: elementRight, + bottom: elementBottom, + }; + + if ( + isRectangleIntersecting({ + elementRectangle, + selectionRectangle, + }) + ) { + selectedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + return selectedElements; +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index c7fac090..ce2e9b1f 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,4 +1,4 @@ -import type { Bookmark, TimelineTrack } from "@/types/timeline"; +import type { Bookmark, TimelineTrack } from "@/lib/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; import { getElementKeyframes } from "@/lib/animation"; diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 6118b327..5230d4e7 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,4 +1,4 @@ -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; export function updateElementInTracks({ tracks, diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 94ff21ba..cde2c814 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -4,13 +4,15 @@ import type { ElementType, VideoTrack, AudioTrack, - StickerTrack, + GraphicTrack, TextTrack, EffectTrack, TimelineElement, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { + ELEMENT_TRACK_MAP, TRACK_CONFIG, + ELEMENT_TYPE_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; import { generateUUID } from "@/utils/id"; @@ -23,16 +25,12 @@ export function canTracktHaveAudio( export function canTrackBeHidden( track: TimelineTrack, -): track is VideoTrack | TextTrack | StickerTrack | EffectTrack { +): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { return track.type !== "audio"; } -export function getTrackColor({ type }: { type: TrackType }) { - return TRACK_CONFIG[type]; -} - -export function getTrackClasses({ type }: { type: TrackType }) { - return TRACK_CONFIG[type].background.trim(); +export function getElementClasses({ type }: { type: TrackType }) { + return ELEMENT_TYPE_CONFIG[type].background.trim(); } export function getTrackHeight({ type }: { type: TrackType }): number { @@ -97,11 +95,11 @@ export function buildEmptyTrack({ elements: [], hidden: false, }; - case "sticker": + case "graphic": return { id, name: trackName, - type: "sticker", + type: "graphic", elements: [], hidden: false, }; @@ -214,14 +212,7 @@ export function canElementGoOnTrack({ elementType: ElementType; trackType: TrackType; }): boolean { - if (elementType === "text") return trackType === "text"; - if (elementType === "audio") return trackType === "audio"; - if (elementType === "sticker") return trackType === "sticker"; - if (elementType === "effect") return trackType === "effect"; - if (elementType === "video" || elementType === "image") { - return trackType === "video"; - } - return false; + return ELEMENT_TRACK_MAP[elementType] === trackType; } export function validateElementTrackCompatibility({ diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts new file mode 100644 index 00000000..16e06bf1 --- /dev/null +++ b/apps/web/src/lib/timeline/types.ts @@ -0,0 +1,304 @@ +import type { ElementAnimations } from "@/lib/animation/types"; +import type { Effect } from "@/lib/effects/types"; +import type { Mask } from "@/lib/masks/types"; +import type { ParamValues } from "@/lib/params"; +import type { BlendMode, Transform } from "@/lib/rendering"; + +export type ElementRef = { + trackId: string; + elementId: string; +}; + +export interface Bookmark { + time: number; + note?: string; + color?: string; + duration?: number; +} + +export interface TScene { + id: string; + name: string; + isMain: boolean; + tracks: TimelineTrack[]; + bookmarks: Bookmark[]; + createdAt: Date; + updatedAt: Date; +} + +export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; + +interface BaseTrack { + id: string; + name: string; +} + +export interface VideoTrack extends BaseTrack { + type: "video"; + elements: (VideoElement | ImageElement)[]; + isMain: boolean; + muted: boolean; + hidden: boolean; +} + +export interface TextTrack extends BaseTrack { + type: "text"; + elements: TextElement[]; + hidden: boolean; +} + +export interface AudioTrack extends BaseTrack { + type: "audio"; + elements: AudioElement[]; + muted: boolean; +} + +export interface GraphicTrack extends BaseTrack { + type: "graphic"; + elements: (StickerElement | GraphicElement)[]; + hidden: boolean; +} + +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | GraphicTrack + | EffectTrack; + +export interface RetimeConfig { + rate: number; + maintainPitch?: boolean; +} + +interface BaseAudioElement extends BaseTimelineElement { + type: "audio"; + volume: number; + muted?: boolean; + buffer?: AudioBuffer; + retime?: RetimeConfig; +} + +export interface UploadAudioElement extends BaseAudioElement { + sourceType: "upload"; + mediaId: string; +} + +export interface LibraryAudioElement extends BaseAudioElement { + sourceType: "library"; + sourceUrl: string; +} + +export type AudioElement = UploadAudioElement | LibraryAudioElement; + +interface BaseTimelineElement { + id: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + animations?: ElementAnimations; +} + +export interface VideoElement extends BaseTimelineElement { + type: "video"; + mediaId: string; + volume?: number; + muted?: boolean; + hidden?: boolean; + retime?: RetimeConfig; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface ImageElement extends BaseTimelineElement { + type: "image"; + mediaId: string; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} + +export interface TextElement extends BaseTimelineElement { + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + background: TextBackground; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + letterSpacing?: number; + lineHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface StickerElement extends BaseTimelineElement { + type: "sticker"; + stickerId: string; + /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ + intrinsicWidth?: number; + intrinsicHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface GraphicElement extends BaseTimelineElement { + type: "graphic"; + definitionId: string; + params: ParamValues; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: ParamValues; +} + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + +export type TimelineElement = + | AudioElement + | VideoElement + | ImageElement + | TextElement + | StickerElement + | GraphicElement + | EffectElement; + +export type ElementType = TimelineElement["type"]; + +function elementTypes<T extends ElementType[]>(...types: T): T { + return types; +} + +export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); + +export type MaskableElement = Extract< + TimelineElement, + { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } +>; + +export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); + +export type RetimableElement = Extract< + TimelineElement, + { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } +>; + +export const VISUAL_ELEMENT_TYPES = elementTypes( + "video", + "image", + "text", + "sticker", + "graphic", +); + +export type VisualElement = Extract< + TimelineElement, + { type: (typeof VISUAL_ELEMENT_TYPES)[number] } +>; + +export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">; +export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit<VideoElement, "id">; +export type CreateImageElement = Omit<ImageElement, "id">; +export type CreateTextElement = Omit<TextElement, "id">; +export type CreateStickerElement = Omit<StickerElement, "id">; +export type CreateGraphicElement = Omit<GraphicElement, "id">; +export type CreateEffectElement = Omit<EffectElement, "id">; +export type CreateTimelineElement = + | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateStickerElement + | CreateGraphicElement + | CreateEffectElement; + +export interface ElementDragState { + isDragging: boolean; + elementId: string | null; + trackId: string | null; + startMouseX: number; + startMouseY: number; + startElementTime: number; + clickOffsetTime: number; + currentTime: number; + currentMouseY: number; +} + +export interface DropTarget { + trackIndex: number; + isNewTrack: boolean; + insertPosition: "above" | "below" | null; + xPosition: number; + targetElement: { elementId: string; trackId: string } | null; +} + +export interface ComputeDropTargetParams { + elementType: ElementType; + mouseX: number; + mouseY: number; + tracks: TimelineTrack[]; + playheadTime: number; + isExternalDrop: boolean; + elementDuration: number; + pixelsPerSecond: number; + zoomLevel: number; + verticalDragDirection?: "up" | "down" | null; + startTimeOverride?: number; + excludeElementId?: string; + targetElementTypes?: string[]; +} + +export interface ClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} |
