refactor: split constants by domain and isolate timeline panel from core
This commit is contained in:
parent
f4b9f40ab1
commit
bd9ec023ee
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const TIMELINE_LAYERS = {
|
||||||
|
trackContent: 10,
|
||||||
|
dragLine: 20,
|
||||||
|
playhead: 30,
|
||||||
|
snapIndicator: 40,
|
||||||
|
} as const;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { TCanvasSize } from "@/lib/project/types";
|
||||||
|
|
||||||
|
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 1080, height: 1920 },
|
||||||
|
{ width: 1080, height: 1080 },
|
||||||
|
{ width: 1440, height: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||||
|
|
@ -5,7 +5,7 @@ import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { 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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const FPS_PRESETS = [
|
||||||
|
{ value: "24", label: "24 fps" },
|
||||||
|
{ value: "25", label: "25 fps" },
|
||||||
|
{ value: "30", label: "30 fps" },
|
||||||
|
{ value: "60", label: "60 fps" },
|
||||||
|
{ value: "120", label: "120 fps" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_FPS = 30;
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const PANEL_CONFIG = {
|
||||||
|
panels: {
|
||||||
|
tools: 25,
|
||||||
|
preview: 50,
|
||||||
|
properties: 25,
|
||||||
|
mainContent: 50,
|
||||||
|
timeline: 50,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
|
||||||
|
|
||||||
|
export const PREVIEW_ZOOM = {
|
||||||
|
min: 0.25,
|
||||||
|
max: 16,
|
||||||
|
step: 1.25,
|
||||||
|
} as const;
|
||||||
|
|
@ -1,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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
|
||||||
|
export const TIMELINE_ZOOM_MIN = 0.1;
|
||||||
|
export const TIMELINE_ZOOM_MAX = 100;
|
||||||
|
|
@ -1,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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue