diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
| commit | 49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch) | |
| tree | 1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/lib/timeline | |
| parent | 9b94f89def4f96836c4f42a23a73e97fe647a43c (diff) | |
| download | opencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz | |
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties.
Includes animation engine (interpolation, mutations, resolvers), timeline
markers with selection/snapping, properties panel toggles, keyframe-aware
renderer, and full undo/redo command support.
Also refactor element command constructors to object params, extract
timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/pixel-utils.ts | 79 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/snap-utils.ts | 27 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-element-update.ts | 33 |
5 files changed, 139 insertions, 8 deletions
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index c51667bf..9e191833 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -18,7 +18,7 @@ import type { AudioElement, VideoElement, ImageElement, - StickerElement, + VisualElement, UploadAudioElement, } from "@/types/timeline"; import type { MediaType } from "@/types/assets"; @@ -31,7 +31,7 @@ export function canElementHaveAudio( export function isVisualElement( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { +): element is VisualElement { return ( element.type === "video" || element.type === "image" || @@ -42,7 +42,7 @@ export function isVisualElement( export function canElementBeHidden( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { +): element is VisualElement { return element.type !== "audio"; } diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index f3ace94f..c7236887 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,9 +1,11 @@ import type { TimelineTrack } from "@/types/timeline"; export * from "./track-utils"; +export * from "./track-element-update"; export * from "./element-utils"; export * from "./zoom-utils"; export * from "./ruler-utils"; +export * from "./pixel-utils"; export function calculateTotalDuration({ tracks, diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts new file mode 100644 index 00000000..4c0c243c --- /dev/null +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index a29737d0..c7fac090 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,10 +1,11 @@ import type { Bookmark, TimelineTrack } from "@/types/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"; + type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; elementId?: string; trackId?: string; } @@ -26,6 +27,7 @@ export function findSnapPoints({ enableElementSnapping = true, enablePlayheadSnapping = true, enableBookmarkSnapping = true, + enableKeyframeSnapping = true, }: { tracks: Array<TimelineTrack>; playheadTime: number; @@ -35,13 +37,15 @@ export function findSnapPoints({ enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { snapPoints.push( { time: element.startTime, @@ -57,6 +61,19 @@ export function findSnapPoints({ }, ); } + + 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, + }); + } + } } } diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts new file mode 100644 index 00000000..6118b327 --- /dev/null +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -0,0 +1,33 @@ +import type { TimelineElement, TimelineTrack } from "@/types/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; + }); +} |
