From 3093cbe06444593d766def700a9627586d9a70e4 Mon Sep 17 00:00:00 2001 From: Joery <44531907+Joery-M@users.noreply.github.com> Date: Wed, 17 Apr 2024 22:03:44 +0200 Subject: [PATCH 1/5] feat: Added basic timelineitem move functions Implemented some basic methods for moving timeline items --- .../shared/src/Timeline/SimpleTimeline.ts | 17 +++++ packages/shared/src/base/TimelineItem.ts | 67 +++++++++++++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/Timeline/SimpleTimeline.ts b/packages/shared/src/Timeline/SimpleTimeline.ts index 2abbd9c4..1297f3c6 100644 --- a/packages/shared/src/Timeline/SimpleTimeline.ts +++ b/packages/shared/src/Timeline/SimpleTimeline.ts @@ -23,5 +23,22 @@ export default class SimpleTimeline extends BaseTimeline { this.framerate = 60; } + /** + * Called when an item is dropped in the timeline + */ + public itemDropped(otherItem: BaseTimelineItem) { + this.items + .filter( + (i) => + i.layer.value === otherItem.layer.value && + i.end.value > otherItem.start.value && + i.start.value < otherItem.end.value && + i.id !== otherItem.id + ) + .forEach((item) => { + item.onDroppedOn(otherItem); + }); + } + updateDuration() {} } diff --git a/packages/shared/src/base/TimelineItem.ts b/packages/shared/src/base/TimelineItem.ts index de1cc201..a361e242 100644 --- a/packages/shared/src/base/TimelineItem.ts +++ b/packages/shared/src/base/TimelineItem.ts @@ -1,23 +1,78 @@ -import type AudioTimelineItem from '../TimelineItem/AudioTimelineItem'; -import type VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; import { v4 as uuidv4 } from 'uuid'; import { ref } from 'vue'; +import type SimpleTimeline from '../Timeline/SimpleTimeline'; +import type AudioTimelineItem from '../TimelineItem/AudioTimelineItem'; +import type VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; export default abstract class BaseTimelineItem { public id = uuidv4(); - public layer = 0; public type: TimelineItemType = 'Base'; + private lastStart = ref(0); + private lastEnd = ref(0); + private lastLayer = ref(0); + public start = ref(0); public end = ref(0); + public layer = ref(0); + + // Might want to change this to BaseTimeline depending on + // what happens with in terms of other types of timelines + private timeline!: SimpleTimeline; + + /** + * Whether this timeline item is a ghost. + * + * Ghosts are used for when an item is being dragged + * and shouldn't interact with surrounding items. + */ + public isGhost = ref(false); + + public onMoveStart() { + this.isGhost.value = true; + this.lastStart.value = this.start.value; + this.lastEnd.value = this.end.value; + this.lastLayer.value = this.layer.value; + } + /** + * @param offset Offset in milliseconds + */ + public onMoveDrag(offset: number) { + this.start.value += offset; + } + public onMoveCancel() { + this.start.value = this.lastStart.value; + this.end.value = this.lastEnd.value; + this.layer.value = this.lastLayer.value; + this.isGhost.value = false; + } + public onDrop() { + this.isGhost.value = false; + + this.timeline.itemDropped(this); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onDroppedOn(otherItem: BaseTimelineItem) { + // TODO: Implement behavior for when an item is dropped on the current item + } - public onMove(newStart: number) { - this.start.value = newStart; + /** + * Move this timeline item to a specific millisecond value programmatically + * + * @param time New millisecond time to move to. + */ + public MoveTo(time: number) { + this.start.value = time; + this.isGhost.value = false; + this.timeline.itemDropped(this); } isBaseTimelineItem = (): this is BaseTimelineItem => this.type === 'Base'; isVideo = (): this is VideoTimelineItem => this.type === 'Video'; + // Implement isAudio = (): this is AudioTimelineItem => this.type === 'Audio'; + // Implement + isImage = (): this is AudioTimelineItem => this.type === 'Image'; } -export type TimelineItemType = 'Base' | 'Video' | 'Audio' | 'EffectLayer'; +export type TimelineItemType = 'Base' | 'Video' | 'Audio' | 'Image' | 'EffectLayer'; From 1aa4f7ab905d8dfc2b7ded6c90e87bdd3f564231 Mon Sep 17 00:00:00 2001 From: Joery <44531907+Joery-M@users.noreply.github.com> Date: Wed, 17 Apr 2024 23:45:24 +0200 Subject: [PATCH 2/5] feat: Started implementing createTimelineItem Half finished mediaToVideoTimelineItem. Added MaybePromise and MaybePromiseResult types. Added some props to videoTimelineItem --- packages/shared/src/Media/Media.ts | 30 ++++++++++++++++++- .../src/TimelineItem/VideoTimelineItem.ts | 9 ++++-- packages/shared/src/base/TimelineItem.ts | 2 ++ packages/shared/types/MaybePromise.d.ts | 13 ++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/shared/types/MaybePromise.d.ts diff --git a/packages/shared/src/Media/Media.ts b/packages/shared/src/Media/Media.ts index e7f5e7ac..1e2e0a39 100644 --- a/packages/shared/src/Media/Media.ts +++ b/packages/shared/src/Media/Media.ts @@ -1,7 +1,10 @@ +import type { DateTime } from 'luxon'; import { type MediaInfoType } from 'mediainfo.js'; import { ref } from 'vue'; import MissingThumbnailUrl from '../../assets/missing_thumbnail.png?url'; -import type { DateTime } from 'luxon'; +import type { MaybePromiseResult } from '../../types/MaybePromise'; +import type BaseTimelineItem from '../base/TimelineItem'; +import VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; // Not sure if refs are needed here, might want to look at this in the future. @@ -53,6 +56,27 @@ export default class Media { return (this.type & totalType) == totalType; } + + static timelineItemCreators: MediaToTimelineItemFunc[] = [mediaToVideoTimelineItem]; + + async createTimelineItem(): Promise { + const results = await Promise.all(Media.timelineItemCreators.map((f) => f(this))); + return results.filter((i) => i !== undefined).flat(1) as T[]; + } +} + +function mediaToVideoTimelineItem(media: Media) { + if (media.isOfType(MediaType.Video)) { + return media.videoTracks.map((track, i) => { + const tItem = new VideoTimelineItem(); + tItem.media.value = media; + tItem.trackInfo.value = track; + // TODO: get sleep and implement difference between audio timeline items and video timeline items + tItem.layer.value = i - 1; + tItem.duration.value = track.duration; + return tItem; + }); + } } export enum MediaType { @@ -105,3 +129,7 @@ export interface ImageInfo { export interface TextTrackInfo { format: string; } + +export type MediaToTimelineItemFunc = ( + media: Media +) => MaybePromiseResult; diff --git a/packages/shared/src/TimelineItem/VideoTimelineItem.ts b/packages/shared/src/TimelineItem/VideoTimelineItem.ts index e21b5269..b6490b73 100644 --- a/packages/shared/src/TimelineItem/VideoTimelineItem.ts +++ b/packages/shared/src/TimelineItem/VideoTimelineItem.ts @@ -1,10 +1,15 @@ -import { shallowRef } from 'vue'; -import Media from '../Media/Media'; +import { ref, shallowRef } from 'vue'; +import Media, { type VideoTrackInfo } from '../Media/Media'; import BaseTimelineItem, { type TimelineItemType } from '../base/TimelineItem'; export default class VideoTimelineItem extends BaseTimelineItem { public type: TimelineItemType = 'Video'; public media = shallowRef(); + public startOffset = ref(0); + public duration = ref(0); + + public trackInfo = ref(); + public RenderVideoFrame() {} } diff --git a/packages/shared/src/base/TimelineItem.ts b/packages/shared/src/base/TimelineItem.ts index a361e242..4f24d69e 100644 --- a/packages/shared/src/base/TimelineItem.ts +++ b/packages/shared/src/base/TimelineItem.ts @@ -8,6 +8,8 @@ export default abstract class BaseTimelineItem { public id = uuidv4(); public type: TimelineItemType = 'Base'; + public name = ref(''); + private lastStart = ref(0); private lastEnd = ref(0); private lastLayer = ref(0); diff --git a/packages/shared/types/MaybePromise.d.ts b/packages/shared/types/MaybePromise.d.ts new file mode 100644 index 00000000..5a2b9bab --- /dev/null +++ b/packages/shared/types/MaybePromise.d.ts @@ -0,0 +1,13 @@ +/** + * Gives the Promise + non-Promise variant of a function + * + * @example MaybePromise<(x: number, y: string | number) => Date> = + * ((x: number, y: string | number) => Date) | ((x: number, y: string | number) => Promise) + * + * @author [COlda](https://gist.github.com/CCColda/4251981e652164b4195ec78bcefbb8b7) + */ +export type MaybePromise any> = + | T + | (T extends (...args: infer A) => infer R ? (...args: A) => Promise : never); + +export type MaybePromiseResult = T | Promise; From 8213cca593356509ff863a8947c380dc5da7417e Mon Sep 17 00:00:00 2001 From: Joery Date: Thu, 18 Apr 2024 17:02:51 +0200 Subject: [PATCH 3/5] feat: Added partial support for linked items Also added some workspace extensions --- .vscode/extensions.json | 4 +++- packages/shared/src/base/TimelineItem.ts | 27 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index aa20c6ec..3afc69ef 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,8 @@ "recommendations": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "vivaxy.vscode-conventional-commits", + "vue.volar" ] } diff --git a/packages/shared/src/base/TimelineItem.ts b/packages/shared/src/base/TimelineItem.ts index 4f24d69e..043af321 100644 --- a/packages/shared/src/base/TimelineItem.ts +++ b/packages/shared/src/base/TimelineItem.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; -import { ref } from 'vue'; +import { ref, shallowReactive } from 'vue'; import type SimpleTimeline from '../Timeline/SimpleTimeline'; import type AudioTimelineItem from '../TimelineItem/AudioTimelineItem'; import type VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; @@ -10,6 +10,8 @@ export default abstract class BaseTimelineItem { public name = ref(''); + public linkedItems = shallowReactive(new Set()); + private lastStart = ref(0); private lastEnd = ref(0); private lastLayer = ref(0); @@ -35,22 +37,45 @@ export default abstract class BaseTimelineItem { this.lastStart.value = this.start.value; this.lastEnd.value = this.end.value; this.lastLayer.value = this.layer.value; + + this.linkedItems.forEach((item) => { + item.isGhost.value = true; + item.lastStart.value = item.start.value; + item.lastEnd.value = item.end.value; + item.lastLayer.value = item.layer.value; + }); } /** * @param offset Offset in milliseconds */ public onMoveDrag(offset: number) { this.start.value += offset; + + this.linkedItems.forEach((item) => { + item.start.value += offset; + }); } public onMoveCancel() { this.start.value = this.lastStart.value; this.end.value = this.lastEnd.value; this.layer.value = this.lastLayer.value; this.isGhost.value = false; + + this.linkedItems.forEach((item) => { + item.start.value = item.lastStart.value; + item.end.value = item.lastEnd.value; + item.layer.value = item.lastLayer.value; + item.isGhost.value = false; + }); } public onDrop() { this.isGhost.value = false; + this.linkedItems.forEach((item) => { + item.isGhost.value = false; + }); + + this.linkedItems.forEach(this.timeline.itemDropped); this.timeline.itemDropped(this); } // eslint-disable-next-line @typescript-eslint/no-unused-vars From 3db178dda0f1cc783f149bbef4cdafe4317a5d65 Mon Sep 17 00:00:00 2001 From: Joery <44531907+Joery-M@users.noreply.github.com> Date: Thu, 18 Apr 2024 22:02:12 +0200 Subject: [PATCH 4/5] feat: Updated createTimelineItems, Implemented linkedItems and usesMedia Added audio to createTimelineItems. Added linkedItems. Added usesMedia. Changed SimpleTimeline a bit. Removed specific properties from Timeline BaseTimeline no longer has width, height and duration properties --- packages/shared/src/Decoder/AVdecoder.ts | 0 packages/shared/src/Media/Media.ts | 41 ++++++++++++++++--- packages/shared/src/Project/SimpleProject.ts | 4 ++ .../shared/src/Timeline/SimpleTimeline.ts | 25 ++++++----- .../src/TimelineItem/AudioTimelineItem.ts | 15 ++++++- .../src/TimelineItem/VideoTimelineItem.ts | 11 ++++- packages/shared/src/base/Timeline.ts | 5 --- packages/shared/src/base/TimelineItem.ts | 19 ++++++++- 8 files changed, 95 insertions(+), 25 deletions(-) delete mode 100644 packages/shared/src/Decoder/AVdecoder.ts diff --git a/packages/shared/src/Decoder/AVdecoder.ts b/packages/shared/src/Decoder/AVdecoder.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/shared/src/Media/Media.ts b/packages/shared/src/Media/Media.ts index 1e2e0a39..0000bc58 100644 --- a/packages/shared/src/Media/Media.ts +++ b/packages/shared/src/Media/Media.ts @@ -4,6 +4,7 @@ import { ref } from 'vue'; import MissingThumbnailUrl from '../../assets/missing_thumbnail.png?url'; import type { MaybePromiseResult } from '../../types/MaybePromise'; import type BaseTimelineItem from '../base/TimelineItem'; +import AudioTimelineItem from '../TimelineItem/AudioTimelineItem'; import VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; // Not sure if refs are needed here, might want to look at this in the future. @@ -59,24 +60,52 @@ export default class Media { static timelineItemCreators: MediaToTimelineItemFunc[] = [mediaToVideoTimelineItem]; - async createTimelineItem(): Promise { - const results = await Promise.all(Media.timelineItemCreators.map((f) => f(this))); - return results.filter((i) => i !== undefined).flat(1) as T[]; + async createTimelineItems(): Promise { + const results = (await Promise.all(Media.timelineItemCreators.map((f) => f(this)))) + .filter((i) => i !== undefined) + .flat(1); + + // Link all items together so they move with each other + results.forEach((item) => { + results.forEach((itemToLink) => { + if (item !== itemToLink) { + item.linkedItems.add(itemToLink); + } + }); + }); + + return results as T[]; } } function mediaToVideoTimelineItem(media: Media) { + const allItems = [] as (VideoTimelineItem | AudioTimelineItem)[]; if (media.isOfType(MediaType.Video)) { - return media.videoTracks.map((track, i) => { + const videoItems = media.videoTracks.map((track, i) => { const tItem = new VideoTimelineItem(); tItem.media.value = media; tItem.trackInfo.value = track; - // TODO: get sleep and implement difference between audio timeline items and video timeline items - tItem.layer.value = i - 1; + tItem.layer.value = media.audioTracks.length + i; tItem.duration.value = track.duration; return tItem; }); + + allItems.push(...videoItems); } + if (media.isOfType(MediaType.Audio)) { + const audioItems = media.audioTracks.map((track, i) => { + const tItem = new AudioTimelineItem(); + tItem.media.value = media; + tItem.trackInfo.value = track; + tItem.layer.value = i; + tItem.duration.value = track.duration; + return tItem; + }); + + allItems.push(...audioItems); + } + + return allItems; } export enum MediaType { diff --git a/packages/shared/src/Project/SimpleProject.ts b/packages/shared/src/Project/SimpleProject.ts index b09ecb04..20aa2d2e 100644 --- a/packages/shared/src/Project/SimpleProject.ts +++ b/packages/shared/src/Project/SimpleProject.ts @@ -14,4 +14,8 @@ export default class SimpleProject extends BaseProject { public selectedTimelineIndex = ref(0); public timelines = shallowReactive([]); public timeline = computed(() => this.timelines.at(this.selectedTimelineIndex.value)!); + + public usesMedia(media: Media) { + return this.timelines.some((timeline) => timeline.usesMedia(media)); + } } diff --git a/packages/shared/src/Timeline/SimpleTimeline.ts b/packages/shared/src/Timeline/SimpleTimeline.ts index 1297f3c6..08a8ca42 100644 --- a/packages/shared/src/Timeline/SimpleTimeline.ts +++ b/packages/shared/src/Timeline/SimpleTimeline.ts @@ -1,7 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; -import { shallowReactive } from 'vue'; +import { ref, shallowReactive } from 'vue'; import BaseTimeline, { type TimelineType } from '../base/Timeline'; import type BaseTimelineItem from '../base/TimelineItem'; +import type Media from '../Media/Media'; export default class SimpleTimeline extends BaseTimeline { public name = 'Untitled'; @@ -10,17 +11,17 @@ export default class SimpleTimeline extends BaseTimeline { public items = shallowReactive([]); - public duration = 0; - public viewportWidth = 1920; - public viewportWeight = 1080; - public framerate = 60; + public duration = ref(0); + public width = ref(1920); + public height = ref(1080); + public framerate = ref(60); constructor() { super(); - this.duration = 0; - this.width = 1920; - this.height = 1080; - this.framerate = 60; + this.duration.value = 0; + this.width.value = 1920; + this.height.value = 1080; + this.framerate.value = 60; } /** @@ -40,5 +41,9 @@ export default class SimpleTimeline extends BaseTimeline { }); } - updateDuration() {} + public usesMedia(media: Media) { + return this.items.some( + (item) => (item.isVideo() || item.isAudio()) && item.media.value == media + ); + } } diff --git a/packages/shared/src/TimelineItem/AudioTimelineItem.ts b/packages/shared/src/TimelineItem/AudioTimelineItem.ts index b6dfec9e..6d6224f4 100644 --- a/packages/shared/src/TimelineItem/AudioTimelineItem.ts +++ b/packages/shared/src/TimelineItem/AudioTimelineItem.ts @@ -1,8 +1,19 @@ -import { shallowRef } from 'vue'; -import Media from '../Media/Media'; +import { ref, shallowRef } from 'vue'; +import Media, { type AudioTrackInfo } from '../Media/Media'; import BaseTimelineItem, { type TimelineItemType } from '../base/TimelineItem'; export default class AudioTimelineItem extends BaseTimelineItem { public type: TimelineItemType = 'Audio'; public media = shallowRef(); + + /** + * Offset from the start of this timeline item to the start of the audio track. + */ + public startOffset = ref(0); + /** + * Total duration of this audio track. + */ + public duration = ref(0); + + public trackInfo = ref(); } diff --git a/packages/shared/src/TimelineItem/VideoTimelineItem.ts b/packages/shared/src/TimelineItem/VideoTimelineItem.ts index b6490b73..21750eae 100644 --- a/packages/shared/src/TimelineItem/VideoTimelineItem.ts +++ b/packages/shared/src/TimelineItem/VideoTimelineItem.ts @@ -6,10 +6,19 @@ export default class VideoTimelineItem extends BaseTimelineItem { public type: TimelineItemType = 'Video'; public media = shallowRef(); + /** + * Offset from the start of this timeline item to the start of the video track. + */ public startOffset = ref(0); + /** + * Total duration of this video track. + */ public duration = ref(0); public trackInfo = ref(); - public RenderVideoFrame() {} + // TODO: Create a whole extensible rendering engine (lol) + public RenderVideoFrame() { + throw new Error('Not implemented'); + } } diff --git a/packages/shared/src/base/Timeline.ts b/packages/shared/src/base/Timeline.ts index b1979d20..4cfb8913 100644 --- a/packages/shared/src/base/Timeline.ts +++ b/packages/shared/src/base/Timeline.ts @@ -5,11 +5,6 @@ export default abstract class BaseTimeline { public abstract id: string; public type: TimelineType = 'Base'; - public width = 1920; - public height = 1080; - - public abstract duration: number; - isBaseTimeline = (): this is BaseTimeline => this.type == 'Base'; isSimpleTimeline = (): this is SimpleTimeline => this.type == 'Simple'; } diff --git a/packages/shared/src/base/TimelineItem.ts b/packages/shared/src/base/TimelineItem.ts index 043af321..97a2cb1a 100644 --- a/packages/shared/src/base/TimelineItem.ts +++ b/packages/shared/src/base/TimelineItem.ts @@ -50,9 +50,11 @@ export default abstract class BaseTimelineItem { */ public onMoveDrag(offset: number) { this.start.value += offset; + this.end.value += offset; this.linkedItems.forEach((item) => { item.start.value += offset; + item.end.value += offset; }); } public onMoveCancel() { @@ -86,17 +88,32 @@ export default abstract class BaseTimelineItem { /** * Move this timeline item to a specific millisecond value programmatically * + * Note that this will be able to move linked items out of the range of the timeline. + * * @param time New millisecond time to move to. */ public MoveTo(time: number) { + this.linkedItems.forEach((item) => { + // Offset between current item and other item + const offset = item.start.value - this.start.value; + + const startEndOffset = item.end.value - item.start.value; + + item.start.value = time + offset; + item.end.value = time + offset + startEndOffset; + item.isGhost.value = false; + }); + + const startEndOffset = this.end.value - this.start.value; this.start.value = time; + this.end.value = time + startEndOffset; this.isGhost.value = false; + this.timeline.itemDropped(this); } isBaseTimelineItem = (): this is BaseTimelineItem => this.type === 'Base'; isVideo = (): this is VideoTimelineItem => this.type === 'Video'; - // Implement isAudio = (): this is AudioTimelineItem => this.type === 'Audio'; // Implement isImage = (): this is AudioTimelineItem => this.type === 'Image'; From 11244e5d8d48896ee482f7fbd9469252453b9e0d Mon Sep 17 00:00:00 2001 From: Joery Date: Fri, 19 Apr 2024 15:29:19 +0200 Subject: [PATCH 5/5] feat: Implemented hasMedia type helper, timeline creation, item drop interaction Added behaviour for dropping a timeline item on another one. Added creating and selecting timelines. Changed SimpleTimeline to have use a set of media. Added extra type clarity for when an item has media --- packages/shared/src/Media/Media.ts | 2 +- packages/shared/src/Project/SimpleProject.ts | 23 ++++++- .../shared/src/Timeline/SimpleTimeline.ts | 63 ++++++++++++------- .../src/TimelineItem/AudioTimelineItem.ts | 5 +- .../src/TimelineItem/VideoTimelineItem.ts | 5 +- .../shared/src/TimelineItem/interfaces.ts | 7 +++ packages/shared/src/base/Timeline.ts | 3 +- packages/shared/src/base/TimelineItem.ts | 29 ++++++++- 8 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 packages/shared/src/TimelineItem/interfaces.ts diff --git a/packages/shared/src/Media/Media.ts b/packages/shared/src/Media/Media.ts index 0000bc58..bcacd86f 100644 --- a/packages/shared/src/Media/Media.ts +++ b/packages/shared/src/Media/Media.ts @@ -1,5 +1,5 @@ import type { DateTime } from 'luxon'; -import { type MediaInfoType } from 'mediainfo.js'; +import type { MediaInfoType } from 'mediainfo.js'; import { ref } from 'vue'; import MissingThumbnailUrl from '../../assets/missing_thumbnail.png?url'; import type { MaybePromiseResult } from '../../types/MaybePromise'; diff --git a/packages/shared/src/Project/SimpleProject.ts b/packages/shared/src/Project/SimpleProject.ts index 20aa2d2e..18a7f6f2 100644 --- a/packages/shared/src/Project/SimpleProject.ts +++ b/packages/shared/src/Project/SimpleProject.ts @@ -1,8 +1,8 @@ -import SimpleTimeline from '../Timeline/SimpleTimeline'; import { v4 as uuidv4 } from 'uuid'; import { computed, ref, shallowReactive } from 'vue'; -import { default as BaseProject, type ProjectType } from '../base/Project'; +import BaseProject, { type ProjectType } from '../base/Project'; import Media from '../Media/Media'; +import SimpleTimeline, { type SimpleTimelineConfig } from '../Timeline/SimpleTimeline'; export default class SimpleProject extends BaseProject { public id = uuidv4(); @@ -18,4 +18,23 @@ export default class SimpleProject extends BaseProject { public usesMedia(media: Media) { return this.timelines.some((timeline) => timeline.usesMedia(media)); } + + public selectTimeline(timeline: SimpleTimeline) { + const timelineIndex = this.timelines.indexOf(timeline); + if (timelineIndex >= 0) { + this.selectedTimelineIndex.value = timelineIndex; + } + } + + public createTimeline(config: SimpleTimelineConfig, selectWhenCreated = true): SimpleTimeline { + const timeline = new SimpleTimeline(config); + + this.timelines.push(timeline); + + if (selectWhenCreated) { + this.selectTimeline(timeline); + } + + return timeline; + } } diff --git a/packages/shared/src/Timeline/SimpleTimeline.ts b/packages/shared/src/Timeline/SimpleTimeline.ts index 08a8ca42..be3b65ae 100644 --- a/packages/shared/src/Timeline/SimpleTimeline.ts +++ b/packages/shared/src/Timeline/SimpleTimeline.ts @@ -1,49 +1,68 @@ import { v4 as uuidv4 } from 'uuid'; -import { ref, shallowReactive } from 'vue'; +import { computed, ref, shallowReactive } from 'vue'; import BaseTimeline, { type TimelineType } from '../base/Timeline'; import type BaseTimelineItem from '../base/TimelineItem'; import type Media from '../Media/Media'; export default class SimpleTimeline extends BaseTimeline { - public name = 'Untitled'; + public name = ref('Untitled'); public id = uuidv4(); public type: TimelineType = 'Simple'; - public items = shallowReactive([]); + public items = shallowReactive(new Set()); - public duration = ref(0); public width = ref(1920); public height = ref(1080); public framerate = ref(60); + /** + * Duration of a single frame in milliseconds + */ + public frameDuration = computed(() => { + return 1000 / this.framerate.value; + }); - constructor() { + constructor(config: SimpleTimelineConfig) { super(); - this.duration.value = 0; - this.width.value = 1920; - this.height.value = 1080; - this.framerate.value = 60; + if (config.name) { + this.name.value = config.name; + } + this.width.value = config.width; + this.height.value = config.height; + this.framerate.value = config.framerate; } /** * Called when an item is dropped in the timeline */ public itemDropped(otherItem: BaseTimelineItem) { - this.items - .filter( - (i) => - i.layer.value === otherItem.layer.value && - i.end.value > otherItem.start.value && - i.start.value < otherItem.end.value && - i.id !== otherItem.id - ) - .forEach((item) => { + this.items.forEach((item) => { + if ( + item.layer.value === otherItem.layer.value && + item.end.value > otherItem.start.value && + item.start.value < otherItem.end.value && + item.id !== otherItem.id + ) { item.onDroppedOn(otherItem); - }); + } + }); } public usesMedia(media: Media) { - return this.items.some( - (item) => (item.isVideo() || item.isAudio()) && item.media.value == media - ); + for (const item of this.items) { + return item.hasMedia() && item.media.value == media; + } + return false; + } + + public deleteItem(item: BaseTimelineItem) { + item.Delete(); + return this.items.delete(item); } } + +export interface SimpleTimelineConfig { + name?: string; + width: number; + height: number; + framerate: number; +} diff --git a/packages/shared/src/TimelineItem/AudioTimelineItem.ts b/packages/shared/src/TimelineItem/AudioTimelineItem.ts index 6d6224f4..6cf01c43 100644 --- a/packages/shared/src/TimelineItem/AudioTimelineItem.ts +++ b/packages/shared/src/TimelineItem/AudioTimelineItem.ts @@ -1,8 +1,9 @@ import { ref, shallowRef } from 'vue'; import Media, { type AudioTrackInfo } from '../Media/Media'; import BaseTimelineItem, { type TimelineItemType } from '../base/TimelineItem'; +import type { TimelineItemMedia } from './interfaces'; -export default class AudioTimelineItem extends BaseTimelineItem { +export default class AudioTimelineItem extends BaseTimelineItem implements TimelineItemMedia { public type: TimelineItemType = 'Audio'; public media = shallowRef(); @@ -16,4 +17,6 @@ export default class AudioTimelineItem extends BaseTimelineItem { public duration = ref(0); public trackInfo = ref(); + + hasMedia = (): this is typeof this & TimelineItemMedia => true; } diff --git a/packages/shared/src/TimelineItem/VideoTimelineItem.ts b/packages/shared/src/TimelineItem/VideoTimelineItem.ts index 21750eae..9921489f 100644 --- a/packages/shared/src/TimelineItem/VideoTimelineItem.ts +++ b/packages/shared/src/TimelineItem/VideoTimelineItem.ts @@ -1,8 +1,9 @@ import { ref, shallowRef } from 'vue'; import Media, { type VideoTrackInfo } from '../Media/Media'; import BaseTimelineItem, { type TimelineItemType } from '../base/TimelineItem'; +import type { TimelineItemMedia } from './interfaces'; -export default class VideoTimelineItem extends BaseTimelineItem { +export default class VideoTimelineItem extends BaseTimelineItem implements TimelineItemMedia { public type: TimelineItemType = 'Video'; public media = shallowRef(); @@ -21,4 +22,6 @@ export default class VideoTimelineItem extends BaseTimelineItem { public RenderVideoFrame() { throw new Error('Not implemented'); } + + hasMedia = (): this is typeof this & TimelineItemMedia => true; } diff --git a/packages/shared/src/TimelineItem/interfaces.ts b/packages/shared/src/TimelineItem/interfaces.ts new file mode 100644 index 00000000..88094bd8 --- /dev/null +++ b/packages/shared/src/TimelineItem/interfaces.ts @@ -0,0 +1,7 @@ +import type { ShallowRef } from 'vue'; +import type Media from '../Media/Media'; + +export interface TimelineItemMedia { + media: ShallowRef; + hasMedia: () => this is TimelineItemMedia; +} diff --git a/packages/shared/src/base/Timeline.ts b/packages/shared/src/base/Timeline.ts index 4cfb8913..36a5c509 100644 --- a/packages/shared/src/base/Timeline.ts +++ b/packages/shared/src/base/Timeline.ts @@ -1,7 +1,8 @@ +import type { Ref } from 'vue'; import type SimpleTimeline from '../Timeline/SimpleTimeline'; export default abstract class BaseTimeline { - public abstract name: string; + public abstract name: Ref; public abstract id: string; public type: TimelineType = 'Base'; diff --git a/packages/shared/src/base/TimelineItem.ts b/packages/shared/src/base/TimelineItem.ts index 97a2cb1a..3e34866c 100644 --- a/packages/shared/src/base/TimelineItem.ts +++ b/packages/shared/src/base/TimelineItem.ts @@ -3,6 +3,7 @@ import { ref, shallowReactive } from 'vue'; import type SimpleTimeline from '../Timeline/SimpleTimeline'; import type AudioTimelineItem from '../TimelineItem/AudioTimelineItem'; import type VideoTimelineItem from '../TimelineItem/VideoTimelineItem'; +import type { TimelineItemMedia } from '../TimelineItem/interfaces'; export default abstract class BaseTimelineItem { public id = uuidv4(); @@ -80,9 +81,24 @@ export default abstract class BaseTimelineItem { this.linkedItems.forEach(this.timeline.itemDropped); this.timeline.itemDropped(this); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onDroppedOn(otherItem: BaseTimelineItem) { - // TODO: Implement behavior for when an item is dropped on the current item + if (otherItem.end.value > this.start.value && otherItem.start.value < this.start.value) { + // ▼ [====] < Other item + // ▼ [=====] < This item + this.start.value = otherItem.end.value + this.timeline.frameDuration.value; + } else if (otherItem.start.value < this.end.value && otherItem.end.value > this.end.value) { + // ▼ [=====] < Other item + // ▼ [====] < This item + this.end.value = otherItem.start.value - this.timeline.frameDuration.value; + } else if ( + otherItem.start.value <= this.start.value && + otherItem.end.value >= this.end.value + ) { + // ▼ [======] < Other item + // ▼ [====] < This item + this.timeline.deleteItem(this); + } } /** @@ -112,11 +128,20 @@ export default abstract class BaseTimelineItem { this.timeline.itemDropped(this); } + public Delete() { + this.linkedItems.forEach((item) => { + // Delete myself from other linked items + item.linkedItems.delete(this); + }); + } + isBaseTimelineItem = (): this is BaseTimelineItem => this.type === 'Base'; isVideo = (): this is VideoTimelineItem => this.type === 'Video'; isAudio = (): this is AudioTimelineItem => this.type === 'Audio'; // Implement isImage = (): this is AudioTimelineItem => this.type === 'Image'; + + hasMedia = (): this is typeof this & TimelineItemMedia => false; } export type TimelineItemType = 'Base' | 'Video' | 'Audio' | 'Image' | 'EffectLayer';