mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
codebase overhaul (#697)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user