refactor: split constants by domain and isolate timeline panel from core

This commit is contained in:
Maze Winther
2026-04-01 19:27:49 +02:00
parent f4b9f40ab1
commit bd9ec023ee
72 changed files with 939 additions and 775 deletions
+11
View File
@@ -0,0 +1,11 @@
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
label: string;
value: number;
}> = [
{ label: "Light", value: 10 },
{ label: "Medium", value: 50 },
{ label: "Heavy", value: 100 },
] as const;
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
export const DEFAULT_BACKGROUND_COLOR = "#000000";
+10
View File
@@ -0,0 +1,10 @@
import type { TCanvasSize } from "@/lib/project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
{ width: 1080, height: 1920 },
{ width: 1080, height: 1080 },
{ width: 1440, height: 1080 },
];
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
@@ -5,7 +5,7 @@ import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { getHighestImportedVideoFps } from "@/lib/project/fps";
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
@@ -9,7 +9,7 @@ import type {
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
@@ -136,7 +136,7 @@ export class InsertElementCommand extends Command {
startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
} as TimelineElement;
}
@@ -1,5 +1,5 @@
import { EditorCore } from "@/core";
import { clampRetimeRate } from "@/constants/retime-constants";
import { clampRetimeRate } from "@/lib/retime/rate";
import { clampAnimationsToDuration } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
import { canTrackHaveAudio } from "@/lib/timeline";
export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -22,7 +22,7 @@ export class ToggleTrackMuteCommand extends Command {
}
const updatedTracks = this.savedState.map((track) =>
track.id === this.trackId && canTracktHaveAudio(track)
track.id === this.trackId && canTrackHaveAudio(track)
? { ...track, muted: !track.muted }
: track,
);
@@ -1,65 +1,65 @@
import { describe, expect, test } from "bun:test";
import {
getHighestImportedVideoFps,
getRaisedProjectFpsForImportedMedia,
} from "@/lib/project/fps";
describe("getHighestImportedVideoFps", () => {
test("returns the highest valid video fps", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "audio" },
{ type: "video", fps: 30 },
{ type: "image", fps: 120 },
{ type: "video", fps: 60 },
],
}),
).toBe(60);
});
test("ignores missing and invalid fps values", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "video" },
{ type: "video", fps: 0 },
{ type: "video", fps: -10 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});
describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [{ type: "video", fps: 60 }],
}),
).toBe(60);
});
test("does not lower the project fps for lower-fps imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 60,
importedAssets: [{ type: "video", fps: 10 }],
}),
).toBeNull();
});
test("ignores non-video imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [
{ type: "image", fps: 60 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});
import { describe, expect, test } from "bun:test";
import {
getHighestImportedVideoFps,
getRaisedProjectFpsForImportedMedia,
} from "@/lib/fps/utils";
describe("getHighestImportedVideoFps", () => {
test("returns the highest valid video fps", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "audio" },
{ type: "video", fps: 30 },
{ type: "image", fps: 120 },
{ type: "video", fps: 60 },
],
}),
).toBe(60);
});
test("ignores missing and invalid fps values", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "video" },
{ type: "video", fps: 0 },
{ type: "video", fps: -10 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});
describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [{ type: "video", fps: 60 }],
}),
).toBe(60);
});
test("does not lower the project fps for lower-fps imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 60,
importedAssets: [{ type: "video", fps: 10 }],
}),
).toBeNull();
});
test("ignores non-video imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [
{ type: "image", fps: 60 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});
+9
View File
@@ -0,0 +1,9 @@
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
{ value: "30", label: "30 fps" },
{ value: "60", label: "60 fps" },
{ value: "120", label: "120 fps" },
] as const;
export const DEFAULT_FPS = 30;
@@ -1,39 +1,39 @@
import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
export function getHighestImportedVideoFps({
mediaAssets,
}: {
mediaAssets: MediaAssetFpsInput[];
}): number | null {
let highestFps: number | null = null;
for (const asset of mediaAssets) {
const fps = asset.fps ?? Number.NaN;
if (asset.type !== "video") continue;
if (!Number.isFinite(fps) || fps <= 0) continue;
highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
}
return highestFps;
}
export function getRaisedProjectFpsForImportedMedia({
currentFps,
importedAssets,
}: {
currentFps: number;
importedAssets: MediaAssetFpsInput[];
}): number | null {
const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets,
});
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
return null;
}
return highestImportedVideoFps;
}
import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
export function getHighestImportedVideoFps({
mediaAssets,
}: {
mediaAssets: MediaAssetFpsInput[];
}): number | null {
let highestFps: number | null = null;
for (const asset of mediaAssets) {
const fps = asset.fps ?? Number.NaN;
if (asset.type !== "video") continue;
if (!Number.isFinite(fps) || fps <= 0) continue;
highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
}
return highestFps;
}
export function getRaisedProjectFpsForImportedMedia({
currentFps,
importedAssets,
}: {
currentFps: number;
importedAssets: MediaAssetFpsInput[];
}): number | null {
const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets,
});
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
return null;
}
return highestImportedVideoFps;
}
+6 -6
View File
@@ -4,7 +4,7 @@ import type {
RetimeConfig,
TimelineTrack,
} from "@/lib/timeline";
import { shouldMaintainPitch } from "@/constants/retime-constants";
import { shouldMaintainPitch } from "@/lib/retime/rate";
import type { MediaAsset } from "@/lib/media/types";
import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
import type { AudioCapableElement } from "@/lib/timeline/audio-state";
@@ -16,7 +16,7 @@ import {
doesElementHaveEnabledAudio,
} from "@/lib/timeline/audio-separation";
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { canTrackHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
@@ -97,7 +97,7 @@ export async function collectAudioElements({
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
@@ -108,7 +108,7 @@ export async function collectAudioElements({
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
if (element.type === "audio") {
pendingElements.push(
@@ -453,7 +453,7 @@ export async function collectAudioMixSources({
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
@@ -515,7 +515,7 @@ export async function collectAudioClips({
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
for (const track of tracks) {
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
+9
View File
@@ -0,0 +1,9 @@
export const PANEL_CONFIG = {
panels: {
tools: 25,
preview: 50,
properties: 25,
mainContent: 50,
timeline: 50,
},
} as const;
+7
View File
@@ -0,0 +1,7 @@
export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
export const PREVIEW_ZOOM = {
min: 0.25,
max: 16,
step: 1.25,
} as const;
+1 -1
View File
@@ -1,5 +1,5 @@
import { PitchShifter } from "soundtouchjs";
import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants";
import { clampRetimeRate, shouldMaintainPitch } from "@/lib/retime/rate";
import type { RetimeConfig } from "@/lib/timeline";
import { getSourceTimeAtClipTime } from "./resolve";
+1
View File
@@ -1,3 +1,4 @@
export * from "./rate";
export * from "./audio-stretch";
export * from "./presets";
export * from "./resolve";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RetimeConfig } from "@/lib/timeline";
import { clampRetimeRate } from "@/constants/retime-constants";
import { clampRetimeRate } from "@/lib/retime/rate";
export function buildConstantRetime({
rate,
+25
View File
@@ -0,0 +1,25 @@
export const DEFAULT_RETIME_RATE = 1;
export const MIN_RETIME_RATE = 0.01;
export const MAX_RETIME_RATE = 5;
export function clampRetimeRate({ rate }: { rate: number }): number {
if (!Number.isFinite(rate) || rate <= 0) {
return DEFAULT_RETIME_RATE;
}
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
}
export function canMaintainPitch({ rate }: { rate: number }): boolean {
return Number.isFinite(rate) && rate > 0;
}
export function shouldMaintainPitch({
rate,
maintainPitch,
}: {
rate: number;
maintainPitch?: boolean;
}): boolean {
return maintainPitch === true && canMaintainPitch({ rate });
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RetimeConfig } from "@/lib/timeline";
import { clampRetimeRate } from "@/constants/retime-constants";
import { clampRetimeRate } from "@/lib/retime/rate";
function getSafeRate({ rate }: { rate: number }): number {
return clampRetimeRate({ rate });
+12
View File
@@ -0,0 +1,12 @@
export const MIN_FONT_SIZE = 5;
export const MAX_FONT_SIZE = 300;
export const DEFAULT_TEXT_COLOR = "#000000";
/**
* higher value: smaller font size
* lower value: larger font size
*/
export const FONT_SIZE_SCALE_REFERENCE = 90;
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
+2 -2
View File
@@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import type { TTimelineViewState } from "@/lib/project/types";
import type { BlendMode, Transform } from "@/lib/rendering";
import type { TextElement } from "./types";
@@ -41,7 +41,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
textDecoration: "none",
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime: 0,
trimStart: 0,
trimEnd: 0,
+2 -2
View File
@@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export function getMouseTimeFromClientX({
clientX,
@@ -14,6 +14,6 @@ export function getMouseTimeFromClientX({
const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
}
-257
View File
@@ -1,257 +0,0 @@
import type {
TimelineTrack,
TimelineElement,
} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
}: {
mouseX: number;
tracks: TimelineTrack[];
trackIndex: number;
targetElementTypes: string[];
pixelsPerSecond: number;
zoomLevel: number;
}): { elementId: string; trackId: string } | null {
const time = mouseX / (pixelsPerSecond * zoomLevel);
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null;
const hit = track.elements.find(
(element: TimelineElement) =>
targetElementTypes.includes(element.type) &&
element.startTime <= time &&
time < element.startTime + element.duration,
);
if (!hit) return null;
return { elementId: hit.id, trackId: track.id };
}
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_CONFIG[tracks[i].type].height;
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;
}
const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
xPosition: number;
}): DropTarget {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
export function computeDropTarget({
elementType,
mouseX,
mouseY,
tracks,
playheadTime,
isExternalDrop,
elementDuration,
pixelsPerSecond,
zoomLevel,
verticalDragDirection,
startTimeOverride,
excludeElementId,
targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
? startTimeOverride
: isExternalDrop
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
if (tracks.length === 0) {
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
});
const emptyTimelineResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!emptyTimelineResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true,
},
});
const outOfBoundsResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!outOfBoundsResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
if (
targetElementTypes &&
targetElementTypes.length > 0
) {
const targetElement = findElementAtPosition({
mouseX,
tracks,
trackIndex,
targetElementTypes,
pixelsPerSecond,
zoomLevel,
});
if (targetElement) {
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
targetElement,
};
}
}
const trackHeight = TRACK_CONFIG[track.type].height;
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex,
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
verticalDragDirection,
},
});
if (!placementResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
if (placementResult.kind === "existingTrack") {
const adjustedXPosition =
placementResult.adjustedStartTime ?? xPosition;
return {
trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
return {
trackIndex: placementResult.insertIndex,
isNewTrack: true,
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
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_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
}
+5 -5
View File
@@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import {
MASKABLE_ELEMENT_TYPES,
RETIMABLE_ELEMENT_TYPES,
@@ -115,7 +115,7 @@ export function buildTextElement({
type: "text",
name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -150,7 +150,7 @@ export function buildEffectElement({
name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -178,7 +178,7 @@ export function buildStickerElement({
stickerId,
intrinsicWidth,
intrinsicHeight,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -208,7 +208,7 @@ export function buildGraphicElement({
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
+1 -1
View File
@@ -2,7 +2,7 @@ import type { TimelineTrack } from "./types";
export * from "./types";
export * from "./drag";
export * from "./track-utils";
export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
+2 -2
View File
@@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
@@ -31,7 +31,7 @@ export function getTimelinePixelsPerSecond({
}: {
zoomLevel: number;
}): number {
return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
}
export function timelineTimeToPixels({
@@ -1,4 +1,4 @@
import { TRACK_CONFIG } from "@/constants/timeline-constants";
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
import type { TrackType, TimelineTrack } from "@/lib/timeline";
export function buildEmptyTrack({
@@ -10,7 +10,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
const trackName = name ?? DEFAULT_TRACK_NAMES[type];
switch (type) {
case "video":
+2 -2
View File
@@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
/**
* frame intervals for labels - starts at 2 so there's always at least
@@ -56,7 +56,7 @@ export function getRulerConfig({
zoomLevel: number;
fps: number;
}): RulerConfig {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const pixelsPerFrame = pixelsPerSecond / fps;
const labelIntervalSeconds = findOptimalInterval({
+3
View File
@@ -0,0 +1,3 @@
export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
export const TIMELINE_ZOOM_MIN = 0.1;
export const TIMELINE_ZOOM_MAX = 100;
@@ -1,133 +0,0 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
import type { TimelineTrack } from "@/lib/timeline";
type TimelineElementRef = { trackId: string; elementId: string };
interface SelectionRectangle {
left: number;
top: number;
right: number;
bottom: number;
}
function getNormalizedRectangle({
startPos,
endPos,
}: {
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
return {
left: Math.min(startPos.x, endPos.x),
top: Math.min(startPos.y, endPos.y),
right: Math.max(startPos.x, endPos.x),
bottom: Math.max(startPos.y, endPos.y),
};
}
function getSelectionRectangleInContent({
container,
scrollContainer,
startPos,
endPos,
}: {
container: HTMLElement;
scrollContainer: HTMLDivElement | null;
startPos: { x: number; y: number };
endPos: { x: number; y: number };
}): SelectionRectangle {
const containerRect = container.getBoundingClientRect();
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const scrollTop = scrollContainer?.scrollTop ?? 0;
const adjustedStart = {
x: startPos.x - containerRect.left + scrollLeft,
y: startPos.y - scrollRect.top + scrollTop,
};
const adjustedEnd = {
x: endPos.x - containerRect.left + scrollLeft,
y: endPos.y - scrollRect.top + scrollTop,
};
return getNormalizedRectangle({
startPos: adjustedStart,
endPos: adjustedEnd,
});
}
function isRectangleIntersecting({
elementRectangle,
selectionRectangle,
}: {
elementRectangle: SelectionRectangle;
selectionRectangle: SelectionRectangle;
}): boolean {
return !(
elementRectangle.right < selectionRectangle.left ||
elementRectangle.left > selectionRectangle.right ||
elementRectangle.bottom < selectionRectangle.top ||
elementRectangle.top > selectionRectangle.bottom
);
}
export function resolveTimelineElementIntersections({
container,
scrollContainer,
tracks,
zoomLevel,
startPos,
currentPos,
}: {
container: HTMLElement;
scrollContainer: HTMLDivElement | null;
tracks: TimelineTrack[];
zoomLevel: number;
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
}): TimelineElementRef[] {
const selectionRectangle = getSelectionRectangleInContent({
container,
scrollContainer,
startPos,
endPos: currentPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const selectedElements: TimelineElementRef[] = [];
for (const [trackIndex, track] of tracks.entries()) {
const trackTop = getCumulativeHeightBefore({
tracks,
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementRectangle = {
left: elementLeft,
top: elementTop,
right: elementRight,
bottom: elementBottom,
};
if (
isRectangleIntersecting({
elementRectangle,
selectionRectangle,
})
) {
selectedElements.push({
trackId: track.id,
elementId: element.id,
});
}
}
}
return selectedElements;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { getElementKeyframes } from "@/lib/animation";
@@ -107,7 +107,7 @@ export function snapToNearestPoint({
zoomLevel: number;
snapThreshold?: number;
}): SnapResult {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
@@ -0,0 +1,20 @@
import type {
TimelineTrack,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
} from "@/lib/timeline";
export function canTrackHaveAudio(
track: TimelineTrack,
): track is VideoTrack | AudioTrack {
return track.type === "audio" || track.type === "video";
}
export function canTrackBeHidden(
track: TimelineTrack,
): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
return track.type !== "audio";
}
-62
View File
@@ -1,62 +0,0 @@
import type {
TrackType,
TimelineTrack,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
} from "@/lib/timeline";
import {
TRACK_CONFIG,
ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
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 | GraphicTrack | EffectTrack {
return track.type !== "audio";
}
export function getElementClasses({ type }: { type: TrackType }) {
return ELEMENT_TYPE_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
return TRACK_CONFIG[type].height;
}
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;
}
+9
View File
@@ -0,0 +1,9 @@
import type { TrackType } from "@/lib/timeline";
export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
video: "Video track",
text: "Text track",
audio: "Audio track",
graphic: "Graphic track",
effect: "Effect track",
} as const;
+9 -6
View File
@@ -1,4 +1,7 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
BASE_TIMELINE_PIXELS_PER_SECOND,
TIMELINE_ZOOM_MAX,
} from "@/lib/timeline/scale";
const PADDING_MAX_RATIO = 0.75;
const PADDING_MIN_RATIO = 0.15;
@@ -16,9 +19,9 @@ export function getTimelineZoomMin({
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
const zoomToFit =
availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND);
return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit);
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
}
export function getTimelinePaddingPx({
@@ -49,7 +52,7 @@ export function getZoomPercent({
zoomLevel: number;
minZoom: number;
}): number {
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom);
}
/**
@@ -59,7 +62,7 @@ export function getZoomPercent({
export function sliderToZoom({
sliderPosition,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
maxZoom = TIMELINE_ZOOM_MAX,
}: {
sliderPosition: number;
minZoom: number;
@@ -75,7 +78,7 @@ export function sliderToZoom({
export function zoomToSlider({
zoomLevel,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
maxZoom = TIMELINE_ZOOM_MAX,
}: {
zoomLevel: number;
minZoom: number;