425 lines
9.0 KiB
TypeScript
425 lines
9.0 KiB
TypeScript
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
|
import {
|
|
DEFAULT_BLEND_MODE,
|
|
DEFAULT_OPACITY,
|
|
DEFAULT_TRANSFORM,
|
|
TIMELINE_CONSTANTS,
|
|
} from "@/constants/timeline-constants";
|
|
import type {
|
|
CreateEffectElement,
|
|
CreateTimelineElement,
|
|
CreateVideoElement,
|
|
CreateImageElement,
|
|
CreateStickerElement,
|
|
CreateUploadAudioElement,
|
|
CreateLibraryAudioElement,
|
|
TextBackground,
|
|
TextElement,
|
|
TimelineElement,
|
|
TimelineTrack,
|
|
AudioElement,
|
|
VideoElement,
|
|
ImageElement,
|
|
VisualElement,
|
|
UploadAudioElement,
|
|
} from "@/types/timeline";
|
|
import type { MediaType } from "@/types/assets";
|
|
import { buildDefaultEffectInstance } from "@/lib/effects";
|
|
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 (
|
|
element.type === "video" ||
|
|
element.type === "image" ||
|
|
element.type === "text" ||
|
|
element.type === "sticker"
|
|
);
|
|
}
|
|
|
|
export function canElementBeHidden(
|
|
element: TimelineElement,
|
|
): element is VisualElement {
|
|
return isVisualElement(element);
|
|
}
|
|
|
|
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((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 ?? DEFAULT_TEXT_ELEMENT.background.color;
|
|
const enabled =
|
|
typeof raw?.enabled === "boolean" ? 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 ?? 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,
|
|
background: buildTextBackground(t.background),
|
|
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
|
|
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
|
|
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
|
|
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
|
|
letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing,
|
|
lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight,
|
|
transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
|
|
opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
|
|
blendMode: t.blendMode ?? DEFAULT_BLEND_MODE,
|
|
};
|
|
}
|
|
|
|
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,
|
|
}: {
|
|
stickerId: string;
|
|
name?: string;
|
|
startTime: number;
|
|
}): CreateStickerElement {
|
|
const stickerNameFromId =
|
|
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
|
|
return {
|
|
type: "sticker",
|
|
name: name ?? stickerNameFromId,
|
|
stickerId,
|
|
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
|
startTime,
|
|
trimStart: 0,
|
|
trimEnd: 0,
|
|
transform: { ...DEFAULT_TRANSFORM },
|
|
opacity: DEFAULT_OPACITY,
|
|
blendMode: DEFAULT_BLEND_MODE,
|
|
};
|
|
}
|
|
|
|
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,
|
|
sourceDuration: duration,
|
|
muted: false,
|
|
hidden: false,
|
|
transform: { ...DEFAULT_TRANSFORM },
|
|
opacity: DEFAULT_OPACITY,
|
|
blendMode: DEFAULT_BLEND_MODE,
|
|
};
|
|
}
|
|
|
|
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: { ...DEFAULT_TRANSFORM },
|
|
opacity: DEFAULT_OPACITY,
|
|
blendMode: DEFAULT_BLEND_MODE,
|
|
};
|
|
}
|
|
|
|
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,
|
|
sourceDuration: duration,
|
|
volume: 1,
|
|
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: 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;
|
|
}
|
|
|
|
export function collectFontFamilies({
|
|
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];
|
|
}
|