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

This commit is contained in:
Maze Winther 2026-04-01 19:27:49 +02:00
parent f4b9f40ab1
commit bd9ec023ee
72 changed files with 939 additions and 775 deletions

View File

@ -25,7 +25,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } 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 { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload"; import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions"; import { invokeAction } from "@/lib/actions";
@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number; startTime: number;
}) => { }) => {
const duration = const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({ const element = buildElementFromMedia({
mediaId: asset.id, mediaId: asset.id,
mediaType: asset.type, mediaType: asset.type,

View File

@ -8,10 +8,10 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { import {
BLUR_INTENSITY_PRESETS, BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY, DEFAULT_BACKGROUND_BLUR_INTENSITY,
DEFAULT_COLOR, DEFAULT_BACKGROUND_COLOR,
} from "@/constants/project-constants"; } from "@/lib/background/constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft"; import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid"; import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui"; import { syntaxUIGradients } from "@/data/colors/syntax-ui";
@ -164,16 +164,16 @@ export function BackgroundContent() {
const currentBlurIntensity = isBlurBackground const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number }) ? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity .blurIntensity
: DEFAULT_BLUR_INTENSITY; : DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color ? (activeProject.settings.background as { color: string }).color
: DEFAULT_COLOR; : DEFAULT_BACKGROUND_COLOR;
const canvasSize = activeProject.settings.canvasSize; const canvasSize = activeProject.settings.canvasSize;
const blurPreviews = useMemo( const blurPreviews = useMemo(
() => () =>
BLUR_INTENSITY_PRESETS.map((blur) => ( BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview <BlurPreview
key={blur.value} key={blur.value}
blur={blur} blur={blur}

View File

@ -9,7 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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 { useEditor } from "@/hooks/use-editor";
import { import {
Section, Section,

View File

@ -17,7 +17,7 @@ import {
screenToCanvas, screenToCanvas,
} from "@/lib/preview/preview-coords"; } from "@/lib/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math"; 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 MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001; const IS_AT_FIT_EPSILON = 0.001;

View File

@ -22,7 +22,7 @@ import {
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
} from "@/components/ui/select"; } 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 { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover"; import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store"; import { usePreviewStore } from "@/stores/preview-store";

View File

@ -11,7 +11,7 @@ import {
MAX_RETIME_RATE, MAX_RETIME_RATE,
clampRetimeRate, clampRetimeRate,
canMaintainPitch, canMaintainPitch,
} from "@/constants/retime-constants"; } from "@/lib/retime/rate";
import type { AudioElement, VideoElement } from "@/lib/timeline"; import type { AudioElement, VideoElement } from "@/lib/timeline";
import { import {
Section, Section,

View File

@ -18,13 +18,13 @@ import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math"; import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import { import {
CORNER_RADIUS_MAX, CORNER_RADIUS_MAX,
DEFAULT_TEXT_COLOR,
CORNER_RADIUS_MIN, CORNER_RADIUS_MIN,
MAX_FONT_SIZE, MAX_FONT_SIZE,
MIN_FONT_SIZE, MIN_FONT_SIZE,
} from "@/constants/text-constants"; } from "@/lib/text/constants";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
@ -343,7 +343,7 @@ function BackgroundSection({
trackId: string; trackId: string;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR); const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime, startTime: element.startTime,
duration: element.duration, duration: element.duration,

View File

@ -6,10 +6,10 @@ import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { import {
DEFAULT_BOOKMARK_COLOR, DEFAULT_TIMELINE_BOOKMARK_COLOR,
TIMELINE_BOOKMARK_ROW_HEIGHT, } from "./theme";
} from "@/constants/timeline-constants"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/lib/fps/constants";
import { getSnappedSeekTime } from "opencut-wasm"; import { getSnappedSeekTime } from "opencut-wasm";
import { import {
ArrowTurnBackwardIcon, ArrowTurnBackwardIcon,
@ -83,12 +83,12 @@ export function TimelineBookmarksRow({
return ( return (
<div <div
className="relative flex-1 overflow-hidden" className="relative flex-1 overflow-hidden"
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }} style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
> >
<button <button
className="relative w-full cursor-default select-none border-0 bg-transparent p-0" className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{ style={{
height: TIMELINE_BOOKMARK_ROW_HEIGHT, height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`, width: `${dynamicTimelineWidth}px`,
}} }}
aria-label="Timeline ruler" aria-label="Timeline ruler"
@ -153,7 +153,7 @@ function TimelineBookmark({
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); 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 }); const handleSeek = () => seekToBookmarkTime({ editor, time });
@ -298,12 +298,16 @@ function BookmarkPopoverContent({
}) { }) {
const editor = useEditor(); const editor = useEditor();
const [draftColorHex, setDraftColorHex] = useState( const [draftColorHex, setDraftColorHex] = useState(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
.replace("#", "")
.toUpperCase(),
); );
useEffect(() => { useEffect(() => {
setDraftColorHex( setDraftColorHex(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
.replace("#", "")
.toUpperCase(),
); );
}, [bookmark.color]); }, [bookmark.color]);
@ -357,7 +361,7 @@ function BookmarkPopoverContent({
/> />
{bookmark.color && {bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !== bookmark.color.replace(/^#/, "").toUpperCase() !==
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button <Button
type="button" type="button"
variant="text" variant="text"

View File

@ -1,6 +1,6 @@
import { getDropLineY } from "@/lib/timeline/drop-utils"; import { getDropLineY } from "./drop-target";
import type { TimelineTrack, DropTarget } from "@/lib/timeline"; import type { TimelineTrack, DropTarget } from "@/lib/timeline";
import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; import { TIMELINE_LAYERS } from "./layers";
interface DragLineProps { interface DragLineProps {
dropTarget: DropTarget | null; dropTarget: DropTarget | null;

View File

@ -1,257 +1,251 @@
import type { import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
TimelineTrack, import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
TimelineElement, import { resolveTrackPlacement } from "@/lib/timeline/placement";
} from "@/lib/timeline"; import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { getTrackHeight } from "./track-layout";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement"; function findElementAtPosition({
mouseX,
function findElementAtPosition({ tracks,
mouseX, trackIndex,
tracks, targetElementTypes,
trackIndex, pixelsPerSecond,
targetElementTypes, zoomLevel,
pixelsPerSecond, }: {
zoomLevel, mouseX: number;
}: { tracks: TimelineTrack[];
mouseX: number; trackIndex: number;
tracks: TimelineTrack[]; targetElementTypes: string[];
trackIndex: number; pixelsPerSecond: number;
targetElementTypes: string[]; zoomLevel: number;
pixelsPerSecond: number; }): { elementId: string; trackId: string } | null {
zoomLevel: number; const time = mouseX / (pixelsPerSecond * zoomLevel);
}): { elementId: string; trackId: string } | null { const track = tracks[trackIndex];
const time = mouseX / (pixelsPerSecond * zoomLevel); if (!track || !("elements" in track)) return null;
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null; const hit = track.elements.find(
(element: TimelineElement) =>
const hit = track.elements.find( targetElementTypes.includes(element.type) &&
(element: TimelineElement) => element.startTime <= time &&
targetElementTypes.includes(element.type) && time < element.startTime + element.duration,
element.startTime <= time && );
time < element.startTime + element.duration, if (!hit) return null;
); return { elementId: hit.id, trackId: track.id };
if (!hit) return null; }
return { elementId: hit.id, trackId: track.id };
} function getTrackAtY({
mouseY,
function getTrackAtY({ tracks,
mouseY, verticalDragDirection,
tracks, }: {
verticalDragDirection, mouseY: number;
}: { tracks: TimelineTrack[];
mouseY: number; verticalDragDirection?: "up" | "down" | null;
tracks: TimelineTrack[]; }): { trackIndex: number; relativeY: number } | null {
verticalDragDirection?: "up" | "down" | null; let cumulativeHeight = 0;
}): { trackIndex: number; relativeY: number } | null {
let cumulativeHeight = 0; for (let i = 0; i < tracks.length; i++) {
const trackHeight = getTrackHeight({ type: tracks[i].type });
for (let i = 0; i < tracks.length; i++) { const trackTop = cumulativeHeight;
const trackHeight = TRACK_CONFIG[tracks[i].type].height; const trackBottom = trackTop + trackHeight;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight; if (mouseY >= trackTop && mouseY < trackBottom) {
return {
if (mouseY >= trackTop && mouseY < trackBottom) { trackIndex: i,
return { relativeY: mouseY - trackTop,
trackIndex: i, };
relativeY: mouseY - trackTop, }
};
} if (i < tracks.length - 1 && verticalDragDirection) {
const gapTop = trackBottom;
if (i < tracks.length - 1 && verticalDragDirection) { const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
const gapTop = trackBottom; if (mouseY >= gapTop && mouseY < gapBottom) {
const gapBottom = gapTop + TRACK_GAP; const isDraggingUp = verticalDragDirection === "up";
if (mouseY >= gapTop && mouseY < gapBottom) { return {
const isDraggingUp = verticalDragDirection === "up"; trackIndex: isDraggingUp ? i : i + 1,
return { relativeY: isDraggingUp ? trackHeight - 1 : 0,
trackIndex: isDraggingUp ? i : i + 1, };
relativeY: isDraggingUp ? trackHeight - 1 : 0, }
}; }
}
} cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
}
cumulativeHeight += trackHeight + TRACK_GAP;
} return null;
}
return null;
} const EMPTY_TARGET_ELEMENT = null;
const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({
xPosition,
function fallbackNewTrackDropTarget({ }: {
xPosition, xPosition: number;
}: { }): DropTarget {
xPosition: number; return {
}): DropTarget { trackIndex: 0,
return { isNewTrack: true,
trackIndex: 0, insertPosition: null,
isNewTrack: true, xPosition,
insertPosition: null, targetElement: EMPTY_TARGET_ELEMENT,
xPosition, };
targetElement: EMPTY_TARGET_ELEMENT, }
};
} export function computeDropTarget({
elementType,
export function computeDropTarget({ mouseX,
elementType, mouseY,
mouseX, tracks,
mouseY, playheadTime,
tracks, isExternalDrop,
playheadTime, elementDuration,
isExternalDrop, pixelsPerSecond,
elementDuration, zoomLevel,
pixelsPerSecond, verticalDragDirection,
zoomLevel, startTimeOverride,
verticalDragDirection, excludeElementId,
startTimeOverride, targetElementTypes,
excludeElementId, }: ComputeDropTargetParams): DropTarget {
targetElementTypes, const xPosition =
}: ComputeDropTargetParams): DropTarget { typeof startTimeOverride === "number"
const xPosition = ? startTimeOverride
typeof startTimeOverride === "number" : isExternalDrop
? startTimeOverride ? playheadTime
: isExternalDrop : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); if (tracks.length === 0) {
const placementResult = resolveTrackPlacement({
if (tracks.length === 0) { tracks,
const placementResult = resolveTrackPlacement({ elementType,
tracks, timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
elementType, strategy: {
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], type: "preferIndex",
strategy: { trackIndex: 0,
type: "preferIndex", hoverDirection: "below",
trackIndex: 0, createNewTrackOnly: true,
hoverDirection: "below", },
createNewTrackOnly: true, });
}, const emptyTimelineResult =
}); placementResult?.kind === "newTrack" ? placementResult : null;
const emptyTimelineResult = if (!emptyTimelineResult) {
placementResult?.kind === "newTrack" ? placementResult : null; return fallbackNewTrackDropTarget({ xPosition });
if (!emptyTimelineResult) { }
return fallbackNewTrackDropTarget({ xPosition });
} return {
trackIndex: emptyTimelineResult.insertIndex,
return { isNewTrack: true,
trackIndex: emptyTimelineResult.insertIndex, insertPosition: emptyTimelineResult.insertPosition,
isNewTrack: true, xPosition,
insertPosition: emptyTimelineResult.insertPosition, targetElement: EMPTY_TARGET_ELEMENT,
xPosition, };
targetElement: EMPTY_TARGET_ELEMENT, }
};
} const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0; const placementResult = resolveTrackPlacement({
tracks,
const placementResult = resolveTrackPlacement({ elementType,
tracks, timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
elementType, strategy: {
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], type: "preferIndex",
strategy: { trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
type: "preferIndex", hoverDirection: isAboveAllTracks ? "above" : "below",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, createNewTrackOnly: true,
hoverDirection: isAboveAllTracks ? "above" : "below", },
createNewTrackOnly: true, });
}, const outOfBoundsResult =
}); placementResult?.kind === "newTrack" ? placementResult : null;
const outOfBoundsResult = if (!outOfBoundsResult) {
placementResult?.kind === "newTrack" ? placementResult : null; return fallbackNewTrackDropTarget({ xPosition });
if (!outOfBoundsResult) { }
return fallbackNewTrackDropTarget({ xPosition });
} return {
trackIndex: outOfBoundsResult.insertIndex,
return { isNewTrack: true,
trackIndex: outOfBoundsResult.insertIndex, insertPosition: outOfBoundsResult.insertPosition,
isNewTrack: true, xPosition,
insertPosition: outOfBoundsResult.insertPosition, targetElement: EMPTY_TARGET_ELEMENT,
xPosition, };
targetElement: EMPTY_TARGET_ELEMENT, }
};
} const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex]; if (targetElementTypes && targetElementTypes.length > 0) {
const targetElement = findElementAtPosition({
if ( mouseX,
targetElementTypes && tracks,
targetElementTypes.length > 0 trackIndex,
) { targetElementTypes,
const targetElement = findElementAtPosition({ pixelsPerSecond,
mouseX, zoomLevel,
tracks, });
trackIndex, if (targetElement) {
targetElementTypes, return {
pixelsPerSecond, trackIndex,
zoomLevel, isNewTrack: false,
}); insertPosition: null,
if (targetElement) { xPosition,
return { targetElement,
trackIndex, };
isNewTrack: false, }
insertPosition: null, }
xPosition,
targetElement, const trackHeight = getTrackHeight({ type: track.type });
}; const placementResult = resolveTrackPlacement({
} tracks,
} elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
const trackHeight = TRACK_CONFIG[track.type].height; strategy: {
const placementResult = resolveTrackPlacement({ type: "preferIndex",
tracks, trackIndex,
elementType, hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], verticalDragDirection,
strategy: { },
type: "preferIndex", });
trackIndex, if (!placementResult) {
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", return fallbackNewTrackDropTarget({ xPosition });
verticalDragDirection, }
},
}); if (placementResult.kind === "existingTrack") {
if (!placementResult) { const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
return fallbackNewTrackDropTarget({ xPosition });
} return {
trackIndex: placementResult.trackIndex,
if (placementResult.kind === "existingTrack") { isNewTrack: false,
const adjustedXPosition = insertPosition: null,
placementResult.adjustedStartTime ?? xPosition; xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
return { };
trackIndex: placementResult.trackIndex, }
isNewTrack: false,
insertPosition: null, return {
xPosition: adjustedXPosition, trackIndex: placementResult.insertIndex,
targetElement: EMPTY_TARGET_ELEMENT, isNewTrack: true,
}; insertPosition: placementResult.insertPosition,
} xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
return { };
trackIndex: placementResult.insertIndex, }
isNewTrack: true,
insertPosition: placementResult.insertPosition, export function getDropLineY({
xPosition, dropTarget,
targetElement: EMPTY_TARGET_ELEMENT, tracks,
}; }: {
} dropTarget: DropTarget;
tracks: TimelineTrack[];
export function getDropLineY({ }): number {
dropTarget, const safeTrackIndex = Math.min(
tracks, Math.max(dropTarget.trackIndex, 0),
}: { tracks.length,
dropTarget: DropTarget; );
tracks: TimelineTrack[]; let y = 0;
}): number {
const safeTrackIndex = Math.min( for (let i = 0; i < safeTrackIndex; i++) {
Math.max(dropTarget.trackIndex, 0), y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
tracks.length, }
);
let y = 0; return y;
}
for (let i = 0; i < safeTrackIndex; i++) {
y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
}

View File

@ -38,20 +38,26 @@ import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { import {
TIMELINE_CONSTANTS,
TRACK_GAP,
TIMELINE_SCROLLBAR_SIZE_PX, 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 { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import { import {
getTrackHeight, canTrackHaveAudio,
getCumulativeHeightBefore,
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden, canTrackBeHidden,
getTimelineZoomMin, getTimelineZoomMin,
getTimelinePaddingPx, getTimelinePaddingPx,
} from "@/lib/timeline"; } 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 { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; 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 { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line"; import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions"; import { invokeAction } from "@/lib/actions";
import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing"; import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800; 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; Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
const clamped = const clamped =
Math.sign(raw) * 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); tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
} else { } else {
tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
@ -335,7 +341,7 @@ export function Timeline() {
const containerWidth = const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({ const paddingPx = getTimelinePaddingPx({
containerWidth, containerWidth,
zoomLevel, zoomLevel,
@ -383,7 +389,7 @@ export function Timeline() {
const timelineHeaderHeight = const timelineHeaderHeight =
(timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
TIMELINE_CONSTANTS.PADDING_TOP_PX || 0; TIMELINE_CONTENT_TOP_PADDING_PX || 0;
return ( return (
<section <section
@ -483,7 +489,7 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.max, TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks }), getTotalTracksHeight({ tracks }),
), ),
) + TIMELINE_CONSTANTS.PADDING_TOP_PX ) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`, }px`,
}} }}
onMouseDown={(event) => { onMouseDown={(event) => {
@ -577,7 +583,10 @@ function TrackLabelsPanel({
); );
return ( 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 <div
className="shrink-0" className="shrink-0"
style={{ height: timelineHeaderHeight || 48 }} style={{ height: timelineHeaderHeight || 48 }}
@ -585,21 +594,24 @@ function TrackLabelsPanel({
<div ref={trackLabelsRef} className="flex-1 overflow-hidden"> <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
<div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
{tracks.length > 0 && ( {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) => ( {tracks.map((track) => (
<div <div
key={track.id} key={track.id}
className={cn( className={cn(
"group flex items-center px-3", "group flex items-center px-3",
tracksWithSelection.has(track.id) && tracksWithSelection.has(track.id) &&
TIMELINE_CONSTANTS.TRACK_SELECTED_BG, SELECTED_TRACK_ROW_CLASS,
)} )}
style={{ style={{
height: `${getTrackHeight({ type: track.type })}px`, height: `${getTrackHeight({ type: track.type })}px`,
}} }}
> >
<div className="flex min-w-0 flex-1 items-center justify-end gap-2"> <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{canTracktHaveAudio(track) && ( {canTrackHaveAudio(track) && (
<TrackToggleIcon <TrackToggleIcon
isOff={track.muted} isOff={track.muted}
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }} icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
@ -707,10 +719,10 @@ function TimelineTrackRows({
className={cn( className={cn(
"absolute right-0 left-0 transition-colors", "absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) && tracksWithSelection.has(track.id) &&
TIMELINE_CONSTANTS.TRACK_SELECTED_BG, SELECTED_TRACK_ROW_CLASS,
)} )}
style={{ 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`, height: `${getTrackHeight({ type: track.type })}px`,
}} }}
> >
@ -753,7 +765,7 @@ function TimelineTrackRows({
timeline.toggleTrackMute({ trackId: track.id }); timeline.toggleTrackMute({ trackId: track.id });
}} }}
> >
{canTracktHaveAudio(track) && track.muted {canTrackHaveAudio(track) && track.muted
? "Unmute track" ? "Unmute track"
: "Mute track"} : "Mute track"}
</ContextMenuItem> </ContextMenuItem>

View File

@ -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;

View File

@ -0,0 +1,6 @@
export const TIMELINE_LAYERS = {
trackContent: 10,
dragLine: 20,
playhead: 30,
snapIndicator: 40,
} as const;

View File

@ -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;

View File

@ -1,133 +1,136 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { TimelineTrack } from "@/lib/timeline";
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
import type { TimelineTrack } from "@/lib/timeline"; import {
TIMELINE_CONTENT_TOP_PADDING_PX,
type TimelineElementRef = { trackId: string; elementId: string }; } from "./layout";
import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
interface SelectionRectangle {
left: number; type TimelineElementRef = { trackId: string; elementId: string };
top: number;
right: number; interface SelectionRectangle {
bottom: number; left: number;
} top: number;
right: number;
function getNormalizedRectangle({ bottom: number;
startPos, }
endPos,
}: { function getNormalizedRectangle({
startPos: { x: number; y: number }; startPos,
endPos: { x: number; y: number }; endPos,
}): SelectionRectangle { }: {
return { startPos: { x: number; y: number };
left: Math.min(startPos.x, endPos.x), endPos: { x: number; y: number };
top: Math.min(startPos.y, endPos.y), }): SelectionRectangle {
right: Math.max(startPos.x, endPos.x), return {
bottom: Math.max(startPos.y, endPos.y), 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, function getSelectionRectangleInContent({
endPos, container,
}: { scrollContainer,
container: HTMLElement; startPos,
scrollContainer: HTMLDivElement | null; endPos,
startPos: { x: number; y: number }; }: {
endPos: { x: number; y: number }; container: HTMLElement;
}): SelectionRectangle { scrollContainer: HTMLDivElement | null;
const containerRect = container.getBoundingClientRect(); startPos: { x: number; y: number };
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; endPos: { x: number; y: number };
const scrollLeft = scrollContainer?.scrollLeft ?? 0; }): SelectionRectangle {
const scrollTop = scrollContainer?.scrollTop ?? 0; const containerRect = container.getBoundingClientRect();
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
const adjustedStart = { const scrollLeft = scrollContainer?.scrollLeft ?? 0;
x: startPos.x - containerRect.left + scrollLeft, const scrollTop = scrollContainer?.scrollTop ?? 0;
y: startPos.y - scrollRect.top + scrollTop,
}; const adjustedStart = {
const adjustedEnd = { x: startPos.x - containerRect.left + scrollLeft,
x: endPos.x - containerRect.left + scrollLeft, y: startPos.y - scrollRect.top + scrollTop,
y: endPos.y - scrollRect.top + scrollTop, };
}; const adjustedEnd = {
x: endPos.x - containerRect.left + scrollLeft,
return getNormalizedRectangle({ y: endPos.y - scrollRect.top + scrollTop,
startPos: adjustedStart, };
endPos: adjustedEnd,
}); return getNormalizedRectangle({
} startPos: adjustedStart,
endPos: adjustedEnd,
function isRectangleIntersecting({ });
elementRectangle, }
selectionRectangle,
}: { function isRectangleIntersecting({
elementRectangle: SelectionRectangle; elementRectangle,
selectionRectangle: SelectionRectangle; selectionRectangle,
}): boolean { }: {
return !( elementRectangle: SelectionRectangle;
elementRectangle.right < selectionRectangle.left || selectionRectangle: SelectionRectangle;
elementRectangle.left > selectionRectangle.right || }): boolean {
elementRectangle.bottom < selectionRectangle.top || return !(
elementRectangle.top > selectionRectangle.bottom elementRectangle.right < selectionRectangle.left ||
); elementRectangle.left > selectionRectangle.right ||
} elementRectangle.bottom < selectionRectangle.top ||
elementRectangle.top > selectionRectangle.bottom
export function resolveTimelineElementIntersections({ );
container, }
scrollContainer,
tracks, export function resolveTimelineElementIntersections({
zoomLevel, container,
startPos, scrollContainer,
currentPos, tracks,
}: { zoomLevel,
container: HTMLElement; startPos,
scrollContainer: HTMLDivElement | null; currentPos,
tracks: TimelineTrack[]; }: {
zoomLevel: number; container: HTMLElement;
startPos: { x: number; y: number }; scrollContainer: HTMLDivElement | null;
currentPos: { x: number; y: number }; tracks: TimelineTrack[];
}): TimelineElementRef[] { zoomLevel: number;
const selectionRectangle = getSelectionRectangleInContent({ startPos: { x: number; y: number };
container, currentPos: { x: number; y: number };
scrollContainer, }): TimelineElementRef[] {
startPos, const selectionRectangle = getSelectionRectangleInContent({
endPos: currentPos, container,
}); scrollContainer,
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; startPos,
const selectedElements: TimelineElementRef[] = []; endPos: currentPos,
});
for (const [trackIndex, track] of tracks.entries()) { const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const trackTop = getCumulativeHeightBefore({ const selectedElements: TimelineElementRef[] = [];
tracks,
trackIndex, for (const [trackIndex, track] of tracks.entries()) {
}); const trackTop = getCumulativeHeightBefore({
const trackHeight = getTrackHeight({ type: track.type }); tracks,
const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; trackIndex,
const elementBottom = elementTop + trackHeight; });
const trackHeight = getTrackHeight({ type: track.type });
for (const element of track.elements) { const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
const elementLeft = element.startTime * pixelsPerSecond; const elementBottom = elementTop + trackHeight;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementRectangle = { for (const element of track.elements) {
left: elementLeft, const elementLeft = element.startTime * pixelsPerSecond;
top: elementTop, const elementRight = elementLeft + element.duration * pixelsPerSecond;
right: elementRight, const elementRectangle = {
bottom: elementBottom, left: elementLeft,
}; top: elementTop,
right: elementRight,
if ( bottom: elementBottom,
isRectangleIntersecting({ };
elementRectangle,
selectionRectangle, if (
}) isRectangleIntersecting({
) { elementRectangle,
selectedElements.push({ selectionRectangle,
trackId: track.id, })
elementId: element.id, ) {
}); selectedElements.push({
} trackId: track.id,
} elementId: element.id,
} });
}
return selectedElements; }
} }
return selectedElements;
}

View File

@ -6,7 +6,7 @@ import {
getCenteredLineLeft, getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX, TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; import { TIMELINE_LAYERS } from "./layers";
interface SnapIndicatorProps { interface SnapIndicatorProps {
snapPoint: SnapPoint | null; snapPoint: SnapPoint | null;
zoomLevel: number; zoomLevel: number;

View File

@ -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();
}

View File

@ -12,8 +12,6 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation"; import { getElementKeyframes } from "@/lib/animation";
import { import {
getElementClasses,
getTrackHeight,
canElementHaveAudio, canElementHaveAudio,
canElementBeHidden, canElementBeHidden,
hasElementEffects, hasElementEffects,
@ -21,7 +19,11 @@ import {
timelineTimeToPixels, timelineTimeToPixels,
timelineTimeToSnappedPixels, timelineTimeToSnappedPixels,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; import { getTrackHeight } from "./track-layout";
import {
getTimelineElementClassName,
TIMELINE_TRACK_THEME,
} from "./theme";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -464,7 +466,7 @@ function ElementInner({
<div <div
className={cn( className={cn(
"absolute inset-0 overflow-hidden rounded-sm", "absolute inset-0 overflow-hidden rounded-sm",
getElementClasses({ type: track.type }), getTimelineElementClassName({ type: track.type }),
isReducedOpacity && "opacity-50", isReducedOpacity && "opacity-50",
)} )}
> >
@ -710,7 +712,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform <AudioWaveform
audioBuffer={audioBuffer} audioBuffer={audioBuffer}
audioUrl={audioUrl} audioUrl={audioUrl}
color={ELEMENT_TYPE_CONFIG.audio.waveformColor} color={TIMELINE_TRACK_THEME.audio.waveformColor}
/> />
<MediaElementHeader name={mediaLabel} hasFade={false} /> <MediaElementHeader name={mediaLabel} hasFade={false} />
</div> </div>

View File

@ -9,9 +9,9 @@ import {
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import {
TIMELINE_LAYERS,
TIMELINE_SCROLLBAR_SIZE_PX, TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants"; } from "./layout";
import { TIMELINE_LAYERS } from "./layers";
interface TimelinePlayheadProps { interface TimelinePlayheadProps {
zoomLevel: number; zoomLevel: number;

View File

@ -1,9 +1,9 @@
import { type JSX, useLayoutEffect, useRef } from "react"; import { type JSX, useLayoutEffect, useRef } from "react";
import { import {
TIMELINE_CONSTANTS, BASE_TIMELINE_PIXELS_PER_SECOND,
TIMELINE_RULER_HEIGHT, } from "@/lib/timeline/scale";
} from "@/constants/timeline-constants"; import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position"; import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
@ -31,7 +31,7 @@ export function TimelineRuler({
handleRulerMouseDown, handleRulerMouseDown,
}: TimelineRulerProps) { }: TimelineRulerProps) {
const duration = useEditor((e) => e.timeline.getTotalDuration()); 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 visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration); const effectiveDuration = Math.max(duration, visibleDuration);
const fps = const fps =
@ -107,7 +107,7 @@ export function TimelineRuler({
aria-valuemax={effectiveDuration} aria-valuemax={effectiveDuration}
aria-valuenow={0} aria-valuenow={0}
className="relative flex-1 overflow-x-visible" className="relative flex-1 overflow-x-visible"
style={{ height: TIMELINE_RULER_HEIGHT }} style={{ height: TIMELINE_RULER_HEIGHT_PX }}
onWheel={handleWheel} onWheel={handleWheel}
onClick={(event) => { onClick={(event) => {
// Ruler seek already happens on mousedown via playhead scrubbing. // Ruler seek already happens on mousedown via playhead scrubbing.
@ -124,7 +124,7 @@ export function TimelineRuler({
ref={rulerRef} ref={rulerRef}
className="relative cursor-default select-none" className="relative cursor-default select-none"
style={{ style={{
height: TIMELINE_RULER_HEIGHT, height: TIMELINE_RULER_HEIGHT_PX,
width: `${dynamicTimelineWidth}px`, width: `${dynamicTimelineWidth}px`,
}} }}
onMouseDown={handleRulerMouseDown} onMouseDown={handleRulerMouseDown}

View File

@ -14,7 +14,10 @@ import {
SplitButtonSeparator, SplitButtonSeparator,
} from "@/components/ui/split-button"; } from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider"; 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 { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view"; import { ScenesView } from "@/components/editor/scenes-view";
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
@ -60,10 +63,10 @@ export function TimelineToolbar({
const newZoomLevel = const newZoomLevel =
direction === "in" direction === "in"
? Math.min( ? Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX, TIMELINE_ZOOM_MAX,
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR, 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 }); setZoomLevel({ zoom: newZoomLevel });
}; };

View File

@ -5,7 +5,8 @@ import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils"; 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 { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline"; import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
@ -59,7 +60,7 @@ export function TimelineTrackContent({
getMouseClientX: () => lastMouseXRef.current ?? 0, getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef, rulerScrollRef,
tracksScrollRef, tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
}); });
return ( return (

View File

@ -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;
}

View File

@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
import { import {
clampRetimeRate, clampRetimeRate,
shouldMaintainPitch, shouldMaintainPitch,
} from "@/constants/retime-constants"; } from "@/lib/retime/rate";
import type { AudioClipSource } from "@/lib/media/audio"; import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio"; import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import { import {

View File

@ -12,11 +12,9 @@ import { storageService } from "@/services/storage/service";
import { toast } from "sonner"; import { toast } from "sonner";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
import { import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/constants";
DEFAULT_FPS, import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
DEFAULT_CANVAS_SIZE, import { DEFAULT_FPS } from "@/lib/fps/constants";
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes"; import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder"; import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
@ -29,7 +27,7 @@ import {
import { loadFonts } from "@/lib/fonts/google-fonts"; import { loadFonts } from "@/lib/fonts/google-fonts";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils"; 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"; import type { MediaAsset } from "@/lib/media/types";
export interface MigrationState { export interface MigrationState {
@ -101,7 +99,7 @@ export class ProjectManager {
originalCanvasSize: null, originalCanvasSize: null,
background: { background: {
type: "color", type: "color",
color: DEFAULT_COLOR, color: DEFAULT_BACKGROUND_COLOR,
}, },
}, },
version: CURRENT_PROJECT_VERSION, version: CURRENT_PROJECT_VERSION,

View File

@ -10,12 +10,10 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
DRAG_THRESHOLD_PX, import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "opencut-wasm"; 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 { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils"; import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
@ -70,7 +68,7 @@ function getClickOffsetTime({
zoomLevel: number; zoomLevel: number;
}): number { }): number {
const clickOffsetX = clientX - elementRect.left; const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
} }
function getVerticalDragDirection({ function getVerticalDragDirection({
@ -136,7 +134,7 @@ function getDragDropTarget({
playheadTime: snappedTime, playheadTime: snappedTime,
isExternalDrop: false, isExternalDrop: false,
elementDuration, elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel, zoomLevel,
startTimeOverride: snappedTime, startTimeOverride: snappedTime,
excludeElementId: movingElement.id, excludeElementId: movingElement.id,
@ -288,7 +286,10 @@ export function useElementInteraction({
if (isPendingDrag && pendingDragRef.current) { if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); 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(); const activeProject = editor.project.getActive();
if (!activeProject) return; if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft; const scrollLeft = scrollContainer.scrollLeft;
@ -416,7 +417,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) { if (mouseDownLocationRef.current) {
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); 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; mouseDownLocationRef.current = null;
endDrag(); endDrag();
onSnapPointChange?.(null); onSnapPointChange?.(null);
@ -596,7 +600,10 @@ export function useElementInteraction({
if (mouseDownLocationRef.current) { if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); 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; mouseDownLocationRef.current = null;
return; return;
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react"; 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 { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
@ -190,7 +190,7 @@ export function useTimelineElementResize({
const deltaX = clientX - resizing.startX; const deltaX = clientX - resizing.startX;
let deltaTime = let deltaTime =
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null; let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps; const projectFps = editor.project.getActive().settings.fps;

View File

@ -9,10 +9,8 @@ import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection"; import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
DRAG_THRESHOLD_PX, import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands"; import { BatchCommand } from "@/lib/commands";
import type { SelectedKeyframeRef } from "@/lib/animation/types"; import type { SelectedKeyframeRef } from "@/lib/animation/types";
@ -64,7 +62,7 @@ export function useKeyframeDrag({
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const fps = activeProject.settings.fps; const fps = activeProject.settings.fps;
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const endDrag = useCallback(() => { const endDrag = useCallback(() => {
setDragState(initialDragState); setDragState(initialDragState);
@ -127,7 +125,7 @@ export function useKeyframeDrag({
const handleMouseMove = ({ clientX }: MouseEvent) => { const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) { if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); 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; const pending = pendingDragRef.current;
pendingDragRef.current = null; pendingDragRef.current = null;
@ -248,7 +246,8 @@ export function useKeyframeDrag({
const wasDrag = const wasDrag =
mouseDownXRef.current !== null && 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; mouseDownXRef.current = null;
if (wasDrag) return; if (wasDrag) return;

View File

@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; 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 { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { import {
@ -131,7 +131,10 @@ export function useBookmarkDrag({
const deltaX = Math.abs(event.clientX - startMouseX); const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY); 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; return;
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline"; 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 { interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null; snapPoint: { time: number } | null;
zoomLevel: number; zoomLevel: number;
@ -44,7 +44,8 @@ export function useSnapIndicatorPosition({
time: snapPoint?.time ?? 0, time: snapPoint?.time ?? 0,
zoomLevel, zoomLevel,
}); });
const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft; const leftPosition =
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
return { return {
leftPosition, leftPosition,

View File

@ -3,7 +3,8 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing"; import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast"; 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 { snapTimeToFrame } from "opencut-wasm";
import { import {
buildTextElement, buildTextElement,
@ -14,7 +15,7 @@ import {
} from "@/lib/timeline/element-utils"; } from "@/lib/timeline/element-utils";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands"; 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 { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/placement"; import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
@ -82,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" || elementType === "sticker" ||
elementType === "effect" elementType === "effect"
) { ) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
} }
if (mediaId) { if (mediaId) {
const mediaAssets = editor.media.getAssets(); const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId); 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], [editor],
); );
@ -158,7 +159,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime, playheadTime: currentTime,
isExternalDrop: isExternal, isExternalDrop: isExternal,
elementDuration: duration, elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel, zoomLevel,
targetElementTypes, targetElementTypes,
}); });
@ -330,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video"; dragData.mediaType === "audio" ? "audio" : "video";
const duration = const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const element = buildElementFromMedia({ const element = buildElementFromMedia({
mediaId: mediaAsset.id, mediaId: mediaAsset.id,
mediaType: mediaAsset.type, mediaType: mediaAsset.type,
@ -445,7 +446,7 @@ export function useTimelineDragDrop({
const duration = const duration =
createdAsset.duration ?? createdAsset.duration ??
TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const currentTracks = editor.timeline.getTracks(); const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0]; const onlyTrack = currentTracks[0];
@ -467,7 +468,7 @@ export function useTimelineDragDrop({
playheadTime: currentTime, playheadTime: currentTime,
isExternalDrop: true, isExternalDrop: true,
elementDuration: duration, elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
zoomLevel, zoomLevel,
}); });

View File

@ -8,7 +8,7 @@ import {
getCenteredLineLeft, getCenteredLineLeft,
timelineTimeToSnappedPixels, timelineTimeToSnappedPixels,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
interface UseTimelinePlayheadProps { interface UseTimelinePlayheadProps {
zoomLevel: number; zoomLevel: number;
@ -67,7 +67,7 @@ export function useTimelinePlayhead({
const relativeMouseX = event.clientX - rulerRect.left; const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth = const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const clampedMouseX = Math.max( const clampedMouseX = Math.max(
0, 0,
@ -78,7 +78,7 @@ export function useTimelinePlayhead({
0, 0,
Math.min( Math.min(
duration, 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, getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef, rulerScrollRef,
tracksScrollRef, tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
}); });
useEffect(() => { useEffect(() => {
@ -248,7 +248,7 @@ export function useTimelinePlayhead({
if (!rulerViewport || !tracksViewport) return; if (!rulerViewport || !tracksViewport) return;
const playheadPixels = const playheadPixels =
time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current; time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const viewportWidth = rulerViewport.clientWidth; const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0; const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;

View File

@ -1,6 +1,6 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } 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 { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor"; import { useEditor } from "../use-editor";
@ -132,7 +132,7 @@ export function useTimelineSeek({
Math.min( Math.min(
duration, duration,
(mouseX + scrollLeft) / (mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
), ),
); );

View File

@ -7,7 +7,14 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } 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 { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils"; import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@ -30,7 +37,7 @@ interface UseTimelineZoomReturn {
export function useTimelineZoom({ export function useTimelineZoom({
containerRef, containerRef,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, minZoom = TIMELINE_ZOOM_MIN,
initialZoom, initialZoom,
initialScrollLeft, initialScrollLeft,
initialPlayheadTime, initialPlayheadTime,
@ -49,7 +56,7 @@ export function useTimelineZoom({
hasInitializedRef.current = true; hasInitializedRef.current = true;
return Math.max( return Math.max(
minZoom, minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom), Math.min(TIMELINE_ZOOM_MAX, initialZoom),
); );
} }
return minZoom; return minZoom;
@ -91,7 +98,7 @@ export function useTimelineZoom({
setZoomLevel((prev) => { setZoomLevel((prev) => {
const nextZoom = Math.max( const nextZoom = Math.max(
minZoom, minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor), Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
); );
return nextZoom; return nextZoom;
}); });
@ -105,7 +112,7 @@ export function useTimelineZoom({
if (initialZoom !== undefined && !hasInitializedRef.current) { if (initialZoom !== undefined && !hasInitializedRef.current) {
hasInitializedRef.current = true; hasInitializedRef.current = true;
setZoomLevel( setZoomLevel(
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)), Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
); );
return; return;
} }
@ -126,7 +133,7 @@ export function useTimelineZoom({
: zoomLevelOrUpdater; : zoomLevelOrUpdater;
const clampedZoom = Math.max( const clampedZoom = Math.max(
minZoom, minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom), Math.min(TIMELINE_ZOOM_MAX, nextZoom),
); );
return clampedZoom; return clampedZoom;
}); });
@ -153,12 +160,12 @@ export function useTimelineZoom({
}); });
const isCrossingThresholdUp = const isCrossingThresholdUp =
previousSliderPercent < previousSliderPercent <
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const isCrossingThresholdDown = const isCrossingThresholdDown =
previousSliderPercent >= previousSliderPercent >=
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const syncScroll = (scrollLeft: number) => { const syncScroll = (scrollLeft: number) => {
scrollElement.scrollLeft = scrollLeft; scrollElement.scrollLeft = scrollLeft;
@ -178,11 +185,11 @@ export function useTimelineZoom({
isInPlayheadAnchorModeRef.current = true; isInPlayheadAnchorModeRef.current = true;
} }
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore = const playheadPixelsBefore =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom; playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter = const playheadPixelsAfter =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const viewportOffset = playheadPixelsBefore - currentScrollLeft; const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset; const newScrollLeft = playheadPixelsAfter - viewportOffset;

View File

@ -7,7 +7,7 @@ import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media"; import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline"; import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands"; 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 { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types"; import type { MediaType } from "@/lib/media/types";
@ -74,7 +74,7 @@ export function usePasteMedia() {
); );
const assetId = addMediaCmd.getAssetId(); const assetId = addMediaCmd.getAssetId();
const duration = const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video"; const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({ const element = buildElementFromMedia({

View File

@ -0,0 +1,11 @@
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
label: string;
value: number;
}> = [
{ label: "Light", value: 10 },
{ label: "Medium", value: 50 },
{ label: "Heavy", value: 100 },
] as const;
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
export const DEFAULT_BACKGROUND_COLOR = "#000000";

View File

@ -0,0 +1,10 @@
import type { TCanvasSize } from "@/lib/project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
{ width: 1080, height: 1920 },
{ width: 1080, height: 1080 },
{ width: 1440, height: 1080 },
];
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };

View File

@ -5,7 +5,7 @@ import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service"; import { storageService } from "@/services/storage/service";
import { hasMediaId } from "@/lib/timeline/element-utils"; 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"; import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command { export class AddMediaAssetCommand extends Command {

View File

@ -9,7 +9,7 @@ import type {
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils"; import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types"; 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 { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import { import {
applyPlacement, applyPlacement,
@ -136,7 +136,7 @@ export class InsertElementCommand extends Command {
startTime: element.startTime, startTime: element.startTime,
trimStart: element.trimStart ?? 0, trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0, trimEnd: element.trimEnd ?? 0,
duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
} as TimelineElement; } as TimelineElement;
} }

View File

@ -1,5 +1,5 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { clampRetimeRate } from "@/constants/retime-constants"; import { clampRetimeRate } from "@/lib/retime/rate";
import { clampAnimationsToDuration } from "@/lib/animation"; import { clampAnimationsToDuration } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";

View File

@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline"; import { canTrackHaveAudio } from "@/lib/timeline";
export class ToggleTrackMuteCommand extends Command { export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@ -22,7 +22,7 @@ export class ToggleTrackMuteCommand extends Command {
} }
const updatedTracks = this.savedState.map((track) => const updatedTracks = this.savedState.map((track) =>
track.id === this.trackId && canTracktHaveAudio(track) track.id === this.trackId && canTrackHaveAudio(track)
? { ...track, muted: !track.muted } ? { ...track, muted: !track.muted }
: track, : track,
); );

View File

@ -1,65 +1,65 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
getHighestImportedVideoFps, getHighestImportedVideoFps,
getRaisedProjectFpsForImportedMedia, getRaisedProjectFpsForImportedMedia,
} from "@/lib/project/fps"; } from "@/lib/fps/utils";
describe("getHighestImportedVideoFps", () => { describe("getHighestImportedVideoFps", () => {
test("returns the highest valid video fps", () => { test("returns the highest valid video fps", () => {
expect( expect(
getHighestImportedVideoFps({ getHighestImportedVideoFps({
mediaAssets: [ mediaAssets: [
{ type: "audio" }, { type: "audio" },
{ type: "video", fps: 30 }, { type: "video", fps: 30 },
{ type: "image", fps: 120 }, { type: "image", fps: 120 },
{ type: "video", fps: 60 }, { type: "video", fps: 60 },
], ],
}), }),
).toBe(60); ).toBe(60);
}); });
test("ignores missing and invalid fps values", () => { test("ignores missing and invalid fps values", () => {
expect( expect(
getHighestImportedVideoFps({ getHighestImportedVideoFps({
mediaAssets: [ mediaAssets: [
{ type: "video" }, { type: "video" },
{ type: "video", fps: 0 }, { type: "video", fps: 0 },
{ type: "video", fps: -10 }, { type: "video", fps: -10 },
{ type: "audio", fps: 120 }, { type: "audio", fps: 120 },
], ],
}), }),
).toBeNull(); ).toBeNull();
}); });
}); });
describe("getRaisedProjectFpsForImportedMedia", () => { describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => { test("raises the project fps to match a higher-fps import", () => {
expect( expect(
getRaisedProjectFpsForImportedMedia({ getRaisedProjectFpsForImportedMedia({
currentFps: 30, currentFps: 30,
importedAssets: [{ type: "video", fps: 60 }], importedAssets: [{ type: "video", fps: 60 }],
}), }),
).toBe(60); ).toBe(60);
}); });
test("does not lower the project fps for lower-fps imports", () => { test("does not lower the project fps for lower-fps imports", () => {
expect( expect(
getRaisedProjectFpsForImportedMedia({ getRaisedProjectFpsForImportedMedia({
currentFps: 60, currentFps: 60,
importedAssets: [{ type: "video", fps: 10 }], importedAssets: [{ type: "video", fps: 10 }],
}), }),
).toBeNull(); ).toBeNull();
}); });
test("ignores non-video imports", () => { test("ignores non-video imports", () => {
expect( expect(
getRaisedProjectFpsForImportedMedia({ getRaisedProjectFpsForImportedMedia({
currentFps: 30, currentFps: 30,
importedAssets: [ importedAssets: [
{ type: "image", fps: 60 }, { type: "image", fps: 60 },
{ type: "audio", fps: 120 }, { type: "audio", fps: 120 },
], ],
}), }),
).toBeNull(); ).toBeNull();
}); });
}); });

View File

@ -0,0 +1,9 @@
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
{ value: "30", label: "30 fps" },
{ value: "60", label: "60 fps" },
{ value: "120", label: "120 fps" },
] as const;
export const DEFAULT_FPS = 30;

View File

@ -1,39 +1,39 @@
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">; type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
export function getHighestImportedVideoFps({ export function getHighestImportedVideoFps({
mediaAssets, mediaAssets,
}: { }: {
mediaAssets: MediaAssetFpsInput[]; mediaAssets: MediaAssetFpsInput[];
}): number | null { }): number | null {
let highestFps: number | null = null; let highestFps: number | null = null;
for (const asset of mediaAssets) { for (const asset of mediaAssets) {
const fps = asset.fps ?? Number.NaN; const fps = asset.fps ?? Number.NaN;
if (asset.type !== "video") continue; if (asset.type !== "video") continue;
if (!Number.isFinite(fps) || fps <= 0) continue; if (!Number.isFinite(fps) || fps <= 0) continue;
highestFps = highestFps === null ? fps : Math.max(highestFps, fps); highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
} }
return highestFps; return highestFps;
} }
export function getRaisedProjectFpsForImportedMedia({ export function getRaisedProjectFpsForImportedMedia({
currentFps, currentFps,
importedAssets, importedAssets,
}: { }: {
currentFps: number; currentFps: number;
importedAssets: MediaAssetFpsInput[]; importedAssets: MediaAssetFpsInput[];
}): number | null { }): number | null {
const highestImportedVideoFps = getHighestImportedVideoFps({ const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets, mediaAssets: importedAssets,
}); });
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) { if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
return null; return null;
} }
return highestImportedVideoFps; return highestImportedVideoFps;
} }

View File

@ -4,7 +4,7 @@ import type {
RetimeConfig, RetimeConfig,
TimelineTrack, TimelineTrack,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { shouldMaintainPitch } from "@/constants/retime-constants"; import { shouldMaintainPitch } from "@/lib/retime/rate";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering"; import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
import type { AudioCapableElement } from "@/lib/timeline/audio-state"; import type { AudioCapableElement } from "@/lib/timeline/audio-state";
@ -16,7 +16,7 @@ import {
doesElementHaveEnabledAudio, doesElementHaveEnabledAudio,
} from "@/lib/timeline/audio-separation"; } from "@/lib/timeline/audio-separation";
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils"; 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 { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime"; import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
@ -97,7 +97,7 @@ export async function collectAudioElements({
const pendingElements: Array<Promise<CollectedAudioElement | null>> = []; const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
for (const track of tracks) { for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue; if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) { for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue; if (!canElementHaveAudio(element)) continue;
@ -108,7 +108,7 @@ export async function collectAudioElements({
: null; : null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isTrackMuted = canTracktHaveAudio(track) && track.muted; const isTrackMuted = canTrackHaveAudio(track) && track.muted;
if (element.type === "audio") { if (element.type === "audio") {
pendingElements.push( pendingElements.push(
@ -453,7 +453,7 @@ export async function collectAudioMixSources({
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = []; const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
for (const track of tracks) { for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue; if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) { for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue; if (!canElementHaveAudio(element)) continue;
@ -515,7 +515,7 @@ export async function collectAudioClips({
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = []; const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
for (const track of tracks) { for (const track of tracks) {
const isTrackMuted = canTracktHaveAudio(track) && track.muted; const isTrackMuted = canTrackHaveAudio(track) && track.muted;
for (const element of track.elements) { for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue; if (!canElementHaveAudio(element)) continue;

View File

@ -0,0 +1,9 @@
export const PANEL_CONFIG = {
panels: {
tools: 25,
preview: 50,
properties: 25,
mainContent: 50,
timeline: 50,
},
} as const;

View File

@ -0,0 +1,7 @@
export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
export const PREVIEW_ZOOM = {
min: 0.25,
max: 16,
step: 1.25,
} as const;

View File

@ -1,5 +1,5 @@
import { PitchShifter } from "soundtouchjs"; 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 type { RetimeConfig } from "@/lib/timeline";
import { getSourceTimeAtClipTime } from "./resolve"; import { getSourceTimeAtClipTime } from "./resolve";

View File

@ -1,3 +1,4 @@
export * from "./rate";
export * from "./audio-stretch"; export * from "./audio-stretch";
export * from "./presets"; export * from "./presets";
export * from "./resolve"; export * from "./resolve";

View File

@ -1,5 +1,5 @@
import type { RetimeConfig } from "@/lib/timeline"; import type { RetimeConfig } from "@/lib/timeline";
import { clampRetimeRate } from "@/constants/retime-constants"; import { clampRetimeRate } from "@/lib/retime/rate";
export function buildConstantRetime({ export function buildConstantRetime({
rate, rate,

View File

@ -1,30 +1,25 @@
export const DEFAULT_RETIME_RATE = 1; export const DEFAULT_RETIME_RATE = 1;
export const MIN_RETIME_RATE = 0.01; export const MIN_RETIME_RATE = 0.01;
export const MAX_RETIME_RATE = 5; export const MAX_RETIME_RATE = 5;
export const MAX_PITCH_PRESERVE_RATE = 10;
export function clampRetimeRate({ rate }: { rate: number }): number {
export function clampRetimeRate({ rate }: { rate: number }): number { if (!Number.isFinite(rate) || rate <= 0) {
if (!Number.isFinite(rate) || rate <= 0) { return DEFAULT_RETIME_RATE;
return DEFAULT_RETIME_RATE; }
}
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE); }
}
export function canMaintainPitch({ rate }: { rate: number }): boolean {
export function canMaintainPitch({ rate }: { rate: number }): boolean { return Number.isFinite(rate) && rate > 0;
if (!Number.isFinite(rate) || rate <= 0) { }
return false;
} export function shouldMaintainPitch({
rate,
return clampRetimeRate({ rate }) <= MAX_PITCH_PRESERVE_RATE; maintainPitch,
} }: {
rate: number;
export function shouldMaintainPitch({ maintainPitch?: boolean;
rate, }): boolean {
maintainPitch, return maintainPitch === true && canMaintainPitch({ rate });
}: { }
rate: number;
maintainPitch?: boolean;
}): boolean {
return maintainPitch === true && canMaintainPitch({ rate });
}

View File

@ -1,5 +1,5 @@
import type { RetimeConfig } from "@/lib/timeline"; import type { RetimeConfig } from "@/lib/timeline";
import { clampRetimeRate } from "@/constants/retime-constants"; import { clampRetimeRate } from "@/lib/retime/rate";
function getSafeRate({ rate }: { rate: number }): number { function getSafeRate({ rate }: { rate: number }): number {
return clampRetimeRate({ rate }); return clampRetimeRate({ rate });

View File

@ -0,0 +1,12 @@
export const MIN_FONT_SIZE = 5;
export const MAX_FONT_SIZE = 300;
export const DEFAULT_TEXT_COLOR = "#000000";
/**
* higher value: smaller font size
* lower value: larger font size
*/
export const FONT_SIZE_SCALE_REFERENCE = 90;
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;

View File

@ -0,0 +1 @@
export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;

View File

@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import type { TTimelineViewState } from "@/lib/project/types"; import type { TTimelineViewState } from "@/lib/project/types";
import type { BlendMode, Transform } from "@/lib/rendering"; import type { BlendMode, Transform } from "@/lib/rendering";
import type { TextElement } from "./types"; import type { TextElement } from "./types";
@ -41,7 +41,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
textDecoration: "none", textDecoration: "none",
letterSpacing: defaultTextLetterSpacing, letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight, lineHeight: defaultTextLineHeight,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime: 0, startTime: 0,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,

View File

@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export function getMouseTimeFromClientX({ export function getMouseTimeFromClientX({
clientX, clientX,
@ -14,6 +14,6 @@ export function getMouseTimeFromClientX({
const mouseX = clientX - containerRect.left + scrollLeft; const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max( return Math.max(
0, 0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
); );
} }

View File

@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { import {
MASKABLE_ELEMENT_TYPES, MASKABLE_ELEMENT_TYPES,
RETIMABLE_ELEMENT_TYPES, RETIMABLE_ELEMENT_TYPES,
@ -115,7 +115,7 @@ export function buildTextElement({
type: "text", type: "text",
name: t.name ?? DEFAULTS.text.element.name, name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content, content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime, startTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@ -150,7 +150,7 @@ export function buildEffectElement({
name: capitalizeFirstLetter({ string: instance.type }), name: capitalizeFirstLetter({ string: instance.type }),
effectType, effectType,
params: instance.params, params: instance.params,
duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime, startTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@ -178,7 +178,7 @@ export function buildStickerElement({
stickerId, stickerId,
intrinsicWidth, intrinsicWidth,
intrinsicHeight, intrinsicHeight,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime, startTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@ -208,7 +208,7 @@ export function buildGraphicElement({
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId, definitionId: instance.definitionId,
params: { ...instance.params, ...(params ?? {}) } as ParamValues, params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime, startTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,

View File

@ -2,7 +2,7 @@ import type { TimelineTrack } from "./types";
export * from "./types"; export * from "./types";
export * from "./drag"; export * from "./drag";
export * from "./track-utils"; export * from "./track-capabilities";
export * from "./track-element-update"; export * from "./track-element-update";
export * from "./element-utils"; export * from "./element-utils";
export * from "./audio-separation"; export * from "./audio-separation";

View File

@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
@ -31,7 +31,7 @@ export function getTimelinePixelsPerSecond({
}: { }: {
zoomLevel: number; zoomLevel: number;
}): number { }): number {
return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
} }
export function timelineTimeToPixels({ export function timelineTimeToPixels({

View File

@ -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"; import type { TrackType, TimelineTrack } from "@/lib/timeline";
export function buildEmptyTrack({ export function buildEmptyTrack({
@ -10,7 +10,7 @@ export function buildEmptyTrack({
type: TrackType; type: TrackType;
name?: string; name?: string;
}): TimelineTrack { }): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName; const trackName = name ?? DEFAULT_TRACK_NAMES[type];
switch (type) { switch (type) {
case "video": case "video":

View File

@ -1,4 +1,4 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
/** /**
* frame intervals for labels - starts at 2 so there's always at least * frame intervals for labels - starts at 2 so there's always at least
@ -56,7 +56,7 @@ export function getRulerConfig({
zoomLevel: number; zoomLevel: number;
fps: number; fps: number;
}): RulerConfig { }): RulerConfig {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const pixelsPerFrame = pixelsPerSecond / fps; const pixelsPerFrame = pixelsPerSecond / fps;
const labelIntervalSeconds = findOptimalInterval({ const labelIntervalSeconds = findOptimalInterval({

View File

@ -0,0 +1,3 @@
export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
export const TIMELINE_ZOOM_MIN = 0.1;
export const TIMELINE_ZOOM_MAX = 100;

View File

@ -1,5 +1,5 @@
import type { Bookmark, TimelineTrack } from "@/lib/timeline"; 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 { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { getElementKeyframes } from "@/lib/animation"; import { getElementKeyframes } from "@/lib/animation";
@ -107,7 +107,7 @@ export function snapToNearestPoint({
zoomLevel: number; zoomLevel: number;
snapThreshold?: number; snapThreshold?: number;
}): SnapResult { }): SnapResult {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond; const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null; let closestSnapPoint: SnapPoint | null = null;

View File

@ -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";
}

View File

@ -1,62 +0,0 @@
import type {
TrackType,
TimelineTrack,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
} from "@/lib/timeline";
import {
TRACK_CONFIG,
ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
export function canTracktHaveAudio(
track: TimelineTrack,
): track is VideoTrack | AudioTrack {
return track.type === "audio" || track.type === "video";
}
export function canTrackBeHidden(
track: TimelineTrack,
): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
return track.type !== "audio";
}
export function getElementClasses({ type }: { type: TrackType }) {
return ELEMENT_TYPE_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
tracks,
trackIndex,
}: {
tracks: Array<{ type: TrackType }>;
trackIndex: number;
}): number {
return tracks
.slice(0, trackIndex)
.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
0,
);
}
export function getTotalTracksHeight({
tracks,
}: {
tracks: Array<{ type: TrackType }>;
}): number {
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight({ type: track.type }),
0,
);
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight;
}

View File

@ -0,0 +1,9 @@
import type { TrackType } from "@/lib/timeline";
export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
video: "Video track",
text: "Text track",
audio: "Audio track",
graphic: "Graphic track",
effect: "Effect track",
} as const;

View File

@ -1,4 +1,7 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import {
BASE_TIMELINE_PIXELS_PER_SECOND,
TIMELINE_ZOOM_MAX,
} from "@/lib/timeline/scale";
const PADDING_MAX_RATIO = 0.75; const PADDING_MAX_RATIO = 0.75;
const PADDING_MIN_RATIO = 0.15; const PADDING_MIN_RATIO = 0.15;
@ -16,9 +19,9 @@ export function getTimelineZoomMin({
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
const availableWidth = safeContainerWidth * contentRatioAtMinZoom; const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
const zoomToFit = 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({ export function getTimelinePaddingPx({
@ -49,7 +52,7 @@ export function getZoomPercent({
zoomLevel: number; zoomLevel: number;
minZoom: number; minZoom: number;
}): 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({ export function sliderToZoom({
sliderPosition, sliderPosition,
minZoom, minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX, maxZoom = TIMELINE_ZOOM_MAX,
}: { }: {
sliderPosition: number; sliderPosition: number;
minZoom: number; minZoom: number;
@ -75,7 +78,7 @@ export function sliderToZoom({
export function zoomToSlider({ export function zoomToSlider({
zoomLevel, zoomLevel,
minZoom, minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX, maxZoom = TIMELINE_ZOOM_MAX,
}: { }: {
zoomLevel: number; zoomLevel: number;
minZoom: number; minZoom: number;

View File

@ -11,7 +11,7 @@ import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { EffectLayerNode } from "./nodes/effect-layer-node"; import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node"; import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types"; 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"; import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048; const PREVIEW_MAX_IMAGE_SIZE = 2048;
@ -252,7 +252,8 @@ export function buildScene({
const blurNodes = buildBlurBackgroundNodes({ const blurNodes = buildBlurBackgroundNodes({
track: mainTrack, track: mainTrack,
mediaMap, mediaMap,
blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, blurIntensity:
background.blurIntensity ?? DEFAULT_BACKGROUND_BLUR_INTENSITY,
}); });
for (const node of blurNodes) { for (const node of blurNodes) {
rootNode.add(node); rootNode.add(node);

View File

@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
DEFAULT_BLUR_INTENSITY, DEFAULT_BACKGROUND_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE, DEFAULT_BACKGROUND_COLOR,
DEFAULT_COLOR, } from "@/lib/background/constants";
DEFAULT_FPS, import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
} from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/lib/fps/constants";
import type { MediaAssetData } from "@/services/storage/types"; import type { MediaAssetData } from "@/services/storage/types";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2"; import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
import { import {
@ -119,7 +119,7 @@ describe("V1 to V2 Migration", () => {
const background = settings.background as Record<string, unknown>; const background = settings.background as Record<string, unknown>;
expect(background.type).toBe("color"); 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 () => { 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 settings = result.project.settings as Record<string, unknown>;
const background = settings.background 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 () => { test("preserves currentSceneId", async () => {

View File

@ -1,9 +1,9 @@
import { import {
DEFAULT_BLUR_INTENSITY, DEFAULT_BACKGROUND_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE, DEFAULT_BACKGROUND_COLOR,
DEFAULT_COLOR, } from "@/lib/background/constants";
DEFAULT_FPS, import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
} from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/lib/fps/constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types"; import type { MediaAssetData } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types"; import type { MigrationResult, ProjectRecord } from "./types";
@ -739,14 +739,17 @@ function getBackgroundValue({
type: "blur", type: "blur",
blurIntensity: getNumberValue({ blurIntensity: getNumberValue({
value: value.blurIntensity, value: value.blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY, fallback: DEFAULT_BACKGROUND_BLUR_INTENSITY,
}), }),
}; };
} }
return { return {
type: "color", 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", type: "blur",
blurIntensity: getNumberValue({ blurIntensity: getNumberValue({
value: blurIntensity, value: blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY, fallback: DEFAULT_BACKGROUND_BLUR_INTENSITY,
}), }),
}; };
} }
return { return {
type: "color", type: "color",
color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }), color: getStringValue({
value: backgroundColor,
fallback: DEFAULT_BACKGROUND_COLOR,
}),
}; };
} }

View File

@ -1,5 +1,5 @@
import { create } from "zustand"; 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"; import type { TCanvasSize } from "@/lib/project/types";
interface EditorState { interface EditorState {

View File

@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { PANEL_CONFIG } from "@/constants/editor-constants"; import { PANEL_CONFIG } from "@/lib/panels/constants";
export interface PanelSizes { export interface PanelSizes {
tools: number; tools: number;