summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx14
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx2
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx6
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx24
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/drop-target.ts (renamed from apps/web/src/lib/timeline/drop-utils.ts)508
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx50
-rw-r--r--apps/web/src/components/editor/panels/timeline/interaction.ts4
-rw-r--r--apps/web/src/components/editor/panels/timeline/layers.ts6
-rw-r--r--apps/web/src/components/editor/panels/timeline/layout.ts16
-rw-r--r--apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts (renamed from apps/web/src/lib/timeline/selection-hit-testing.ts)269
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/theme.ts29
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx14
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx11
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx5
-rw-r--r--apps/web/src/components/editor/panels/timeline/track-layout.ts37
-rw-r--r--apps/web/src/core/managers/audio-manager.ts2
-rw-r--r--apps/web/src/core/managers/project-manager.ts12
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts27
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts4
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts13
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts7
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts5
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts19
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts10
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts4
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts33
-rw-r--r--apps/web/src/hooks/use-paste-media.ts4
-rw-r--r--apps/web/src/lib/background/constants.ts11
-rw-r--r--apps/web/src/lib/canvas/constants.ts10
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts4
-rw-r--r--apps/web/src/lib/fps/__tests__/fps.test.ts (renamed from apps/web/src/lib/project/__tests__/fps.test.ts)130
-rw-r--r--apps/web/src/lib/fps/constants.ts9
-rw-r--r--apps/web/src/lib/fps/utils.ts (renamed from apps/web/src/lib/project/fps.ts)78
-rw-r--r--apps/web/src/lib/media/audio.ts12
-rw-r--r--apps/web/src/lib/panels/constants.ts9
-rw-r--r--apps/web/src/lib/preview/constants.ts7
-rw-r--r--apps/web/src/lib/retime/audio-stretch.ts2
-rw-r--r--apps/web/src/lib/retime/index.ts1
-rw-r--r--apps/web/src/lib/retime/presets.ts2
-rw-r--r--apps/web/src/lib/retime/rate.ts (renamed from apps/web/src/constants/retime-constants.ts)55
-rw-r--r--apps/web/src/lib/retime/resolve.ts2
-rw-r--r--apps/web/src/lib/text/constants.ts12
-rw-r--r--apps/web/src/lib/timeline/creation.ts1
-rw-r--r--apps/web/src/lib/timeline/defaults.ts4
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts4
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts10
-rw-r--r--apps/web/src/lib/timeline/index.ts2
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts4
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts4
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts4
-rw-r--r--apps/web/src/lib/timeline/scale.ts3
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts4
-rw-r--r--apps/web/src/lib/timeline/track-capabilities.ts20
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts62
-rw-r--r--apps/web/src/lib/timeline/tracks.ts9
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts15
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts5
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts16
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts24
-rw-r--r--apps/web/src/stores/editor-store.ts2
-rw-r--r--apps/web/src/stores/panel-store.ts2
72 files changed, 939 insertions, 775 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index e17e4d4a..031d2e6a 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
@@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number;
}) => {
const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
index 7a81188f..bbceac7d 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -8,10 +8,10 @@ import {
SectionTitle,
} from "@/components/section";
import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
-} from "@/constants/project-constants";
+ BACKGROUND_BLUR_INTENSITY_PRESETS,
+ DEFAULT_BACKGROUND_BLUR_INTENSITY,
+ DEFAULT_BACKGROUND_COLOR,
+} from "@/lib/background/constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
@@ -164,16 +164,16 @@ export function BackgroundContent() {
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
+ : DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
- : DEFAULT_COLOR;
+ : DEFAULT_BACKGROUND_COLOR;
const canvasSize = activeProject.settings.canvasSize;
const blurPreviews = useMemo(
() =>
- BLUR_INTENSITY_PRESETS.map((blur) => (
+ BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index 1526a842..6c819a36 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
+import { FPS_PRESETS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
index 8fd38768..51e5d2d0 100644
--- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
@@ -17,7 +17,7 @@ import {
screenToCanvas,
} from "@/lib/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
-import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+import { PREVIEW_ZOOM } from "@/lib/preview/constants";
const MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001;
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 340058d0..0be1ec46 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -22,7 +22,7 @@ import {
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
-import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/constants";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store";
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
index da86c626..cb10aac3 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
@@ -11,7 +11,7 @@ import {
MAX_RETIME_RATE,
clampRetimeRate,
canMaintainPitch,
-} from "@/constants/retime-constants";
+} from "@/lib/retime/rate";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
Section,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index 4c58023b..11412c04 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -18,13 +18,13 @@ import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
+ DEFAULT_TEXT_COLOR,
CORNER_RADIUS_MIN,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
-} from "@/constants/text-constants";
+} from "@/lib/text/constants";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
@@ -343,7 +343,7 @@ function BackgroundSection({
trackId: string;
}) {
const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index e2dc09f1..d4b1657e 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -6,10 +6,10 @@ import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
- DEFAULT_BOOKMARK_COLOR,
- TIMELINE_BOOKMARK_ROW_HEIGHT,
-} from "@/constants/timeline-constants";
-import { DEFAULT_FPS } from "@/constants/project-constants";
+ DEFAULT_TIMELINE_BOOKMARK_COLOR,
+} from "./theme";
+import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
+import { DEFAULT_FPS } from "@/lib/fps/constants";
import { getSnappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
@@ -83,12 +83,12 @@ export function TimelineBookmarksRow({
return (
<div
className="relative flex-1 overflow-hidden"
- style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
+ style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
>
<button
className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
- height: TIMELINE_BOOKMARK_ROW_HEIGHT,
+ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
@@ -153,7 +153,7 @@ function TimelineBookmark({
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
- const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
+ const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
const handleSeek = () => seekToBookmarkTime({ editor, time });
@@ -298,12 +298,16 @@ function BookmarkPopoverContent({
}) {
const editor = useEditor();
const [draftColorHex, setDraftColorHex] = useState(
- (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
+ .replace("#", "")
+ .toUpperCase(),
);
useEffect(() => {
setDraftColorHex(
- (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
+ .replace("#", "")
+ .toUpperCase(),
);
}, [bookmark.color]);
@@ -357,7 +361,7 @@ function BookmarkPopoverContent({
/>
{bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !==
- DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
+ DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button
type="button"
variant="text"
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index a5a145b4..89a4c93f 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -1,6 +1,6 @@
-import { getDropLineY } from "@/lib/timeline/drop-utils";
+import { getDropLineY } from "./drop-target";
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
interface DragLineProps {
dropTarget: DropTarget | null;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts
index 161d2714..dbd4efaf 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/components/editor/panels/timeline/drop-target.ts
@@ -1,257 +1,251 @@
-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;
-}
+import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
+import { resolveTrackPlacement } from "@/lib/timeline/placement";
+import { TIMELINE_TRACK_GAP_PX } from "./layout";
+import { getTrackHeight } from "./track-layout";
+
+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 = getTrackHeight({ type: 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 + TIMELINE_TRACK_GAP_PX;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
+ }
+
+ 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 = getTrackHeight({ type: track.type });
+ 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 += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 819413e6..4114ae2c 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -38,20 +38,26 @@ import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/lib/timeline";
import {
- TIMELINE_CONSTANTS,
- TRACK_GAP,
TIMELINE_SCROLLBAR_SIZE_PX,
-} from "@/constants/timeline-constants";
+ TIMELINE_CONTENT_TOP_PADDING_PX,
+ TIMELINE_TRACK_GAP_PX,
+ TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
+} from "./layout";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
- canTracktHaveAudio,
+ canTrackHaveAudio,
canTrackBeHidden,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+} from "./track-layout";
+import { SELECTED_TRACK_ROW_CLASS } from "./theme";
+import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@@ -67,7 +73,7 @@ import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
-import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
+import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
@@ -230,7 +236,7 @@ export function Timeline() {
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
const clamped =
Math.sign(raw) *
- Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
+ Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
} else {
tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
@@ -335,7 +341,7 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
- timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@@ -383,7 +389,7 @@ export function Timeline() {
const timelineHeaderHeight =
(timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
- TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
+ TIMELINE_CONTENT_TOP_PADDING_PX || 0;
return (
<section
@@ -483,7 +489,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks }),
),
- ) + TIMELINE_CONSTANTS.PADDING_TOP_PX
+ ) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
}}
onMouseDown={(event) => {
@@ -577,7 +583,10 @@ function TrackLabelsPanel({
);
return (
- <div className="flex w-28 shrink-0 flex-col border-r">
+ <div
+ className="flex shrink-0 flex-col border-r"
+ style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
+ >
<div
className="shrink-0"
style={{ height: timelineHeaderHeight || 48 }}
@@ -585,21 +594,24 @@ function TrackLabelsPanel({
<div ref={trackLabelsRef} className="flex-1 overflow-hidden">
<div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
{tracks.length > 0 && (
- <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
+ <div
+ className="flex flex-col"
+ style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
+ >
{tracks.map((track) => (
<div
key={track.id}
className={cn(
"group flex items-center px-3",
tracksWithSelection.has(track.id) &&
- TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ SELECTED_TRACK_ROW_CLASS,
)}
style={{
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
- {canTracktHaveAudio(track) && (
+ {canTrackHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
@@ -707,10 +719,10 @@ function TimelineTrackRows({
className={cn(
"absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) &&
- TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ SELECTED_TRACK_ROW_CLASS,
)}
style={{
- top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
+ top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
@@ -753,7 +765,7 @@ function TimelineTrackRows({
timeline.toggleTrackMute({ trackId: track.id });
}}
>
- {canTracktHaveAudio(track) && track.muted
+ {canTrackHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</ContextMenuItem>
diff --git a/apps/web/src/components/editor/panels/timeline/interaction.ts b/apps/web/src/components/editor/panels/timeline/interaction.ts
new file mode 100644
index 00000000..fe0210a4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/interaction.ts
@@ -0,0 +1,4 @@
+export const TIMELINE_DRAG_THRESHOLD_PX = 5;
+export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
+export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
+export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
diff --git a/apps/web/src/components/editor/panels/timeline/layers.ts b/apps/web/src/components/editor/panels/timeline/layers.ts
new file mode 100644
index 00000000..d59db412
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/layers.ts
@@ -0,0 +1,6 @@
+export const TIMELINE_LAYERS = {
+ trackContent: 10,
+ dragLine: 20,
+ playhead: 30,
+ snapIndicator: 40,
+} as const;
diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts
new file mode 100644
index 00000000..88443797
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/layout.ts
@@ -0,0 +1,16 @@
+import type { TrackType } from "@/lib/timeline";
+
+export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
+ video: 65,
+ text: 25,
+ audio: 50,
+ graphic: 25,
+ effect: 25,
+} as const;
+
+export const TIMELINE_TRACK_GAP_PX = 6;
+export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
+export const TIMELINE_RULER_HEIGHT_PX = 22;
+export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
+export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
+export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
index 3c0f597e..6488e29a 100644
--- a/apps/web/src/lib/timeline/selection-hit-testing.ts
+++ b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
@@ -1,133 +1,136 @@
-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;
-}
+import type { TimelineTrack } from "@/lib/timeline";
+import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
+import {
+ TIMELINE_CONTENT_TOP_PADDING_PX,
+} from "./layout";
+import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
+
+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 = getTimelinePixelsPerSecond({ 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_CONTENT_TOP_PADDING_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;
+}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 766d0843..a86e2a5e 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -6,7 +6,7 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts
new file mode 100644
index 00000000..e7fc1f11
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/theme.ts
@@ -0,0 +1,29 @@
+import type { TrackType } from "@/lib/timeline";
+
+export const TIMELINE_TRACK_THEME: Record<
+ TrackType,
+ {
+ elementClassName: string;
+ waveformColor?: string;
+ }
+> = {
+ video: { elementClassName: "transparent" },
+ text: { elementClassName: "bg-[#5DBAA0]" },
+ audio: {
+ elementClassName: "bg-[#8F5DBA]",
+ waveformColor: "rgba(255, 255, 255, 0.5)",
+ },
+ graphic: { elementClassName: "bg-[#BA5D7A]" },
+ effect: { elementClassName: "bg-[#5d93ba]" },
+} as const;
+
+export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
+export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
+
+export function getTimelineElementClassName({
+ type,
+}: {
+ type: TrackType;
+}): string {
+ return TIMELINE_TRACK_THEME[type].elementClassName.trim();
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index bb30b0b0..25218d78 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -12,8 +12,6 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getElementClasses,
- getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasElementEffects,
@@ -21,7 +19,11 @@ import {
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
-import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
+import { getTrackHeight } from "./track-layout";
+import {
+ getTimelineElementClassName,
+ TIMELINE_TRACK_THEME,
+} from "./theme";
import {
ContextMenu,
ContextMenuContent,
@@ -464,7 +466,7 @@ function ElementInner({
<div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
- getElementClasses({ type: track.type }),
+ getTimelineElementClassName({ type: track.type }),
isReducedOpacity && "opacity-50",
)}
>
@@ -710,7 +712,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
- color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
</div>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index 1abe65bd..361f1398 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -9,9 +9,9 @@ import {
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
import {
- TIMELINE_LAYERS,
TIMELINE_SCROLLBAR_SIZE_PX,
-} from "@/constants/timeline-constants";
+} from "./layout";
+import { TIMELINE_LAYERS } from "./layers";
interface TimelinePlayheadProps {
zoomLevel: number;
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index 09562f23..9e49bf6c 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -1,9 +1,9 @@
import { type JSX, useLayoutEffect, useRef } from "react";
import {
- TIMELINE_CONSTANTS,
- TIMELINE_RULER_HEIGHT,
-} from "@/constants/timeline-constants";
-import { DEFAULT_FPS } from "@/constants/project-constants";
+ BASE_TIMELINE_PIXELS_PER_SECOND,
+} from "@/lib/timeline/scale";
+import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
+import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
@@ -31,7 +31,7 @@ export function TimelineRuler({
handleRulerMouseDown,
}: TimelineRulerProps) {
const duration = useEditor((e) => e.timeline.getTotalDuration());
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const fps =
@@ -107,7 +107,7 @@ export function TimelineRuler({
aria-valuemax={effectiveDuration}
aria-valuenow={0}
className="relative flex-1 overflow-x-visible"
- style={{ height: TIMELINE_RULER_HEIGHT }}
+ style={{ height: TIMELINE_RULER_HEIGHT_PX }}
onWheel={handleWheel}
onClick={(event) => {
// Ruler seek already happens on mousedown via playhead scrubbing.
@@ -124,7 +124,7 @@ export function TimelineRuler({
ref={rulerRef}
className="relative cursor-default select-none"
style={{
- height: TIMELINE_RULER_HEIGHT,
+ height: TIMELINE_RULER_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index ff43e00a..ecaf37e2 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -14,7 +14,10 @@ import {
SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_ZOOM_BUTTON_FACTOR,
+} from "./interaction";
+import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
@@ -60,10 +63,10 @@ export function TimelineToolbar({
const newZoomLevel =
direction === "in"
? Math.min(
- TIMELINE_CONSTANTS.ZOOM_MAX,
- zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
+ TIMELINE_ZOOM_MAX,
+ zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
)
- : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
+ : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index ffc2007b..888e1496 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -5,7 +5,8 @@ import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
@@ -59,7 +60,7 @@ export function TimelineTrackContent({
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
return (
diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts
new file mode 100644
index 00000000..89cc4d05
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/track-layout.ts
@@ -0,0 +1,37 @@
+import type { TrackType } from "@/lib/timeline";
+import {
+ TIMELINE_TRACK_GAP_PX,
+ TIMELINE_TRACK_HEIGHTS_PX,
+} from "./layout";
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TIMELINE_TRACK_HEIGHTS_PX[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 }) + TIMELINE_TRACK_GAP_PX,
+ 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) * TIMELINE_TRACK_GAP_PX;
+ return tracksHeight + gapsHeight;
+}
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 9e6be22c..0f642b1f 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
import {
clampRetimeRate,
shouldMaintainPitch,
-} from "@/constants/retime-constants";
+} from "@/lib/retime/rate";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import {
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 916ab328..10a9229d 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -12,11 +12,9 @@ import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
-import {
- DEFAULT_FPS,
- DEFAULT_CANVAS_SIZE,
- DEFAULT_COLOR,
-} from "@/constants/project-constants";
+import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/constants";
+import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
+import { DEFAULT_FPS } from "@/lib/fps/constants";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
@@ -29,7 +27,7 @@ import {
import { loadFonts } from "@/lib/fonts/google-fonts";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
-import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
+import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils";
import type { MediaAsset } from "@/lib/media/types";
export interface MigrationState {
@@ -101,7 +99,7 @@ export class ProjectManager {
originalCanvasSize: null,
background: {
type: "color",
- color: DEFAULT_COLOR,
+ color: DEFAULT_BACKGROUND_COLOR,
},
},
version: CURRENT_PROJECT_VERSION,
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 3e0a6997..193379ed 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -10,12 +10,10 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import {
- DRAG_THRESHOLD_PX,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
-import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
@@ -70,7 +68,7 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
- return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
}
function getVerticalDragDirection({
@@ -136,7 +134,7 @@ function getDragDropTarget({
playheadTime: snappedTime,
isExternalDrop: false,
elementDuration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
@@ -288,7 +286,10 @@ export function useElementInteraction({
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
- if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
+ if (
+ deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
+ deltaY > TIMELINE_DRAG_THRESHOLD_PX
+ ) {
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
@@ -416,7 +417,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
- if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ if (
+ deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
+ deltaY <= TIMELINE_DRAG_THRESHOLD_PX
+ ) {
mouseDownLocationRef.current = null;
endDrag();
onSnapPointChange?.(null);
@@ -596,7 +600,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
- if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
+ if (
+ deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
+ deltaY > TIMELINE_DRAG_THRESHOLD_PX
+ ) {
mouseDownLocationRef.current = null;
return;
}
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 26bd8ef9..890820d7 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
@@ -190,7 +190,7 @@ export function useTimelineElementResize({
const deltaX = clientX - resizing.startX;
let deltaTime =
- deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index e0bf8084..bbe843f2 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -9,10 +9,8 @@ import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import {
- DRAG_THRESHOLD_PX,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
@@ -64,7 +62,7 @@ export function useKeyframeDrag({
const activeProject = editor.project.getActive();
const fps = activeProject.settings.fps;
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const endDrag = useCallback(() => {
setDragState(initialDragState);
@@ -127,7 +125,7 @@ export function useKeyframeDrag({
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- if (deltaX <= DRAG_THRESHOLD_PX) return;
+ if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
const pending = pendingDragRef.current;
pendingDragRef.current = null;
@@ -248,7 +246,8 @@ export function useKeyframeDrag({
const wasDrag =
mouseDownXRef.current !== null &&
- Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
+ Math.abs(event.clientX - mouseDownXRef.current) >
+ TIMELINE_DRAG_THRESHOLD_PX;
mouseDownXRef.current = null;
if (wasDrag) return;
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 4a1185bd..14d58d9a 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -7,7 +7,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
@@ -131,7 +131,10 @@ export function useBookmarkDrag({
const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY);
- if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ if (
+ deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
+ deltaY <= TIMELINE_DRAG_THRESHOLD_PX
+ ) {
return;
}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index 3dc34b7f..b941dcb5 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
+import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -44,7 +44,8 @@ export function useSnapIndicatorPosition({
time: snapPoint?.time ?? 0,
zoomLevel,
});
- const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
+ const leftPosition =
+ TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
return {
leftPosition,
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index ce6f1341..8e87510c 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -3,7 +3,8 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import {
buildTextElement,
@@ -14,7 +15,7 @@ import {
} from "@/lib/timeline/element-utils";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
-import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
@@ -82,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
- return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
}
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
},
[editor],
);
@@ -158,7 +159,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime,
isExternalDrop: isExternal,
elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
targetElementTypes,
});
@@ -330,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
- mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@@ -445,7 +446,7 @@ export function useTimelineDragDrop({
const duration =
createdAsset.duration ??
- TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
@@ -467,7 +468,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel,
});
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index d4cfcc06..fce84135 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -8,7 +8,7 @@ import {
getCenteredLineLeft,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -67,7 +67,7 @@ export function useTimelinePlayhead({
const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth =
- duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const clampedMouseX = Math.max(
0,
@@ -78,7 +78,7 @@ export function useTimelinePlayhead({
0,
Math.min(
duration,
- clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
@@ -161,7 +161,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
useEffect(() => {
@@ -248,7 +248,7 @@ export function useTimelinePlayhead({
if (!rulerViewport || !tracksViewport) return;
const playheadPixels =
- time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
+ time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
index 2672062f..1690153e 100644
--- a/apps/web/src/hooks/timeline/use-timeline-seek.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts
@@ -1,6 +1,6 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor";
@@ -132,7 +132,7 @@ export function useTimelineSeek({
Math.min(
duration,
(mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 64a0fb58..a1740384 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -7,7 +7,14 @@ import {
useRef,
useState,
} from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
+} from "@/components/editor/panels/timeline/interaction";
+import {
+ TIMELINE_ZOOM_MAX,
+ TIMELINE_ZOOM_MIN,
+ BASE_TIMELINE_PIXELS_PER_SECOND,
+} from "@/lib/timeline/scale";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@@ -30,7 +37,7 @@ interface UseTimelineZoomReturn {
export function useTimelineZoom({
containerRef,
- minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
+ minZoom = TIMELINE_ZOOM_MIN,
initialZoom,
initialScrollLeft,
initialPlayheadTime,
@@ -49,7 +56,7 @@ export function useTimelineZoom({
hasInitializedRef.current = true;
return Math.max(
minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
+ Math.min(TIMELINE_ZOOM_MAX, initialZoom),
);
}
return minZoom;
@@ -91,7 +98,7 @@ export function useTimelineZoom({
setZoomLevel((prev) => {
const nextZoom = Math.max(
minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
+ Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
);
return nextZoom;
});
@@ -105,7 +112,7 @@ export function useTimelineZoom({
if (initialZoom !== undefined && !hasInitializedRef.current) {
hasInitializedRef.current = true;
setZoomLevel(
- Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
+ Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
);
return;
}
@@ -126,7 +133,7 @@ export function useTimelineZoom({
: zoomLevelOrUpdater;
const clampedZoom = Math.max(
minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
+ Math.min(TIMELINE_ZOOM_MAX, nextZoom),
);
return clampedZoom;
});
@@ -153,12 +160,12 @@ export function useTimelineZoom({
});
const isCrossingThresholdUp =
previousSliderPercent <
- TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const isCrossingThresholdDown =
previousSliderPercent >=
- TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const syncScroll = (scrollLeft: number) => {
scrollElement.scrollLeft = scrollLeft;
@@ -178,11 +185,11 @@ export function useTimelineZoom({
isInPlayheadAnchorModeRef.current = true;
}
- if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
+ if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
- playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
+ playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
- playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
index 2c9c43ea..1cba34c0 100644
--- a/apps/web/src/hooks/use-paste-media.ts
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -7,7 +7,7 @@ import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
@@ -74,7 +74,7 @@ export function usePasteMedia() {
);
const assetId = addMediaCmd.getAssetId();
const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
diff --git a/apps/web/src/lib/background/constants.ts b/apps/web/src/lib/background/constants.ts
new file mode 100644
index 00000000..654154a5
--- /dev/null
+++ b/apps/web/src/lib/background/constants.ts
@@ -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";
diff --git a/apps/web/src/lib/canvas/constants.ts b/apps/web/src/lib/canvas/constants.ts
new file mode 100644
index 00000000..dce75bcb
--- /dev/null
+++ b/apps/web/src/lib/canvas/constants.ts
@@ -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 };
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index 86fa3e35..6f54ee72 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -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 {
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index cdff3565..2119d219 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -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;
}
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
index 4057ec41..f0574ce7 100644
--- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
+++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
@@ -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";
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
index 120220fe..0094f1de 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
@@ -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,
);
diff --git a/apps/web/src/lib/project/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts
index b30408ef..fd082bb2 100644
--- a/apps/web/src/lib/project/__tests__/fps.test.ts
+++ b/apps/web/src/lib/fps/__tests__/fps.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/src/lib/fps/constants.ts b/apps/web/src/lib/fps/constants.ts
new file mode 100644
index 00000000..100c043a
--- /dev/null
+++ b/apps/web/src/lib/fps/constants.ts
@@ -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;
diff --git a/apps/web/src/lib/project/fps.ts b/apps/web/src/lib/fps/utils.ts
index f031d348..8e3221a1 100644
--- a/apps/web/src/lib/project/fps.ts
+++ b/apps/web/src/lib/fps/utils.ts
@@ -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;
+}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
index 0afc42fb..d1025aa9 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -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;
diff --git a/apps/web/src/lib/panels/constants.ts b/apps/web/src/lib/panels/constants.ts
new file mode 100644
index 00000000..5a570469
--- /dev/null
+++ b/apps/web/src/lib/panels/constants.ts
@@ -0,0 +1,9 @@
+export const PANEL_CONFIG = {
+ panels: {
+ tools: 25,
+ preview: 50,
+ properties: 25,
+ mainContent: 50,
+ timeline: 50,
+ },
+} as const;
diff --git a/apps/web/src/lib/preview/constants.ts b/apps/web/src/lib/preview/constants.ts
new file mode 100644
index 00000000..9e6f5905
--- /dev/null
+++ b/apps/web/src/lib/preview/constants.ts
@@ -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;
diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts
index f8dd297c..92902dff 100644
--- a/apps/web/src/lib/retime/audio-stretch.ts
+++ b/apps/web/src/lib/retime/audio-stretch.ts
@@ -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";
diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts
index ca6725dd..2ab1d10f 100644
--- a/apps/web/src/lib/retime/index.ts
+++ b/apps/web/src/lib/retime/index.ts
@@ -1,3 +1,4 @@
+export * from "./rate";
export * from "./audio-stretch";
export * from "./presets";
export * from "./resolve";
diff --git a/apps/web/src/lib/retime/presets.ts b/apps/web/src/lib/retime/presets.ts
index 777c0219..fbd391ff 100644
--- a/apps/web/src/lib/retime/presets.ts
+++ b/apps/web/src/lib/retime/presets.ts
@@ -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,
diff --git a/apps/web/src/constants/retime-constants.ts b/apps/web/src/lib/retime/rate.ts
index c9949583..75ea530b 100644
--- a/apps/web/src/constants/retime-constants.ts
+++ b/apps/web/src/lib/retime/rate.ts
@@ -1,30 +1,25 @@
-export const DEFAULT_RETIME_RATE = 1;
-export const MIN_RETIME_RATE = 0.01;
-export const MAX_RETIME_RATE = 5;
-export const MAX_PITCH_PRESERVE_RATE = 10;
-
-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 {
- if (!Number.isFinite(rate) || rate <= 0) {
- return false;
- }
-
- return clampRetimeRate({ rate }) <= MAX_PITCH_PRESERVE_RATE;
-}
-
-export function shouldMaintainPitch({
- rate,
- maintainPitch,
-}: {
- rate: number;
- maintainPitch?: boolean;
-}): boolean {
- return maintainPitch === true && canMaintainPitch({ rate });
-}
+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 });
+}
diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts
index 31a562be..5e7236d5 100644
--- a/apps/web/src/lib/retime/resolve.ts
+++ b/apps/web/src/lib/retime/resolve.ts
@@ -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 });
diff --git a/apps/web/src/lib/text/constants.ts b/apps/web/src/lib/text/constants.ts
new file mode 100644
index 00000000..a101f209
--- /dev/null
+++ b/apps/web/src/lib/text/constants.ts
@@ -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;
diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts
new file mode 100644
index 00000000..335ab912
--- /dev/null
+++ b/apps/web/src/lib/timeline/creation.ts
@@ -0,0 +1 @@
+export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts
index 01dbe912..bc968a3c 100644
--- a/apps/web/src/lib/timeline/defaults.ts
+++ b/apps/web/src/lib/timeline/defaults.ts
@@ -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,
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts
index cf59a20d..8efeab79 100644
--- a/apps/web/src/lib/timeline/drag-utils.ts
+++ b/apps/web/src/lib/timeline/drag-utils.ts
@@ -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),
);
}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 5820b96d..96588903 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -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,
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
index 83a6a0f0..469b916e 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -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";
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
index c4635e52..e93b5ab5 100644
--- a/apps/web/src/lib/timeline/pixel-utils.ts
+++ b/apps/web/src/lib/timeline/pixel-utils.ts
@@ -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({
diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts
index e77a8a96..ee056261 100644
--- a/apps/web/src/lib/timeline/placement/track-factory.ts
+++ b/apps/web/src/lib/timeline/placement/track-factory.ts
@@ -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":
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
index 94ef416a..732d9ea4 100644
--- a/apps/web/src/lib/timeline/ruler-utils.ts
+++ b/apps/web/src/lib/timeline/ruler-utils.ts
@@ -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({
diff --git a/apps/web/src/lib/timeline/scale.ts b/apps/web/src/lib/timeline/scale.ts
new file mode 100644
index 00000000..7770f59f
--- /dev/null
+++ b/apps/web/src/lib/timeline/scale.ts
@@ -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;
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
index 2efe24e4..ef5d6e11 100644
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -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;
diff --git a/apps/web/src/lib/timeline/track-capabilities.ts b/apps/web/src/lib/timeline/track-capabilities.ts
new file mode 100644
index 00000000..08ee841c
--- /dev/null
+++ b/apps/web/src/lib/timeline/track-capabilities.ts
@@ -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";
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
deleted file mode 100644
index cc9e67c4..00000000
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ /dev/null
@@ -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;
-}
diff --git a/apps/web/src/lib/timeline/tracks.ts b/apps/web/src/lib/timeline/tracks.ts
new file mode 100644
index 00000000..181b9fcf
--- /dev/null
+++ b/apps/web/src/lib/timeline/tracks.ts
@@ -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;
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
index bbc0b6ae..c06957d0 100644
--- a/apps/web/src/lib/timeline/zoom-utils.ts
+++ b/apps/web/src/lib/timeline/zoom-utils.ts
@@ -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;
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 8fd8631b..76b97021 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -11,7 +11,7 @@ import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
-import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
+import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants";
import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
@@ -252,7 +252,8 @@ export function buildScene({
const blurNodes = buildBlurBackgroundNodes({
track: mainTrack,
mediaMap,
- blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
+ blurIntensity:
+ background.blurIntensity ?? DEFAULT_BACKGROUND_BLUR_INTENSITY,
});
for (const node of blurNodes) {
rootNode.add(node);
diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
index 31f7dae8..7a4dd641 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test";
import {
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_CANVAS_SIZE,
- DEFAULT_COLOR,
- DEFAULT_FPS,
-} from "@/constants/project-constants";
+ DEFAULT_BACKGROUND_BLUR_INTENSITY,
+ DEFAULT_BACKGROUND_COLOR,
+} from "@/lib/background/constants";
+import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
+import { DEFAULT_FPS } from "@/lib/fps/constants";
import type { MediaAssetData } from "@/services/storage/types";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
import {
@@ -119,7 +119,7 @@ describe("V1 to V2 Migration", () => {
const background = settings.background as Record<string, unknown>;
expect(background.type).toBe("color");
- expect(background.color).toBe(DEFAULT_COLOR);
+ expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR);
});
test("uses default blur intensity when missing", async () => {
@@ -135,7 +135,9 @@ describe("V1 to V2 Migration", () => {
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
- expect(background.blurIntensity).toBe(DEFAULT_BLUR_INTENSITY);
+ expect(background.blurIntensity).toBe(
+ DEFAULT_BACKGROUND_BLUR_INTENSITY,
+ );
});
test("preserves currentSceneId", async () => {
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index 52701cd5..a2c72478 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -1,9 +1,9 @@
import {
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_CANVAS_SIZE,
- DEFAULT_COLOR,
- DEFAULT_FPS,
-} from "@/constants/project-constants";
+ DEFAULT_BACKGROUND_BLUR_INTENSITY,
+ DEFAULT_BACKGROUND_COLOR,
+} from "@/lib/background/constants";
+import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
+import { DEFAULT_FPS } from "@/lib/fps/constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types";
@@ -739,14 +739,17 @@ function getBackgroundValue({
type: "blur",
blurIntensity: getNumberValue({
value: value.blurIntensity,
- fallback: DEFAULT_BLUR_INTENSITY,
+ fallback: DEFAULT_BACKGROUND_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
- color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
+ color: getStringValue({
+ value: value.color,
+ fallback: DEFAULT_BACKGROUND_COLOR,
+ }),
};
}
@@ -755,14 +758,17 @@ function getBackgroundValue({
type: "blur",
blurIntensity: getNumberValue({
value: blurIntensity,
- fallback: DEFAULT_BLUR_INTENSITY,
+ fallback: DEFAULT_BACKGROUND_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
- color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
+ color: getStringValue({
+ value: backgroundColor,
+ fallback: DEFAULT_BACKGROUND_COLOR,
+ }),
};
}
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index 0eb3c93e..50aa1b08 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants";
+import { DEFAULT_CANVAS_PRESETS } from "@/lib/canvas/constants";
import type { TCanvasSize } from "@/lib/project/types";
interface EditorState {
diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts
index 6f22b6b4..72f4e9bc 100644
--- a/apps/web/src/stores/panel-store.ts
+++ b/apps/web/src/stores/panel-store.ts
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { PANEL_CONFIG } from "@/constants/editor-constants";
+import { PANEL_CONFIG } from "@/lib/panels/constants";
export interface PanelSizes {
tools: number;