codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
+61
View File
@@ -0,0 +1,61 @@
import { roundToFrame } from "@/lib/time";
export function findBookmarkIndex({
bookmarks,
frameTime,
}: {
bookmarks: number[];
frameTime: number;
}): number {
return bookmarks.findIndex(
(bookmark) => Math.abs(bookmark - frameTime) < 0.001,
);
}
export function isBookmarkAtTime({
bookmarks,
frameTime,
}: {
bookmarks: number[];
frameTime: number;
}): boolean {
return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001);
}
export function toggleBookmarkInArray({
bookmarks,
frameTime,
}: {
bookmarks: number[];
frameTime: number;
}): number[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
if (bookmarkIndex !== -1) {
return bookmarks.filter((_, i) => i !== bookmarkIndex);
}
return [...bookmarks, frameTime].sort((a, b) => a - b);
}
export function removeBookmarkFromArray({
bookmarks,
frameTime,
}: {
bookmarks: number[];
frameTime: number;
}): number[] {
return bookmarks.filter(
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001,
);
}
export function getFrameTime({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return roundToFrame({ time, fps });
}
+234
View File
@@ -0,0 +1,234 @@
import type { TimelineTrack, ElementType } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack } from "./track-utils";
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_HEIGHTS[tracks[i].type];
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 {
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
return false;
}
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",
};
}
export function computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime,
isExternalDrop,
elementDuration,
pixelsPerSecond,
zoomLevel,
verticalDragDirection,
startTimeOverride,
excludeElementId,
}: 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,
};
}
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
if (elementType === "audio") {
return {
trackIndex: tracks.length,
isNewTrack: true,
insertPosition: "below",
xPosition,
};
}
if (isAboveAllTracks) {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "above",
xPosition,
};
}
return {
trackIndex: Math.max(0, mainTrackIndex),
isNewTrack: true,
insertPosition: "above",
xPosition,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const trackHeight = TRACK_HEIGHTS[track.type];
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) {
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
};
}
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,
};
}
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_HEIGHTS[tracks[i].type] + TRACK_GAP;
}
return y;
}
+304
View File
@@ -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;
}
+23
View File
@@ -0,0 +1,23 @@
import type { TimelineTrack } from "@/types/timeline";
export * from "./track-utils";
export * from "./element-utils";
export * from "./zoom-utils";
export * from "./ruler-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);
}
+228
View File
@@ -0,0 +1,228 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
/**
* Frame intervals for labels - starts at 2 so there's always at least
* one tick between labels even at max zoom.
* Pattern: 2, 3, 5, 10, 15 (matches CapCut)
*/
const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
/**
* Frame intervals for ticks - can go down to 1 for max granularity.
*/
const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
/**
* Second intervals for when we're zoomed out past frame-level detail.
*/
const SECOND_MULTIPLIERS = [1, 2, 3, 5, 10, 15, 30, 60] as const;
/**
* Minimum pixel spacing between labels to keep them readable
*/
const MIN_LABEL_SPACING_PX = 120;
/**
* Minimum pixel spacing between ticks. Much denser than labels.
*/
const MIN_TICK_SPACING_PX = 18;
export interface RulerConfig {
/** Time interval in seconds between each label */
labelIntervalSeconds: number;
/** Time interval in seconds between each tick */
tickIntervalSeconds: number;
}
/**
* Determines the optimal label and tick intervals based on zoom level and FPS.
*
* Labels and ticks scale independently:
* - Labels need wide spacing (~50px) to stay readable
* - Ticks can be denser (~8px) to show finer subdivisions
*
* Example at different zoom levels:
* - Very zoomed in: labels every 2f, ticks every 1f
* - Zoomed in: labels every 10f, ticks every 1f
* - Zoomed out: labels every 15f, ticks every 3f
* - Very zoomed out: labels every 1s, ticks every 5f
*/
export function getRulerConfig({
zoomLevel,
fps,
}: {
zoomLevel: number;
fps: number;
}): RulerConfig {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerFrame = pixelsPerSecond / fps;
const labelIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
fps,
minSpacingPx: MIN_LABEL_SPACING_PX,
frameIntervals: LABEL_FRAME_INTERVALS,
});
const rawTickIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
fps,
minSpacingPx: MIN_TICK_SPACING_PX,
frameIntervals: TICK_FRAME_INTERVALS,
});
// Ensure tick interval divides evenly into label interval so labels always land on ticks
const tickIntervalSeconds = ensureTickDividesLabel({
tickIntervalSeconds: rawTickIntervalSeconds,
labelIntervalSeconds,
pixelsPerFrame,
fps,
});
return { labelIntervalSeconds, tickIntervalSeconds };
}
/**
* Adjusts tick interval to ensure it divides evenly into the label interval.
* This guarantees labels always land on tick positions.
*/
function ensureTickDividesLabel({
tickIntervalSeconds,
labelIntervalSeconds,
pixelsPerFrame,
fps,
}: {
tickIntervalSeconds: number;
labelIntervalSeconds: number;
pixelsPerFrame: number;
fps: number;
}): number {
const labelFrames = Math.round(labelIntervalSeconds * fps);
const tickFrames = Math.round(tickIntervalSeconds * fps);
// If tick already divides label evenly, we're good
if (labelFrames % tickFrames === 0) {
return tickIntervalSeconds;
}
// Find the smallest tick interval that divides the label interval and has adequate spacing
for (const candidateFrames of TICK_FRAME_INTERVALS) {
if (labelFrames % candidateFrames === 0) {
const candidateSpacing = pixelsPerFrame * candidateFrames;
// Accept if spacing meets minimum threshold
if (candidateSpacing >= MIN_TICK_SPACING_PX) {
return candidateFrames / fps;
}
}
}
// Fallback: use the label interval itself (no intermediate ticks)
return labelIntervalSeconds;
}
function findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
fps,
minSpacingPx,
frameIntervals,
}: {
pixelsPerFrame: number;
pixelsPerSecond: number;
fps: number;
minSpacingPx: number;
frameIntervals: readonly number[];
}): number {
// Try frame-level intervals first
for (const frameInterval of frameIntervals) {
const pixelSpacing = pixelsPerFrame * frameInterval;
if (pixelSpacing >= minSpacingPx) {
return frameInterval / fps;
}
}
// Then try second-level intervals
for (const secondMultiplier of SECOND_MULTIPLIERS) {
const pixelSpacing = pixelsPerSecond * secondMultiplier;
if (pixelSpacing >= minSpacingPx) {
return secondMultiplier;
}
}
return 60;
}
/**
* Checks if a time should have a label based on the label interval.
*/
export function shouldShowLabel({
time,
labelIntervalSeconds,
}: {
time: number;
labelIntervalSeconds: number;
}): boolean {
const epsilon = 0.0001;
const remainder = time % labelIntervalSeconds;
return remainder < epsilon || remainder > labelIntervalSeconds - epsilon;
}
/**
* Formats a ruler tick label.
*
* - On second boundaries: "MM:SS" (e.g., "00:00", "01:30")
* - Between seconds: "Xf" (e.g., "5f", "15f")
*/
export function formatRulerLabel({
timeInSeconds,
fps,
}: {
timeInSeconds: number;
fps: number;
}): string {
if (isSecondBoundary({ timeInSeconds })) {
return formatTimestamp({ timeInSeconds });
}
const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps });
return `${frameWithinSecond}f`;
}
/**
* Checks if a time falls exactly on a second boundary.
*/
function isSecondBoundary({
timeInSeconds,
}: {
timeInSeconds: number;
}): boolean {
const epsilon = 0.0001;
const remainder = timeInSeconds % 1;
return remainder < epsilon || remainder > 1 - epsilon;
}
/**
* Gets the frame number within the current second.
*/
function getFrameWithinSecond({
timeInSeconds,
fps,
}: {
timeInSeconds: number;
fps: number;
}): number {
const fractionalPart = timeInSeconds % 1;
return Math.round(fractionalPart * fps);
}
/**
* Formats a timestamp as MM:SS.
*/
function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
const totalSeconds = Math.round(timeInSeconds);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
+244
View File
@@ -0,0 +1,244 @@
import type {
TrackType,
TimelineTrack,
ElementType,
VideoTrack,
AudioTrack,
StickerTrack,
TextTrack,
} from "@/types/timeline";
import {
TRACK_COLORS,
TRACK_HEIGHTS,
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 | StickerTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
return TRACK_COLORS[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
const colors = TRACK_COLORS[type];
return `${colors.background}`.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
return TRACK_HEIGHTS[type];
}
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 ??
(type === "video"
? "Video track"
: type === "text"
? "Text track"
: type === "audio"
? "Audio track"
: type === "sticker"
? "Sticker track"
: "Track");
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 "sticker":
return {
id,
name: trackName,
type: "sticker",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: 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;
}
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 {
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
return false;
}
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 };
}
+53
View File
@@ -0,0 +1,53 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
const PADDING_MAX_RATIO = 0.75;
const PADDING_MIN_RATIO = 0.15;
const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
export function getTimelineZoomMin({
duration,
containerWidth,
}: {
duration: number;
containerWidth: number | null | undefined;
}): number {
const safeDuration = Math.max(duration, 1);
const safeContainerWidth = containerWidth ?? 1000;
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
const zoomToFit =
availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit);
}
export function getTimelinePaddingPx({
containerWidth,
zoomLevel,
minZoom,
}: {
containerWidth: number;
zoomLevel: number;
minZoom: number;
}): number {
const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
const paddingTransitionPercent = Math.min(
zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
1,
);
const paddingRatio =
PADDING_MAX_RATIO -
(PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
return containerWidth * paddingRatio;
}
export function getZoomPercent({
zoomLevel,
minZoom,
}: {
zoomLevel: number;
minZoom: number;
}): number {
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
}