diff options
Diffstat (limited to 'apps/web/src/lib')
34 files changed, 263 insertions, 595 deletions
diff --git a/apps/web/src/lib/background/constants.ts b/apps/web/src/lib/background/constants.ts new file mode 100644 index 00000000..654154a5 --- /dev/null +++ b/apps/web/src/lib/background/constants.ts @@ -0,0 +1,11 @@ +export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{ + label: string; + value: number; +}> = [ + { label: "Light", value: 10 }, + { label: "Medium", value: 50 }, + { label: "Heavy", value: 100 }, +] as const; + +export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; +export const DEFAULT_BACKGROUND_COLOR = "#000000"; diff --git a/apps/web/src/lib/canvas/constants.ts b/apps/web/src/lib/canvas/constants.ts new file mode 100644 index 00000000..dce75bcb --- /dev/null +++ b/apps/web/src/lib/canvas/constants.ts @@ -0,0 +1,10 @@ +import type { TCanvasSize } from "@/lib/project/types"; + +export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [ + { width: 1920, height: 1080 }, + { width: 1080, height: 1920 }, + { width: 1080, height: 1080 }, + { width: 1440, height: 1080 }, +]; + +export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 }; diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index 86fa3e35..6f54ee72 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -5,7 +5,7 @@ import type { MediaAsset } from "@/lib/media/types"; import { generateUUID } from "@/utils/id"; import { storageService } from "@/services/storage/service"; import { hasMediaId } from "@/lib/timeline/element-utils"; -import { getHighestImportedVideoFps } from "@/lib/project/fps"; +import { getHighestImportedVideoFps } from "@/lib/fps/utils"; import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; export class AddMediaAssetCommand extends Command { diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index cdff3565..2119d219 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -9,7 +9,7 @@ import type { import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
@@ -136,7 +136,7 @@ export class InsertElementCommand extends Command { startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
- duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
} as TimelineElement;
}
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts index 4057ec41..f0574ce7 100644 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts @@ -1,5 +1,5 @@ import { EditorCore } from "@/core";
-import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampRetimeRate } from "@/lib/retime/rate";
import { clampAnimationsToDuration } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 120220fe..0094f1de 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
-import { canTracktHaveAudio } from "@/lib/timeline";
+import { canTrackHaveAudio } from "@/lib/timeline";
export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -22,7 +22,7 @@ export class ToggleTrackMuteCommand extends Command { }
const updatedTracks = this.savedState.map((track) =>
- track.id === this.trackId && canTracktHaveAudio(track)
+ track.id === this.trackId && canTrackHaveAudio(track)
? { ...track, muted: !track.muted }
: track,
);
diff --git a/apps/web/src/lib/project/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts index b30408ef..fd082bb2 100644 --- a/apps/web/src/lib/project/__tests__/fps.test.ts +++ b/apps/web/src/lib/fps/__tests__/fps.test.ts @@ -1,65 +1,65 @@ -import { describe, expect, test } from "bun:test";
-import {
- getHighestImportedVideoFps,
- getRaisedProjectFpsForImportedMedia,
-} from "@/lib/project/fps";
-
-describe("getHighestImportedVideoFps", () => {
- test("returns the highest valid video fps", () => {
- expect(
- getHighestImportedVideoFps({
- mediaAssets: [
- { type: "audio" },
- { type: "video", fps: 30 },
- { type: "image", fps: 120 },
- { type: "video", fps: 60 },
- ],
- }),
- ).toBe(60);
- });
-
- test("ignores missing and invalid fps values", () => {
- expect(
- getHighestImportedVideoFps({
- mediaAssets: [
- { type: "video" },
- { type: "video", fps: 0 },
- { type: "video", fps: -10 },
- { type: "audio", fps: 120 },
- ],
- }),
- ).toBeNull();
- });
-});
-
-describe("getRaisedProjectFpsForImportedMedia", () => {
- test("raises the project fps to match a higher-fps import", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: 30,
- importedAssets: [{ type: "video", fps: 60 }],
- }),
- ).toBe(60);
- });
-
- test("does not lower the project fps for lower-fps imports", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: 60,
- importedAssets: [{ type: "video", fps: 10 }],
- }),
- ).toBeNull();
- });
-
- test("ignores non-video imports", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: 30,
- importedAssets: [
- { type: "image", fps: 60 },
- { type: "audio", fps: 120 },
- ],
- }),
- ).toBeNull();
- });
-});
+import { describe, expect, test } from "bun:test"; +import { + getHighestImportedVideoFps, + getRaisedProjectFpsForImportedMedia, +} from "@/lib/fps/utils"; + +describe("getHighestImportedVideoFps", () => { + test("returns the highest valid video fps", () => { + expect( + getHighestImportedVideoFps({ + mediaAssets: [ + { type: "audio" }, + { type: "video", fps: 30 }, + { type: "image", fps: 120 }, + { type: "video", fps: 60 }, + ], + }), + ).toBe(60); + }); + + test("ignores missing and invalid fps values", () => { + expect( + getHighestImportedVideoFps({ + mediaAssets: [ + { type: "video" }, + { type: "video", fps: 0 }, + { type: "video", fps: -10 }, + { type: "audio", fps: 120 }, + ], + }), + ).toBeNull(); + }); +}); + +describe("getRaisedProjectFpsForImportedMedia", () => { + test("raises the project fps to match a higher-fps import", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: 30, + importedAssets: [{ type: "video", fps: 60 }], + }), + ).toBe(60); + }); + + test("does not lower the project fps for lower-fps imports", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: 60, + importedAssets: [{ type: "video", fps: 10 }], + }), + ).toBeNull(); + }); + + test("ignores non-video imports", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: 30, + importedAssets: [ + { type: "image", fps: 60 }, + { type: "audio", fps: 120 }, + ], + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/fps/constants.ts b/apps/web/src/lib/fps/constants.ts new file mode 100644 index 00000000..100c043a --- /dev/null +++ b/apps/web/src/lib/fps/constants.ts @@ -0,0 +1,9 @@ +export const FPS_PRESETS = [ + { value: "24", label: "24 fps" }, + { value: "25", label: "25 fps" }, + { value: "30", label: "30 fps" }, + { value: "60", label: "60 fps" }, + { value: "120", label: "120 fps" }, +] as const; + +export const DEFAULT_FPS = 30; diff --git a/apps/web/src/lib/project/fps.ts b/apps/web/src/lib/fps/utils.ts index f031d348..8e3221a1 100644 --- a/apps/web/src/lib/project/fps.ts +++ b/apps/web/src/lib/fps/utils.ts @@ -1,39 +1,39 @@ -import type { MediaAsset } from "@/lib/media/types";
-
-type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
-
-export function getHighestImportedVideoFps({
- mediaAssets,
-}: {
- mediaAssets: MediaAssetFpsInput[];
-}): number | null {
- let highestFps: number | null = null;
-
- for (const asset of mediaAssets) {
- const fps = asset.fps ?? Number.NaN;
- if (asset.type !== "video") continue;
- if (!Number.isFinite(fps) || fps <= 0) continue;
-
- highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
- }
-
- return highestFps;
-}
-
-export function getRaisedProjectFpsForImportedMedia({
- currentFps,
- importedAssets,
-}: {
- currentFps: number;
- importedAssets: MediaAssetFpsInput[];
-}): number | null {
- const highestImportedVideoFps = getHighestImportedVideoFps({
- mediaAssets: importedAssets,
- });
-
- if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
- return null;
- }
-
- return highestImportedVideoFps;
-}
+import type { MediaAsset } from "@/lib/media/types"; + +type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">; + +export function getHighestImportedVideoFps({ + mediaAssets, +}: { + mediaAssets: MediaAssetFpsInput[]; +}): number | null { + let highestFps: number | null = null; + + for (const asset of mediaAssets) { + const fps = asset.fps ?? Number.NaN; + if (asset.type !== "video") continue; + if (!Number.isFinite(fps) || fps <= 0) continue; + + highestFps = highestFps === null ? fps : Math.max(highestFps, fps); + } + + return highestFps; +} + +export function getRaisedProjectFpsForImportedMedia({ + currentFps, + importedAssets, +}: { + currentFps: number; + importedAssets: MediaAssetFpsInput[]; +}): number | null { + const highestImportedVideoFps = getHighestImportedVideoFps({ + mediaAssets: importedAssets, + }); + + if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) { + return null; + } + + return highestImportedVideoFps; +} diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index 0afc42fb..d1025aa9 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -4,7 +4,7 @@ import type { RetimeConfig, TimelineTrack, } from "@/lib/timeline"; -import { shouldMaintainPitch } from "@/constants/retime-constants"; +import { shouldMaintainPitch } from "@/lib/retime/rate"; import type { MediaAsset } from "@/lib/media/types"; import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering"; import type { AudioCapableElement } from "@/lib/timeline/audio-state"; @@ -16,7 +16,7 @@ import { doesElementHaveEnabledAudio, } from "@/lib/timeline/audio-separation"; import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils"; -import { canTracktHaveAudio } from "@/lib/timeline"; +import { canTrackHaveAudio } from "@/lib/timeline"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime"; import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; @@ -97,7 +97,7 @@ export async function collectAudioElements({ const pendingElements: Array<Promise<CollectedAudioElement | null>> = []; for (const track of tracks) { - if (canTracktHaveAudio(track) && track.muted) continue; + if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; @@ -108,7 +108,7 @@ export async function collectAudioElements({ : null; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; - const isTrackMuted = canTracktHaveAudio(track) && track.muted; + const isTrackMuted = canTrackHaveAudio(track) && track.muted; if (element.type === "audio") { pendingElements.push( @@ -453,7 +453,7 @@ export async function collectAudioMixSources({ const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = []; for (const track of tracks) { - if (canTracktHaveAudio(track) && track.muted) continue; + if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; @@ -515,7 +515,7 @@ export async function collectAudioClips({ const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = []; for (const track of tracks) { - const isTrackMuted = canTracktHaveAudio(track) && track.muted; + const isTrackMuted = canTrackHaveAudio(track) && track.muted; for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; diff --git a/apps/web/src/lib/panels/constants.ts b/apps/web/src/lib/panels/constants.ts new file mode 100644 index 00000000..5a570469 --- /dev/null +++ b/apps/web/src/lib/panels/constants.ts @@ -0,0 +1,9 @@ +export const PANEL_CONFIG = { + panels: { + tools: 25, + preview: 50, + properties: 25, + mainContent: 50, + timeline: 50, + }, +} as const; diff --git a/apps/web/src/lib/preview/constants.ts b/apps/web/src/lib/preview/constants.ts new file mode 100644 index 00000000..9e6f5905 --- /dev/null +++ b/apps/web/src/lib/preview/constants.ts @@ -0,0 +1,7 @@ +export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200]; + +export const PREVIEW_ZOOM = { + min: 0.25, + max: 16, + step: 1.25, +} as const; diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts index f8dd297c..92902dff 100644 --- a/apps/web/src/lib/retime/audio-stretch.ts +++ b/apps/web/src/lib/retime/audio-stretch.ts @@ -1,5 +1,5 @@ import { PitchShifter } from "soundtouchjs";
-import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants";
+import { clampRetimeRate, shouldMaintainPitch } from "@/lib/retime/rate";
import type { RetimeConfig } from "@/lib/timeline";
import { getSourceTimeAtClipTime } from "./resolve";
diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts index ca6725dd..2ab1d10f 100644 --- a/apps/web/src/lib/retime/index.ts +++ b/apps/web/src/lib/retime/index.ts @@ -1,3 +1,4 @@ +export * from "./rate";
export * from "./audio-stretch";
export * from "./presets";
export * from "./resolve";
diff --git a/apps/web/src/lib/retime/presets.ts b/apps/web/src/lib/retime/presets.ts index 777c0219..fbd391ff 100644 --- a/apps/web/src/lib/retime/presets.ts +++ b/apps/web/src/lib/retime/presets.ts @@ -1,5 +1,5 @@ import type { RetimeConfig } from "@/lib/timeline";
-import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampRetimeRate } from "@/lib/retime/rate";
export function buildConstantRetime({
rate,
diff --git a/apps/web/src/lib/retime/rate.ts b/apps/web/src/lib/retime/rate.ts new file mode 100644 index 00000000..75ea530b --- /dev/null +++ b/apps/web/src/lib/retime/rate.ts @@ -0,0 +1,25 @@ +export const DEFAULT_RETIME_RATE = 1; +export const MIN_RETIME_RATE = 0.01; +export const MAX_RETIME_RATE = 5; + +export function clampRetimeRate({ rate }: { rate: number }): number { + if (!Number.isFinite(rate) || rate <= 0) { + return DEFAULT_RETIME_RATE; + } + + return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE); +} + +export function canMaintainPitch({ rate }: { rate: number }): boolean { + return Number.isFinite(rate) && rate > 0; +} + +export function shouldMaintainPitch({ + rate, + maintainPitch, +}: { + rate: number; + maintainPitch?: boolean; +}): boolean { + return maintainPitch === true && canMaintainPitch({ rate }); +} diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts index 31a562be..5e7236d5 100644 --- a/apps/web/src/lib/retime/resolve.ts +++ b/apps/web/src/lib/retime/resolve.ts @@ -1,5 +1,5 @@ import type { RetimeConfig } from "@/lib/timeline";
-import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampRetimeRate } from "@/lib/retime/rate";
function getSafeRate({ rate }: { rate: number }): number {
return clampRetimeRate({ rate });
diff --git a/apps/web/src/lib/text/constants.ts b/apps/web/src/lib/text/constants.ts new file mode 100644 index 00000000..a101f209 --- /dev/null +++ b/apps/web/src/lib/text/constants.ts @@ -0,0 +1,12 @@ +export const MIN_FONT_SIZE = 5; +export const MAX_FONT_SIZE = 300; +export const DEFAULT_TEXT_COLOR = "#000000"; + +/** + * higher value: smaller font size + * lower value: larger font size + */ +export const FONT_SIZE_SCALE_REFERENCE = 90; + +export const CORNER_RADIUS_MIN = 0; +export const CORNER_RADIUS_MAX = 100; diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts new file mode 100644 index 00000000..335ab912 --- /dev/null +++ b/apps/web/src/lib/timeline/creation.ts @@ -0,0 +1 @@ +export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5; diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts index 01dbe912..bc968a3c 100644 --- a/apps/web/src/lib/timeline/defaults.ts +++ b/apps/web/src/lib/timeline/defaults.ts @@ -1,4 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import type { TTimelineViewState } from "@/lib/project/types";
import type { BlendMode, Transform } from "@/lib/rendering";
import type { TextElement } from "./types";
@@ -41,7 +41,7 @@ const defaultTextElement: Omit<TextElement, "id"> = { textDecoration: "none",
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime: 0,
trimStart: 0,
trimEnd: 0,
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts index cf59a20d..8efeab79 100644 --- a/apps/web/src/lib/timeline/drag-utils.ts +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -1,4 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export function getMouseTimeFromClientX({
clientX,
@@ -14,6 +14,6 @@ export function getMouseTimeFromClientX({ const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max(
0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
}
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts deleted file mode 100644 index 161d2714..00000000 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type {
- TimelineTrack,
- TimelineElement,
-} from "@/lib/timeline";
-import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
-import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-
-function findElementAtPosition({
- mouseX,
- tracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
-}: {
- mouseX: number;
- tracks: TimelineTrack[];
- trackIndex: number;
- targetElementTypes: string[];
- pixelsPerSecond: number;
- zoomLevel: number;
-}): { elementId: string; trackId: string } | null {
- const time = mouseX / (pixelsPerSecond * zoomLevel);
- const track = tracks[trackIndex];
- if (!track || !("elements" in track)) return null;
-
- const hit = track.elements.find(
- (element: TimelineElement) =>
- targetElementTypes.includes(element.type) &&
- element.startTime <= time &&
- time < element.startTime + element.duration,
- );
- if (!hit) return null;
- return { elementId: hit.id, trackId: track.id };
-}
-
-function getTrackAtY({
- mouseY,
- tracks,
- verticalDragDirection,
-}: {
- mouseY: number;
- tracks: TimelineTrack[];
- verticalDragDirection?: "up" | "down" | null;
-}): { trackIndex: number; relativeY: number } | null {
- let cumulativeHeight = 0;
-
- for (let i = 0; i < tracks.length; i++) {
- const trackHeight = TRACK_CONFIG[tracks[i].type].height;
- const trackTop = cumulativeHeight;
- const trackBottom = trackTop + trackHeight;
-
- if (mouseY >= trackTop && mouseY < trackBottom) {
- return {
- trackIndex: i,
- relativeY: mouseY - trackTop,
- };
- }
-
- if (i < tracks.length - 1 && verticalDragDirection) {
- const gapTop = trackBottom;
- const gapBottom = gapTop + TRACK_GAP;
- if (mouseY >= gapTop && mouseY < gapBottom) {
- const isDraggingUp = verticalDragDirection === "up";
- return {
- trackIndex: isDraggingUp ? i : i + 1,
- relativeY: isDraggingUp ? trackHeight - 1 : 0,
- };
- }
- }
-
- cumulativeHeight += trackHeight + TRACK_GAP;
- }
-
- return null;
-}
-
-const EMPTY_TARGET_ELEMENT = null;
-
-function fallbackNewTrackDropTarget({
- xPosition,
-}: {
- xPosition: number;
-}): DropTarget {
- return {
- trackIndex: 0,
- isNewTrack: true,
- insertPosition: null,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime,
- isExternalDrop,
- elementDuration,
- pixelsPerSecond,
- zoomLevel,
- verticalDragDirection,
- startTimeOverride,
- excludeElementId,
- targetElementTypes,
-}: ComputeDropTargetParams): DropTarget {
- const xPosition =
- typeof startTimeOverride === "number"
- ? startTimeOverride
- : isExternalDrop
- ? playheadTime
- : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
-
- if (tracks.length === 0) {
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- createNewTrackOnly: true,
- },
- });
- const emptyTimelineResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!emptyTimelineResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: emptyTimelineResult.insertIndex,
- isNewTrack: true,
- insertPosition: emptyTimelineResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
-
- if (!trackAtMouse) {
- const isAboveAllTracks = mouseY < 0;
-
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
- hoverDirection: isAboveAllTracks ? "above" : "below",
- createNewTrackOnly: true,
- },
- });
- const outOfBoundsResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!outOfBoundsResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: outOfBoundsResult.insertIndex,
- isNewTrack: true,
- insertPosition: outOfBoundsResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const { trackIndex, relativeY } = trackAtMouse;
- const track = tracks[trackIndex];
-
- if (
- targetElementTypes &&
- targetElementTypes.length > 0
- ) {
- const targetElement = findElementAtPosition({
- mouseX,
- tracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
- });
- if (targetElement) {
- return {
- trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition,
- targetElement,
- };
- }
- }
-
- const trackHeight = TRACK_CONFIG[track.type].height;
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex,
- hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
- verticalDragDirection,
- },
- });
- if (!placementResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- if (placementResult.kind === "existingTrack") {
- const adjustedXPosition =
- placementResult.adjustedStartTime ?? xPosition;
-
- return {
- trackIndex: placementResult.trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition: adjustedXPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- return {
- trackIndex: placementResult.insertIndex,
- isNewTrack: true,
- insertPosition: placementResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function getDropLineY({
- dropTarget,
- tracks,
-}: {
- dropTarget: DropTarget;
- tracks: TimelineTrack[];
-}): number {
- const safeTrackIndex = Math.min(
- Math.max(dropTarget.trackIndex, 0),
- tracks.length,
- );
- let y = 0;
-
- for (let i = 0; i < safeTrackIndex; i++) {
- y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
- }
-
- return y;
-}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 5820b96d..96588903 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,4 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import {
MASKABLE_ELEMENT_TYPES,
RETIMABLE_ELEMENT_TYPES,
@@ -115,7 +115,7 @@ export function buildTextElement({ type: "text",
name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content,
- duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -150,7 +150,7 @@ export function buildEffectElement({ name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
- duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -178,7 +178,7 @@ export function buildStickerElement({ stickerId,
intrinsicWidth,
intrinsicHeight,
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
@@ -208,7 +208,7 @@ export function buildGraphicElement({ name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 83a6a0f0..469b916e 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -2,7 +2,7 @@ import type { TimelineTrack } from "./types"; export * from "./types";
export * from "./drag";
-export * from "./track-utils";
+export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts index c4635e52..e93b5ab5 100644 --- a/apps/web/src/lib/timeline/pixel-utils.ts +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -1,4 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
@@ -31,7 +31,7 @@ export function getTimelinePixelsPerSecond({ }: {
zoomLevel: number;
}): number {
- return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
}
export function timelineTimeToPixels({
diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts index e77a8a96..ee056261 100644 --- a/apps/web/src/lib/timeline/placement/track-factory.ts +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -1,4 +1,4 @@ -import { TRACK_CONFIG } from "@/constants/timeline-constants"; +import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; import type { TrackType, TimelineTrack } from "@/lib/timeline"; export function buildEmptyTrack({ @@ -10,7 +10,7 @@ export function buildEmptyTrack({ type: TrackType; name?: string; }): TimelineTrack { - const trackName = name ?? TRACK_CONFIG[type].defaultName; + const trackName = name ?? DEFAULT_TRACK_NAMES[type]; switch (type) { case "video": diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts index 94ef416a..732d9ea4 100644 --- a/apps/web/src/lib/timeline/ruler-utils.ts +++ b/apps/web/src/lib/timeline/ruler-utils.ts @@ -1,4 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; /** * frame intervals for labels - starts at 2 so there's always at least @@ -56,7 +56,7 @@ export function getRulerConfig({ zoomLevel: number; fps: number; }): RulerConfig { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; const pixelsPerFrame = pixelsPerSecond / fps; const labelIntervalSeconds = findOptimalInterval({ diff --git a/apps/web/src/lib/timeline/scale.ts b/apps/web/src/lib/timeline/scale.ts new file mode 100644 index 00000000..7770f59f --- /dev/null +++ b/apps/web/src/lib/timeline/scale.ts @@ -0,0 +1,3 @@ +export const BASE_TIMELINE_PIXELS_PER_SECOND = 50; +export const TIMELINE_ZOOM_MIN = 0.1; +export const TIMELINE_ZOOM_MAX = 100; diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts deleted file mode 100644 index 3c0f597e..00000000 --- a/apps/web/src/lib/timeline/selection-hit-testing.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
-import type { TimelineTrack } from "@/lib/timeline";
-
-type TimelineElementRef = { trackId: string; elementId: string };
-
-interface SelectionRectangle {
- left: number;
- top: number;
- right: number;
- bottom: number;
-}
-
-function getNormalizedRectangle({
- startPos,
- endPos,
-}: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- return {
- left: Math.min(startPos.x, endPos.x),
- top: Math.min(startPos.y, endPos.y),
- right: Math.max(startPos.x, endPos.x),
- bottom: Math.max(startPos.y, endPos.y),
- };
-}
-
-function getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- const containerRect = container.getBoundingClientRect();
- const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
-
- const adjustedStart = {
- x: startPos.x - containerRect.left + scrollLeft,
- y: startPos.y - scrollRect.top + scrollTop,
- };
- const adjustedEnd = {
- x: endPos.x - containerRect.left + scrollLeft,
- y: endPos.y - scrollRect.top + scrollTop,
- };
-
- return getNormalizedRectangle({
- startPos: adjustedStart,
- endPos: adjustedEnd,
- });
-}
-
-function isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
-}: {
- elementRectangle: SelectionRectangle;
- selectionRectangle: SelectionRectangle;
-}): boolean {
- return !(
- elementRectangle.right < selectionRectangle.left ||
- elementRectangle.left > selectionRectangle.right ||
- elementRectangle.bottom < selectionRectangle.top ||
- elementRectangle.top > selectionRectangle.bottom
- );
-}
-
-export function resolveTimelineElementIntersections({
- container,
- scrollContainer,
- tracks,
- zoomLevel,
- startPos,
- currentPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- tracks: TimelineTrack[];
- zoomLevel: number;
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
-}): TimelineElementRef[] {
- const selectionRectangle = getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos: currentPos,
- });
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const selectedElements: TimelineElementRef[] = [];
-
- for (const [trackIndex, track] of tracks.entries()) {
- const trackTop = getCumulativeHeightBefore({
- tracks,
- trackIndex,
- });
- const trackHeight = getTrackHeight({ type: track.type });
- const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
- const elementBottom = elementTop + trackHeight;
-
- for (const element of track.elements) {
- const elementLeft = element.startTime * pixelsPerSecond;
- const elementRight = elementLeft + element.duration * pixelsPerSecond;
- const elementRectangle = {
- left: elementLeft,
- top: elementTop,
- right: elementRight,
- bottom: elementBottom,
- };
-
- if (
- isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
- })
- ) {
- selectedElements.push({
- trackId: track.id,
- elementId: element.id,
- });
- }
- }
- }
-
- return selectedElements;
-}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index 2efe24e4..ef5d6e11 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,5 +1,5 @@ import type { Bookmark, TimelineTrack } from "@/lib/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { getElementKeyframes } from "@/lib/animation";
@@ -107,7 +107,7 @@ export function snapToNearestPoint({ zoomLevel: number;
snapThreshold?: number;
}): SnapResult {
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
diff --git a/apps/web/src/lib/timeline/track-capabilities.ts b/apps/web/src/lib/timeline/track-capabilities.ts new file mode 100644 index 00000000..08ee841c --- /dev/null +++ b/apps/web/src/lib/timeline/track-capabilities.ts @@ -0,0 +1,20 @@ +import type { + TimelineTrack, + VideoTrack, + AudioTrack, + GraphicTrack, + TextTrack, + EffectTrack, +} from "@/lib/timeline"; + +export function canTrackHaveAudio( + track: TimelineTrack, +): track is VideoTrack | AudioTrack { + return track.type === "audio" || track.type === "video"; +} + +export function canTrackBeHidden( + track: TimelineTrack, +): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { + return track.type !== "audio"; +} diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts deleted file mode 100644 index cc9e67c4..00000000 --- a/apps/web/src/lib/timeline/track-utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type {
- TrackType,
- TimelineTrack,
- VideoTrack,
- AudioTrack,
- GraphicTrack,
- TextTrack,
- EffectTrack,
-} from "@/lib/timeline";
-import {
- TRACK_CONFIG,
- ELEMENT_TYPE_CONFIG,
- TRACK_GAP,
-} from "@/constants/timeline-constants";
-
-export function canTracktHaveAudio(
- track: TimelineTrack,
-): track is VideoTrack | AudioTrack {
- return track.type === "audio" || track.type === "video";
-}
-
-export function canTrackBeHidden(
- track: TimelineTrack,
-): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
- return track.type !== "audio";
-}
-
-export function getElementClasses({ type }: { type: TrackType }) {
- return ELEMENT_TYPE_CONFIG[type].background.trim();
-}
-
-export function getTrackHeight({ type }: { type: TrackType }): number {
- return TRACK_CONFIG[type].height;
-}
-
-export function getCumulativeHeightBefore({
- tracks,
- trackIndex,
-}: {
- tracks: Array<{ type: TrackType }>;
- trackIndex: number;
-}): number {
- return tracks
- .slice(0, trackIndex)
- .reduce(
- (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
- 0,
- );
-}
-
-export function getTotalTracksHeight({
- tracks,
-}: {
- tracks: Array<{ type: TrackType }>;
-}): number {
- const tracksHeight = tracks.reduce(
- (sum, track) => sum + getTrackHeight({ type: track.type }),
- 0,
- );
- const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
- return tracksHeight + gapsHeight;
-}
diff --git a/apps/web/src/lib/timeline/tracks.ts b/apps/web/src/lib/timeline/tracks.ts new file mode 100644 index 00000000..181b9fcf --- /dev/null +++ b/apps/web/src/lib/timeline/tracks.ts @@ -0,0 +1,9 @@ +import type { TrackType } from "@/lib/timeline"; + +export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = { + video: "Video track", + text: "Text track", + audio: "Audio track", + graphic: "Graphic track", + effect: "Effect track", +} as const; diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index bbc0b6ae..c06957d0 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -1,4 +1,7 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + BASE_TIMELINE_PIXELS_PER_SECOND, + TIMELINE_ZOOM_MAX, +} from "@/lib/timeline/scale"; const PADDING_MAX_RATIO = 0.75; const PADDING_MIN_RATIO = 0.15; @@ -16,9 +19,9 @@ export function getTimelineZoomMin({ const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; const availableWidth = safeContainerWidth * contentRatioAtMinZoom; const zoomToFit = - availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND); + availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND); - return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit); + return Math.min(TIMELINE_ZOOM_MAX, zoomToFit); } export function getTimelinePaddingPx({ @@ -49,7 +52,7 @@ export function getZoomPercent({ zoomLevel: number; minZoom: number; }): number { - return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom); + return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom); } /** @@ -59,7 +62,7 @@ export function getZoomPercent({ export function sliderToZoom({ sliderPosition, minZoom, - maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX, + maxZoom = TIMELINE_ZOOM_MAX, }: { sliderPosition: number; minZoom: number; @@ -75,7 +78,7 @@ export function sliderToZoom({ export function zoomToSlider({ zoomLevel, minZoom, - maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX, + maxZoom = TIMELINE_ZOOM_MAX, }: { zoomLevel: number; minZoom: number; |
