diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/lib/timeline | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/audio-constants.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/audio-state.ts | 200 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/bookmarks.ts | 298 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/defaults.ts | 154 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drag-utils.ts | 38 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drag.ts | 84 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drop-utils.ts | 628 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 870 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 56 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/pixel-utils.ts | 158 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/ripple-utils.ts | 34 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/selection-hit-testing.ts | 266 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/snap-utils.ts | 344 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-element-update.ts | 66 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-utils.ts | 594 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/types.ts | 608 |
16 files changed, 2201 insertions, 2201 deletions
diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts index 44592df4..f5bb8a3e 100644 --- a/apps/web/src/lib/timeline/audio-constants.ts +++ b/apps/web/src/lib/timeline/audio-constants.ts @@ -1,2 +1,2 @@ -export const VOLUME_DB_MIN = -60; -export const VOLUME_DB_MAX = 20; +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 index 4bc3a7e1..28e381c2 100644 --- a/apps/web/src/lib/timeline/audio-state.ts +++ b/apps/web/src/lib/timeline/audio-state.ts @@ -1,100 +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 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; -} +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 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 f5c034c7..31bf73af 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,149 +1,149 @@ -import type { Bookmark } from "@/lib/timeline"; -import { roundToFrame } from "@/lib/time"; - -export const BOOKMARK_TIME_EPSILON = 0.001; - -function bookmarkTimeEqual({ - bookmarkTime, - frameTime, -}: { - bookmarkTime: number; - frameTime: number; -}): boolean { - return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; -} - -export function findBookmarkIndex({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): number { - return bookmarks.findIndex((bookmark) => - bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function isBookmarkAtTime({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): boolean { - return bookmarks.some((bookmark) => - bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function toggleBookmarkInArray({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark[] { - const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); - - if (bookmarkIndex !== -1) { - return bookmarks.filter((_, index) => index !== bookmarkIndex); - } - - const newBookmarks = [...bookmarks, { time: frameTime }]; - return newBookmarks.slice().sort((a, b) => a.time - b.time); -} - -export function removeBookmarkFromArray({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark[] { - return bookmarks.filter( - (bookmark) => - !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function updateBookmarkInArray({ - bookmarks, - frameTime, - updates, -}: { - bookmarks: Bookmark[]; - frameTime: number; - updates: Partial<Omit<Bookmark, "time">>; -}): Bookmark[] { - const index = findBookmarkIndex({ bookmarks, frameTime }); - if (index === -1) { - return bookmarks; - } - - const updated = { ...bookmarks[index], ...updates }; - const result = [...bookmarks]; - result[index] = updated; - return result; -} - -export function moveBookmarkInArray({ - bookmarks, - fromTime, - toTime, -}: { - bookmarks: Bookmark[]; - fromTime: number; - toTime: number; -}): Bookmark[] { - const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); - if (index === -1) { - return bookmarks; - } - - const updated = { ...bookmarks[index], time: toTime }; - const result = [...bookmarks]; - result[index] = updated; - return result.slice().sort((a, b) => a.time - b.time); -} - -export function getFrameTime({ - time, - fps, -}: { - time: number; - fps: number; -}): number { - return roundToFrame({ time, fps }); -} - -export function getBookmarkAtTime({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark | null { - const index = findBookmarkIndex({ bookmarks, frameTime }); - return index === -1 ? null : bookmarks[index]; -} - -export function getBookmarksActiveAtTime({ - bookmarks, - time, -}: { - bookmarks: Bookmark[]; - time: number; -}): Bookmark[] { - return bookmarks.filter((bookmark) => { - const start = bookmark.time; - const end = - bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration - : start; - return ( - time >= start - BOOKMARK_TIME_EPSILON && - time <= end + BOOKMARK_TIME_EPSILON - ); - }); -} +import type { Bookmark } from "@/lib/timeline";
+import { roundToFrame } from "@/lib/time";
+
+export const BOOKMARK_TIME_EPSILON = 0.001;
+
+function bookmarkTimeEqual({
+ bookmarkTime,
+ frameTime,
+}: {
+ bookmarkTime: number;
+ frameTime: number;
+}): boolean {
+ return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
+}
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, index) => index !== bookmarkIndex);
+ }
+
+ const newBookmarks = [...bookmarks, { time: frameTime }];
+ return newBookmarks.slice().sort((a, b) => a.time - b.time);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ return bookmarks.filter(
+ (bookmark) =>
+ !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], ...updates };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result;
+}
+
+export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+}: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], time: toTime };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result.slice().sort((a, b) => a.time - b.time);
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return roundToFrame({ time, fps });
+}
+
+export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark | null {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ return index === -1 ? null : bookmarks[index];
+}
+
+export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+}): Bookmark[] {
+ return bookmarks.filter((bookmark) => {
+ const start = bookmark.time;
+ const end =
+ bookmark.duration != null && bookmark.duration > 0
+ ? start + bookmark.duration
+ : start;
+ return (
+ time >= start - BOOKMARK_TIME_EPSILON &&
+ time <= end + BOOKMARK_TIME_EPSILON
+ );
+ });
+}
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts index 7f52905f..01dbe912 100644 --- a/apps/web/src/lib/timeline/defaults.ts +++ b/apps/web/src/lib/timeline/defaults.ts @@ -1,77 +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, - }, -}; +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-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts index 43d896ae..cf59a20d 100644 --- a/apps/web/src/lib/timeline/drag-utils.ts +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -1,19 +1,19 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export function getMouseTimeFromClientX({ - clientX, - containerRect, - zoomLevel, - scrollLeft, -}: { - clientX: number; - containerRect: DOMRect; - zoomLevel: number; - scrollLeft: number; -}): number { - const mouseX = clientX - containerRect.left + scrollLeft; - return Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ); -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ return Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ );
+}
diff --git a/apps/web/src/lib/timeline/drag.ts b/apps/web/src/lib/timeline/drag.ts index 89b1593f..6e74e17c 100644 --- a/apps/web/src/lib/timeline/drag.ts +++ b/apps/web/src/lib/timeline/drag.ts @@ -1,42 +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; +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 d8206474..cba8c65e 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,314 +1,314 @@ -import type { - TimelineTrack, - ElementType, - TimelineElement, -} from "@/lib/timeline"; -import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; -import { wouldElementOverlap } from "./element-utils"; -import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; -import { - canElementGoOnTrack, - isMainTrack, - enforceMainTrackStart, -} from "./track-utils"; - -function findElementAtPosition({ - mouseX, - tracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, -}: { - mouseX: number; - tracks: TimelineTrack[]; - trackIndex: number; - targetElementTypes: string[]; - pixelsPerSecond: number; - zoomLevel: number; -}): { elementId: string; trackId: string } | null { - const time = mouseX / (pixelsPerSecond * zoomLevel); - const track = tracks[trackIndex]; - if (!track || !("elements" in track)) return null; - - const hit = track.elements.find( - (element: TimelineElement) => - targetElementTypes.includes(element.type) && - element.startTime <= time && - time < element.startTime + element.duration, - ); - if (!hit) return null; - return { elementId: hit.id, trackId: track.id }; -} - -function getTrackAtY({ - mouseY, - tracks, - verticalDragDirection, -}: { - mouseY: number; - tracks: TimelineTrack[]; - verticalDragDirection?: "up" | "down" | null; -}): { trackIndex: number; relativeY: number } | null { - let cumulativeHeight = 0; - - for (let i = 0; i < tracks.length; i++) { - const trackHeight = TRACK_CONFIG[tracks[i].type].height; - const trackTop = cumulativeHeight; - const trackBottom = trackTop + trackHeight; - - if (mouseY >= trackTop && mouseY < trackBottom) { - return { - trackIndex: i, - relativeY: mouseY - trackTop, - }; - } - - if (i < tracks.length - 1 && verticalDragDirection) { - const gapTop = trackBottom; - const gapBottom = gapTop + TRACK_GAP; - if (mouseY >= gapTop && mouseY < gapBottom) { - const isDraggingUp = verticalDragDirection === "up"; - return { - trackIndex: isDraggingUp ? i : i + 1, - relativeY: isDraggingUp ? trackHeight - 1 : 0, - }; - } - } - - cumulativeHeight += trackHeight + TRACK_GAP; - } - - return null; -} - -function isCompatible({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TimelineTrack["type"]; -}): boolean { - return canElementGoOnTrack({ elementType, trackType }); -} - -function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number { - return tracks.findIndex((track) => isMainTrack(track)); -} - -function findInsertIndex({ - elementType, - tracks, - preferredIndex, - insertAbove, -}: { - elementType: ElementType; - tracks: TimelineTrack[]; - preferredIndex: number; - insertAbove: boolean; -}): { index: number; position: "above" | "below" } { - const mainTrackIndex = getMainTrackIndex({ tracks }); - - if (elementType === "audio") { - if (preferredIndex <= mainTrackIndex) { - return { index: mainTrackIndex + 1, position: "below" }; - } - return { - index: insertAbove ? preferredIndex : preferredIndex + 1, - position: insertAbove ? "above" : "below", - }; - } - - const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1; - - if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) { - return { index: mainTrackIndex, position: "above" }; - } - - return { - index: overlayInsertIndex, - position: insertAbove ? "above" : "below", - }; -} - -const EMPTY_TARGET_ELEMENT = null; - -export function computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks, - playheadTime, - isExternalDrop, - elementDuration, - pixelsPerSecond, - zoomLevel, - verticalDragDirection, - startTimeOverride, - excludeElementId, - targetElementTypes, -}: ComputeDropTargetParams): DropTarget { - const xPosition = - typeof startTimeOverride === "number" - ? startTimeOverride - : isExternalDrop - ? playheadTime - : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); - - const mainTrackIndex = getMainTrackIndex({ tracks }); - - if (tracks.length === 0) { - if (elementType === "audio") { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: null, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); - - if (!trackAtMouse) { - const isAboveAllTracks = mouseY < 0; - - if (elementType === "audio") { - return { - trackIndex: tracks.length, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - if (isAboveAllTracks) { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: "above", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - return { - trackIndex: Math.max(0, mainTrackIndex), - isNewTrack: true, - insertPosition: "above", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const { trackIndex, relativeY } = trackAtMouse; - const track = tracks[trackIndex]; - - if ( - targetElementTypes && - targetElementTypes.length > 0 - ) { - const targetElement = findElementAtPosition({ - mouseX, - tracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, - }); - if (targetElement) { - return { - trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition, - targetElement, - }; - } - } - - const trackHeight = TRACK_CONFIG[track.type].height; - const isInUpperHalf = relativeY < trackHeight / 2; - - const isTrackCompatible = isCompatible({ - elementType, - trackType: track.type, - }); - - const endTime = xPosition + elementDuration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: xPosition, - endTime, - excludeElementId, - }); - - if (isTrackCompatible && !hasOverlap) { - const targetTrack = tracks[trackIndex]; - // safe: snap to 0 only happens when element becomes the new earliest, - // meaning the space before the current earliest is empty - const adjustedXPosition = enforceMainTrackStart({ - tracks, - targetTrackId: targetTrack.id, - requestedStartTime: xPosition, - excludeElementId, - }); - - return { - trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition: adjustedXPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - let insertAbove = isInUpperHalf; - if (!isTrackCompatible && verticalDragDirection) { - insertAbove = verticalDragDirection === "up"; - } - - const { index, position } = findInsertIndex({ - elementType, - tracks, - preferredIndex: trackIndex, - insertAbove, - }); - - return { - trackIndex: index, - isNewTrack: true, - insertPosition: position, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; -} - -export function getDropLineY({ - dropTarget, - tracks, -}: { - dropTarget: DropTarget; - tracks: TimelineTrack[]; -}): number { - const safeTrackIndex = Math.min( - Math.max(dropTarget.trackIndex, 0), - tracks.length, - ); - let y = 0; - - for (let i = 0; i < safeTrackIndex; i++) { - y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP; - } - - return y; -} +import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/lib/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
+import { wouldElementOverlap } from "./element-utils";
+import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
+import {
+ canElementGoOnTrack,
+ isMainTrack,
+ enforceMainTrackStart,
+} from "./track-utils";
+
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
+function getTrackAtY({
+ mouseY,
+ tracks,
+ verticalDragDirection,
+}: {
+ mouseY: number;
+ tracks: TimelineTrack[];
+ verticalDragDirection?: "up" | "down" | null;
+}): { trackIndex: number; relativeY: number } | null {
+ let cumulativeHeight = 0;
+
+ for (let i = 0; i < tracks.length; i++) {
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
+ const trackTop = cumulativeHeight;
+ const trackBottom = trackTop + trackHeight;
+
+ if (mouseY >= trackTop && mouseY < trackBottom) {
+ return {
+ trackIndex: i,
+ relativeY: mouseY - trackTop,
+ };
+ }
+
+ if (i < tracks.length - 1 && verticalDragDirection) {
+ const gapTop = trackBottom;
+ const gapBottom = gapTop + TRACK_GAP;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TRACK_GAP;
+ }
+
+ return null;
+}
+
+function isCompatible({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TimelineTrack["type"];
+}): boolean {
+ return canElementGoOnTrack({ elementType, trackType });
+}
+
+function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
+ return tracks.findIndex((track) => isMainTrack(track));
+}
+
+function findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex,
+ insertAbove,
+}: {
+ elementType: ElementType;
+ tracks: TimelineTrack[];
+ preferredIndex: number;
+ insertAbove: boolean;
+}): { index: number; position: "above" | "below" } {
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (elementType === "audio") {
+ if (preferredIndex <= mainTrackIndex) {
+ return { index: mainTrackIndex + 1, position: "below" };
+ }
+ return {
+ index: insertAbove ? preferredIndex : preferredIndex + 1,
+ position: insertAbove ? "above" : "below",
+ };
+ }
+
+ const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
+
+ if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
+ return { index: mainTrackIndex, position: "above" };
+ }
+
+ return {
+ index: overlayInsertIndex,
+ position: insertAbove ? "above" : "below",
+ };
+}
+
+const EMPTY_TARGET_ELEMENT = null;
+
+export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+ targetElementTypes,
+}: ComputeDropTargetParams): DropTarget {
+ const xPosition =
+ typeof startTimeOverride === "number"
+ ? startTimeOverride
+ : isExternalDrop
+ ? playheadTime
+ : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
+
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (tracks.length === 0) {
+ if (elementType === "audio") {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
+
+ if (!trackAtMouse) {
+ const isAboveAllTracks = mouseY < 0;
+
+ if (elementType === "audio") {
+ return {
+ trackIndex: tracks.length,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ if (isAboveAllTracks) {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ return {
+ trackIndex: Math.max(0, mainTrackIndex),
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const { trackIndex, relativeY } = trackAtMouse;
+ const track = tracks[trackIndex];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
+ const isInUpperHalf = relativeY < trackHeight / 2;
+
+ const isTrackCompatible = isCompatible({
+ elementType,
+ trackType: track.type,
+ });
+
+ const endTime = xPosition + elementDuration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: xPosition,
+ endTime,
+ excludeElementId,
+ });
+
+ if (isTrackCompatible && !hasOverlap) {
+ const targetTrack = tracks[trackIndex];
+ // safe: snap to 0 only happens when element becomes the new earliest,
+ // meaning the space before the current earliest is empty
+ const adjustedXPosition = enforceMainTrackStart({
+ tracks,
+ targetTrackId: targetTrack.id,
+ requestedStartTime: xPosition,
+ excludeElementId,
+ });
+
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ let insertAbove = isInUpperHalf;
+ if (!isTrackCompatible && verticalDragDirection) {
+ insertAbove = verticalDragDirection === "up";
+ }
+
+ const { index, position } = findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex: trackIndex,
+ insertAbove,
+ });
+
+ return {
+ trackIndex: index,
+ isNewTrack: true,
+ insertPosition: position,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+}
+
+export function getDropLineY({
+ dropTarget,
+ tracks,
+}: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
+}): number {
+ const safeTrackIndex = Math.min(
+ Math.max(dropTarget.trackIndex, 0),
+ tracks.length,
+ );
+ let y = 0;
+
+ for (let i = 0; i < safeTrackIndex; i++) {
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index c44ae044..19b821be 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,435 +1,435 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { - 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( - element: TimelineElement, -): element is AudioElement | VideoElement { - return element.type === "audio" || element.type === "video"; -} - -export function isVisualElement( - element: TimelineElement, -): element is VisualElement { - 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( - element: TimelineElement, -): element is VisualElement { - 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 { - return "mediaId" in element; -} - -export function requiresMediaId({ - element, -}: { - element: CreateTimelineElement; -}): boolean { - return ( - element.type === "video" || - element.type === "image" || - (element.type === "audio" && element.sourceType === "upload") - ); -} - -export function wouldElementOverlap({ - elements, - startTime, - endTime, - excludeElementId, -}: { - elements: TimelineElement[]; - startTime: number; - endTime: number; - excludeElementId?: string; -}): boolean { - return elements.some((element) => { - if (excludeElementId && element.id === excludeElementId) return false; - const elementEnd = element.startTime + element.duration; - return startTime < elementEnd && endTime > element.startTime; - }); -} - -function buildTextBackground( - raw: Partial<TextBackground> | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; -} - -export function buildTextElement({ - raw, - startTime, -}: { - raw: Partial<Omit<TextElement, "type" | "id">>; - startTime: number; -}): CreateTimelineElement { - const t = raw as Partial<TextElement>; - - return { - type: "text", - 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: 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 ?? 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, - }; -} - -export function buildEffectElement({ - effectType, - startTime, - duration, -}: { - effectType: string; - startTime: number; - duration?: number; -}): CreateEffectElement { - const instance = buildDefaultEffectInstance({ effectType }); - return { - type: "effect", - name: capitalizeFirstLetter({ string: instance.type }), - effectType, - params: instance.params, - duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - }; -} - -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; - return { - 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: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildVideoElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateVideoElement { - return { - type: "video", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - muted: false, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, - }; -} - -function buildImageElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateImageElement { - return { - type: "image", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateUploadAudioElement { - const element: CreateUploadAudioElement = { - type: "audio", - sourceType: "upload", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function buildElementFromMedia({ - mediaId, - mediaType, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - mediaType: MediaType; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateTimelineElement { - switch (mediaType) { - case "audio": - return buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, - }); - case "video": - return buildVideoElement({ mediaId, name, duration, startTime }); - case "image": - return buildImageElement({ mediaId, name, duration, startTime }); - } -} - -export function buildLibraryAudioElement({ - sourceUrl, - name, - duration, - startTime, - buffer, -}: { - sourceUrl: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateLibraryAudioElement { - const element: CreateLibraryAudioElement = { - type: "audio", - sourceType: "library", - sourceUrl, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function getElementsAtTime({ - tracks, - time, -}: { - tracks: TimelineTrack[]; - time: number; -}): { trackId: string; elementId: string }[] { - const result: { trackId: string; elementId: string }[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - const elementStart = element.startTime; - const elementEnd = element.startTime + element.duration; - - if (time > elementStart && time < elementEnd) { - result.push({ trackId: track.id, elementId: element.id }); - } - } - } - - return result; -} - -export function getElementFontFamilies({ - tracks, -}: { - tracks: TimelineTrack[]; -}): string[] { - const families = new Set<string>(); - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); - } - } - } - return [...families]; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ 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(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function isVisualElement(
+ element: TimelineElement,
+): element is VisualElement {
+ 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(
+ element: TimelineElement,
+): element is VisualElement {
+ 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 {
+ return "mediaId" in element;
+}
+
+export function requiresMediaId({
+ element,
+}: {
+ element: CreateTimelineElement;
+}): boolean {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ (element.type === "audio" && element.sourceType === "upload")
+ );
+}
+
+export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((element) => {
+ if (excludeElementId && element.id === excludeElementId) return false;
+ const elementEnd = element.startTime + element.duration;
+ return startTime < elementEnd && endTime > element.startTime;
+ });
+}
+
+function buildTextBackground(
+ raw: Partial<TextBackground> | undefined,
+): TextBackground {
+ const color = raw?.color ?? DEFAULTS.text.element.background.color;
+ const enabled = raw?.enabled ?? color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
+export function buildTextElement({
+ raw,
+ startTime,
+}: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
+}): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
+
+ return {
+ type: "text",
+ 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: 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 ?? 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,
+ };
+}
+
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
+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;
+ return {
+ 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: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateVideoElement {
+ return {
+ type: "video",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ muted: false,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ volume: DEFAULTS.element.volume,
+ };
+}
+
+function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateImageElement {
+ return {
+ type: "image",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateUploadAudioElement {
+ const element: CreateUploadAudioElement = {
+ type: "audio",
+ sourceType: "upload",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateTimelineElement {
+ switch (mediaType) {
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ });
+ case "video":
+ return buildVideoElement({ mediaId, name, duration, startTime });
+ case "image":
+ return buildImageElement({ mediaId, name, duration, startTime });
+ }
+}
+
+export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateLibraryAudioElement {
+ const element: CreateLibraryAudioElement = {
+ type: "audio",
+ sourceType: "library",
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: TimelineTrack[];
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
+
+export function getElementFontFamilies({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): string[] {
+ const families = new Set<string>();
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "text" && element.fontFamily) {
+ families.add(element.fontFamily);
+ }
+ }
+ }
+ return [...families];
+}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 29414722..d4337833 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,28 +1,28 @@ -import type { TimelineTrack } from "./types"; - -export * from "./types"; -export * from "./drag"; -export * from "./track-utils"; -export * from "./track-element-update"; -export * from "./element-utils"; -export * from "./zoom-utils"; -export * from "./ruler-utils"; -export * from "./ripple-utils"; -export * from "./pixel-utils"; - -export function calculateTotalDuration({ - tracks, -}: { - tracks: TimelineTrack[]; -}): number { - if (tracks.length === 0) return 0; - - const trackEndTimes = tracks.map((track) => - track.elements.reduce((maxEnd, element) => { - const elementEnd = element.startTime + element.duration; - return Math.max(maxEnd, elementEnd); - }, 0), - ); - - return Math.max(...trackEndTimes, 0); -} +import type { TimelineTrack } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-utils";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./ripple-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): number {
+ if (tracks.length === 0) return 0;
+
+ const trackEndTimes = tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd = element.startTime + element.duration;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
+
+ return Math.max(...trackEndTimes, 0);
+}
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts index 4c0c243c..c4635e52 100644 --- a/apps/web/src/lib/timeline/pixel-utils.ts +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -1,79 +1,79 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; - -function getDevicePixelRatio({ - devicePixelRatio, -}: { - devicePixelRatio?: number; -}): number { - if ( - typeof devicePixelRatio === "number" && - Number.isFinite(devicePixelRatio) && - devicePixelRatio > 0 - ) { - return devicePixelRatio; - } - - if (typeof window === "undefined") { - return 1; - } - - if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { - return window.devicePixelRatio; - } - - return 1; -} - -export function getTimelinePixelsPerSecond({ - zoomLevel, -}: { - zoomLevel: number; -}): number { - return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; -} - -export function timelineTimeToPixels({ - time, - zoomLevel, -}: { - time: number; - zoomLevel: number; -}): number { - return time * getTimelinePixelsPerSecond({ zoomLevel }); -} - -export function snapPixelToDeviceGrid({ - pixel, - devicePixelRatio, -}: { - pixel: number; - devicePixelRatio?: number; -}): number { - const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); - return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; -} - -export function timelineTimeToSnappedPixels({ - time, - zoomLevel, - devicePixelRatio, -}: { - time: number; - zoomLevel: number; - devicePixelRatio?: number; -}): number { - const rawPixel = timelineTimeToPixels({ time, zoomLevel }); - return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); -} - -export function getCenteredLineLeft({ - centerPixel, - lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, -}: { - centerPixel: number; - lineWidthPx?: number; -}): number { - return centerPixel - lineWidthPx / 2; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
+
+function getDevicePixelRatio({
+ devicePixelRatio,
+}: {
+ devicePixelRatio?: number;
+}): number {
+ if (
+ typeof devicePixelRatio === "number" &&
+ Number.isFinite(devicePixelRatio) &&
+ devicePixelRatio > 0
+ ) {
+ return devicePixelRatio;
+ }
+
+ if (typeof window === "undefined") {
+ return 1;
+ }
+
+ if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
+ return window.devicePixelRatio;
+ }
+
+ return 1;
+}
+
+export function getTimelinePixelsPerSecond({
+ zoomLevel,
+}: {
+ zoomLevel: number;
+}): number {
+ return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+}
+
+export function timelineTimeToPixels({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}): number {
+ return time * getTimelinePixelsPerSecond({ zoomLevel });
+}
+
+export function snapPixelToDeviceGrid({
+ pixel,
+ devicePixelRatio,
+}: {
+ pixel: number;
+ devicePixelRatio?: number;
+}): number {
+ const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
+ return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
+}
+
+export function timelineTimeToSnappedPixels({
+ time,
+ zoomLevel,
+ devicePixelRatio,
+}: {
+ time: number;
+ zoomLevel: number;
+ devicePixelRatio?: number;
+}): number {
+ const rawPixel = timelineTimeToPixels({ time, zoomLevel });
+ return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
+}
+
+export function getCenteredLineLeft({
+ centerPixel,
+ lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
+}: {
+ centerPixel: number;
+ lineWidthPx?: number;
+}): number {
+ return centerPixel - lineWidthPx / 2;
+}
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts index 369759f3..5e17e9a3 100644 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -1,17 +1,17 @@ -import type { TimelineElement } from "@/lib/timeline"; - -export function rippleShiftElements({ - elements, - afterTime, - shiftAmount, -}: { - elements: TimelineElement[]; - afterTime: number; - shiftAmount: number; -}): TimelineElement[] { - return elements.map((element) => - element.startTime >= afterTime - ? { ...element, startTime: element.startTime - shiftAmount } - : element, - ); -} +import type { TimelineElement } from "@/lib/timeline";
+
+export function rippleShiftElements({
+ elements,
+ afterTime,
+ shiftAmount,
+}: {
+ elements: TimelineElement[];
+ afterTime: number;
+ shiftAmount: number;
+}): TimelineElement[] {
+ return elements.map((element) =>
+ element.startTime >= afterTime
+ ? { ...element, startTime: element.startTime - shiftAmount }
+ : element,
+ );
+}
diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts index ac527600..3c0f597e 100644 --- a/apps/web/src/lib/timeline/selection-hit-testing.ts +++ b/apps/web/src/lib/timeline/selection-hit-testing.ts @@ -1,133 +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; -} +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 ce2e9b1f..2efe24e4 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,172 +1,172 @@ -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"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -const DEFAULT_SNAP_THRESHOLD_PX = 10; - -export function findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks = [], - excludeBookmarkTime, - enableElementSnapping = true, - enablePlayheadSnapping = true, - enableBookmarkSnapping = true, - enableKeyframeSnapping = true, -}: { - tracks: Array<TimelineTrack>; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array<Bookmark>; - excludeBookmarkTime?: number; - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; - enableBookmarkSnapping?: boolean; - enableKeyframeSnapping?: boolean; -}): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; - - if (enableElementSnapping) { - snapPoints.push( - { - time: element.startTime, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: element.startTime + element.duration, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - } - - if (enableKeyframeSnapping) { - for (const keyframe of getElementKeyframes({ - animations: element.animations, - })) { - snapPoints.push({ - time: element.startTime + keyframe.time, - type: "keyframe", - elementId: element.id, - trackId: track.id, - }); - } - } - } - } - - if (enablePlayheadSnapping) { - snapPoints.push({ time: playheadTime, type: "playhead" }); - } - - if (enableBookmarkSnapping) { - for (const bookmark of bookmarks) { - if ( - excludeBookmarkTime != null && - Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON - ) { - continue; - } - snapPoints.push({ time: bookmark.time, type: "bookmark" }); - } - } - - return snapPoints; -} - -export function snapToNearestPoint({ - targetTime, - snapPoints, - zoomLevel, - snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, -}: { - targetTime: number; - snapPoints: Array<SnapPoint>; - zoomLevel: number; - snapThreshold?: number; -}): SnapResult { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - for (const snapPoint of snapPoints) { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - } - - return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; -} - -export function snapElementEdge({ - targetTime, - elementDuration, - tracks, - playheadTime, - zoomLevel, - excludeElementId, - snapToStart = true, - bookmarks = [], -}: { - targetTime: number; - elementDuration: number; - tracks: Array<TimelineTrack>; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array<Bookmark>; -}): SnapResult { - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks, - }); - - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - - const snapResult = snapToNearestPoint({ - targetTime: effectiveTargetTime, - snapPoints, - zoomLevel, - }); - - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; -} +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";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+const DEFAULT_SNAP_THRESHOLD_PX = 10;
+
+export function findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
+}: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+
+ if (enableKeyframeSnapping) {
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({ time: playheadTime, type: "playhead" });
+ }
+
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({ time: bookmark.time, type: "bookmark" });
+ }
+ }
+
+ return snapPoints;
+}
+
+export function snapToNearestPoint({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
+}: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ snapThreshold?: number;
+}): SnapResult {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
+
+export function snapElementEdge({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ bookmarks = [],
+}: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
+}): SnapResult {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 5230d4e7..19765923 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,33 +1,33 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; - -export function updateElementInTracks({ - tracks, - trackId, - elementId, - update, - elementPredicate, -}: { - tracks: TimelineTrack[]; - trackId: string; - elementId: string; - update: (element: TimelineElement) => TimelineElement; - elementPredicate?: (element: TimelineElement) => boolean; -}): TimelineTrack[] { - return tracks.map((track) => { - if (track.id !== trackId) { - return track; - } - - const nextElements = track.elements.map((element) => { - if (element.id !== elementId) { - return element; - } - if (elementPredicate && !elementPredicate(element)) { - return element; - } - return update(element); - }); - - return { ...track, elements: nextElements } as TimelineTrack; - }); -} +import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+
+export function updateElementInTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: TimelineTrack[];
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): TimelineTrack[] {
+ return tracks.map((track) => {
+ if (track.id !== trackId) {
+ return track;
+ }
+
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
+ }
+ return update(element);
+ });
+
+ return { ...track, elements: nextElements } as TimelineTrack;
+ });
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index cde2c814..0034c72e 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -1,297 +1,297 @@ -import type { - TrackType, - TimelineTrack, - ElementType, - VideoTrack, - AudioTrack, - GraphicTrack, - TextTrack, - EffectTrack, - TimelineElement, -} from "@/lib/timeline"; -import { - ELEMENT_TRACK_MAP, - TRACK_CONFIG, - ELEMENT_TYPE_CONFIG, - TRACK_GAP, -} from "@/constants/timeline-constants"; -import { generateUUID } from "@/utils/id"; - -export function canTracktHaveAudio( - track: TimelineTrack, -): track is VideoTrack | AudioTrack { - return track.type === "audio" || track.type === "video"; -} - -export function canTrackBeHidden( - track: TimelineTrack, -): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { - return track.type !== "audio"; -} - -export function getElementClasses({ type }: { type: TrackType }) { - return ELEMENT_TYPE_CONFIG[type].background.trim(); -} - -export function getTrackHeight({ type }: { type: TrackType }): number { - return TRACK_CONFIG[type].height; -} - -export function getCumulativeHeightBefore({ - tracks, - trackIndex, -}: { - tracks: Array<{ type: TrackType }>; - trackIndex: number; -}): number { - return tracks - .slice(0, trackIndex) - .reduce( - (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP, - 0, - ); -} - -export function getTotalTracksHeight({ - tracks, -}: { - tracks: Array<{ type: TrackType }>; -}): number { - const tracksHeight = tracks.reduce( - (sum, track) => sum + getTrackHeight({ type: track.type }), - 0, - ); - const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP; - return tracksHeight + gapsHeight; -} - -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: TrackType; - name?: string; -}): TimelineTrack { - const trackName = name ?? TRACK_CONFIG[type].defaultName; - - switch (type) { - case "video": - return { - id, - name: trackName, - type: "video", - elements: [], - hidden: false, - muted: false, - isMain: false, - }; - case "text": - return { - id, - name: trackName, - type: "text", - elements: [], - hidden: false, - }; - case "graphic": - return { - id, - name: trackName, - type: "graphic", - elements: [], - hidden: false, - }; - case "audio": - return { - id, - name: trackName, - type: "audio", - elements: [], - muted: false, - }; - case "effect": - return { - id, - name: trackName, - type: "effect", - elements: [], - hidden: false, - }; - default: - throw new Error(`Unsupported track type: ${type}`); - } -} - -export function getDefaultInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - if (trackType === "audio") { - return tracks.length; - } - - if (trackType === "effect") { - return 0; - } - - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex( - (track) => track.type === "audio", - ); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; -} - -export function getHighestInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - - if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; - } - - return 0; -} - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - const hasMainTrack = tracks.some((track) => isMainTrack(track)); - - if (!hasMainTrack) { - const mainTrack: TimelineTrack = { - id: generateUUID(), - name: "Main Track", - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }; - return [mainTrack, ...tracks]; - } - - return tracks; -} - -export function canElementGoOnTrack({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TrackType; -}): boolean { - return ELEMENT_TRACK_MAP[elementType] === trackType; -} - -export function validateElementTrackCompatibility({ - element, - track, -}: { - element: { type: ElementType }; - track: { type: TrackType }; -}): { isValid: boolean; errorMessage?: string } { - const isValid = canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }); - - if (!isValid) { - return { - isValid: false, - errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`, - }; - } - - return { isValid: true }; -} - -export function getEarliestMainTrackElement({ - tracks, - excludeElementId, -}: { - tracks: TimelineTrack[]; - excludeElementId?: string; -}): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - - const elements = mainTrack.elements.filter( - (element) => !excludeElementId || element.id !== excludeElementId, - ); - - if (elements.length === 0) { - return null; - } - - return elements.reduce((earliest, element) => - element.startTime < earliest.startTime ? element : earliest, - ); -} - -export function enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime, - excludeElementId, -}: { - tracks: TimelineTrack[]; - targetTrackId: string; - requestedStartTime: number; - excludeElementId?: string; -}): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { - return requestedStartTime; - } - - const earliestElement = getEarliestMainTrackElement({ - tracks, - excludeElementId, - }); - - if (!earliestElement) { - return 0; - } - - // main track must always start at time 0; if this element would - // become the earliest, pin it to the start - if (requestedStartTime <= earliestElement.startTime) { - return 0; - } - - return requestedStartTime; -} +import type {
+ TrackType,
+ TimelineTrack,
+ ElementType,
+ VideoTrack,
+ AudioTrack,
+ GraphicTrack,
+ TextTrack,
+ EffectTrack,
+ TimelineElement,
+} from "@/lib/timeline";
+import {
+ ELEMENT_TRACK_MAP,
+ TRACK_CONFIG,
+ ELEMENT_TYPE_CONFIG,
+ TRACK_GAP,
+} from "@/constants/timeline-constants";
+import { generateUUID } from "@/utils/id";
+
+export function canTracktHaveAudio(
+ track: TimelineTrack,
+): track is VideoTrack | AudioTrack {
+ return track.type === "audio" || track.type === "video";
+}
+
+export function canTrackBeHidden(
+ track: TimelineTrack,
+): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
+ return track.type !== "audio";
+}
+
+export function getElementClasses({ type }: { type: TrackType }) {
+ return ELEMENT_TYPE_CONFIG[type].background.trim();
+}
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TRACK_CONFIG[type].height;
+}
+
+export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
+}): number {
+ return tracks
+ .slice(0, trackIndex)
+ .reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
+ 0,
+ );
+}
+
+export function getTotalTracksHeight({
+ tracks,
+}: {
+ tracks: Array<{ type: TrackType }>;
+}): number {
+ const tracksHeight = tracks.reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }),
+ 0,
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
+ return tracksHeight + gapsHeight;
+}
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack {
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
+
+ switch (type) {
+ case "video":
+ return {
+ id,
+ name: trackName,
+ type: "video",
+ elements: [],
+ hidden: false,
+ muted: false,
+ isMain: false,
+ };
+ case "text":
+ return {
+ id,
+ name: trackName,
+ type: "text",
+ elements: [],
+ hidden: false,
+ };
+ case "graphic":
+ return {
+ id,
+ name: trackName,
+ type: "graphic",
+ elements: [],
+ hidden: false,
+ };
+ case "audio":
+ return {
+ id,
+ name: trackName,
+ type: "audio",
+ elements: [],
+ muted: false,
+ };
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
+ default:
+ throw new Error(`Unsupported track type: ${type}`);
+ }
+}
+
+export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.length;
+ }
+
+ if (trackType === "effect") {
+ return 0;
+ }
+
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+ if (mainTrackIndex >= 0) {
+ return mainTrackIndex;
+ }
+
+ const firstAudioTrackIndex = tracks.findIndex(
+ (track) => track.type === "audio",
+ );
+ if (firstAudioTrackIndex >= 0) {
+ return firstAudioTrackIndex;
+ }
+
+ return tracks.length;
+}
+
+export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+
+ if (trackType === "audio") {
+ return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
+ }
+
+ return 0;
+}
+
+export function isMainTrack(track: TimelineTrack): track is VideoTrack {
+ return track.type === "video" && track.isMain === true;
+}
+
+export function getMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack | null {
+ return tracks.find((track) => isMainTrack(track)) ?? null;
+}
+
+export function ensureMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack[] {
+ const hasMainTrack = tracks.some((track) => isMainTrack(track));
+
+ if (!hasMainTrack) {
+ const mainTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Main Track",
+ type: "video",
+ elements: [],
+ muted: false,
+ isMain: true,
+ hidden: false,
+ };
+ return [mainTrack, ...tracks];
+ }
+
+ return tracks;
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ return ELEMENT_TRACK_MAP[elementType] === trackType;
+}
+
+export function validateElementTrackCompatibility({
+ element,
+ track,
+}: {
+ element: { type: ElementType };
+ track: { type: TrackType };
+}): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ });
+
+ if (!isValid) {
+ return {
+ isValid: false,
+ errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+export function getEarliestMainTrackElement({
+ tracks,
+ excludeElementId,
+}: {
+ tracks: TimelineTrack[];
+ excludeElementId?: string;
+}): TimelineElement | null {
+ const mainTrack = getMainTrack({ tracks });
+ if (!mainTrack) {
+ return null;
+ }
+
+ const elements = mainTrack.elements.filter(
+ (element) => !excludeElementId || element.id !== excludeElementId,
+ );
+
+ if (elements.length === 0) {
+ return null;
+ }
+
+ return elements.reduce((earliest, element) =>
+ element.startTime < earliest.startTime ? element : earliest,
+ );
+}
+
+export function enforceMainTrackStart({
+ tracks,
+ targetTrackId,
+ requestedStartTime,
+ excludeElementId,
+}: {
+ tracks: TimelineTrack[];
+ targetTrackId: string;
+ requestedStartTime: number;
+ excludeElementId?: string;
+}): number {
+ const mainTrack = getMainTrack({ tracks });
+ if (!mainTrack || mainTrack.id !== targetTrackId) {
+ return requestedStartTime;
+ }
+
+ const earliestElement = getEarliestMainTrackElement({
+ tracks,
+ excludeElementId,
+ });
+
+ if (!earliestElement) {
+ return 0;
+ }
+
+ // main track must always start at time 0; if this element would
+ // become the earliest, pin it to the start
+ if (requestedStartTime <= earliestElement.startTime) {
+ return 0;
+ }
+
+ return requestedStartTime;
+}
diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index 16e06bf1..69d363c7 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -1,304 +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; -} +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;
+}
|
