summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/element-utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline/element-utils.ts')
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts304
1 files changed, 304 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
new file mode 100644
index 00000000..a167e548
--- /dev/null
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -0,0 +1,304 @@
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type {
+ CreateTimelineElement,
+ CreateVideoElement,
+ CreateImageElement,
+ CreateStickerElement,
+ CreateUploadAudioElement,
+ CreateLibraryAudioElement,
+ TextElement,
+ TimelineElement,
+ TimelineTrack,
+ AudioElement,
+ VideoElement,
+ ImageElement,
+ StickerElement,
+ UploadAudioElement,
+} from "@/types/timeline";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VideoElement | ImageElement | TextElement | StickerElement {
+ return element.type !== "audio";
+}
+
+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 checkElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): boolean {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+
+ for (let i = 0; i < sortedElements.length - 1; i++) {
+ const current = sortedElements[i];
+ const next = sortedElements[i + 1];
+
+ const currentEnd = current.startTime + current.duration;
+
+ if (currentEnd > next.startTime) return true;
+ }
+
+ return false;
+}
+
+export function resolveElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): TimelineElement[] {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+ const resolvedElements: TimelineElement[] = [];
+
+ for (let i = 0; i < sortedElements.length; i++) {
+ const current = { ...sortedElements[i] };
+
+ if (resolvedElements.length > 0) {
+ const previous = resolvedElements[resolvedElements.length - 1];
+ const previousEnd = previous.startTime + previous.duration;
+
+ if (current.startTime < previousEnd) {
+ current.startTime = previousEnd;
+ }
+ }
+
+ resolvedElements.push(current);
+ }
+
+ return resolvedElements;
+}
+
+export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((el) => {
+ if (excludeElementId && el.id === excludeElementId) return false;
+ const elEnd = el.startTime + el.duration;
+ return startTime < elEnd && endTime > el.startTime;
+ });
+}
+
+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 ?? DEFAULT_TEXT_ELEMENT.name,
+ content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
+ duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize:
+ typeof t.fontSize === "number"
+ ? t.fontSize
+ : DEFAULT_TEXT_ELEMENT.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
+ color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
+ backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
+ textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
+ transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
+ opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
+ };
+}
+
+export function buildStickerElement({
+ iconName,
+ startTime,
+}: {
+ iconName: string;
+ startTime: number;
+}): CreateStickerElement {
+ return {
+ type: "sticker",
+ name: iconName.split(":")[1] || iconName,
+ iconName,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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,
+ muted: false,
+ hidden: false,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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,
+ volume: 1,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+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,
+ volume: 1,
+ 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;
+}