From bd9ec023eefc4b5d26fa8b66c4338e97104f0f96 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 1 Apr 2026 19:27:49 +0200 Subject: refactor: split constants by domain and isolate timeline panel from core --- apps/web/src/lib/background/constants.ts | 11 + apps/web/src/lib/canvas/constants.ts | 10 + apps/web/src/lib/commands/media/add-media-asset.ts | 2 +- .../commands/timeline/element/insert-element.ts | 4 +- .../element/retime/update-element-retime.ts | 2 +- .../commands/timeline/track/toggle-track-mute.ts | 4 +- apps/web/src/lib/fps/__tests__/fps.test.ts | 65 ++++++ apps/web/src/lib/fps/constants.ts | 9 + apps/web/src/lib/fps/utils.ts | 39 ++++ apps/web/src/lib/media/audio.ts | 12 +- apps/web/src/lib/panels/constants.ts | 9 + apps/web/src/lib/preview/constants.ts | 7 + apps/web/src/lib/project/__tests__/fps.test.ts | 65 ------ apps/web/src/lib/project/fps.ts | 39 ---- apps/web/src/lib/retime/audio-stretch.ts | 2 +- apps/web/src/lib/retime/index.ts | 1 + apps/web/src/lib/retime/presets.ts | 2 +- apps/web/src/lib/retime/rate.ts | 25 ++ apps/web/src/lib/retime/resolve.ts | 2 +- apps/web/src/lib/text/constants.ts | 12 + apps/web/src/lib/timeline/creation.ts | 1 + apps/web/src/lib/timeline/defaults.ts | 4 +- apps/web/src/lib/timeline/drag-utils.ts | 4 +- apps/web/src/lib/timeline/drop-utils.ts | 257 --------------------- apps/web/src/lib/timeline/element-utils.ts | 10 +- apps/web/src/lib/timeline/index.ts | 2 +- apps/web/src/lib/timeline/pixel-utils.ts | 4 +- .../src/lib/timeline/placement/track-factory.ts | 4 +- apps/web/src/lib/timeline/ruler-utils.ts | 4 +- apps/web/src/lib/timeline/scale.ts | 3 + apps/web/src/lib/timeline/selection-hit-testing.ts | 133 ----------- apps/web/src/lib/timeline/snap-utils.ts | 4 +- apps/web/src/lib/timeline/track-capabilities.ts | 20 ++ apps/web/src/lib/timeline/track-utils.ts | 62 ----- apps/web/src/lib/timeline/tracks.ts | 9 + apps/web/src/lib/timeline/zoom-utils.ts | 15 +- 36 files changed, 263 insertions(+), 595 deletions(-) create mode 100644 apps/web/src/lib/background/constants.ts create mode 100644 apps/web/src/lib/canvas/constants.ts create mode 100644 apps/web/src/lib/fps/__tests__/fps.test.ts create mode 100644 apps/web/src/lib/fps/constants.ts create mode 100644 apps/web/src/lib/fps/utils.ts create mode 100644 apps/web/src/lib/panels/constants.ts create mode 100644 apps/web/src/lib/preview/constants.ts delete mode 100644 apps/web/src/lib/project/__tests__/fps.test.ts delete mode 100644 apps/web/src/lib/project/fps.ts create mode 100644 apps/web/src/lib/retime/rate.ts create mode 100644 apps/web/src/lib/text/constants.ts create mode 100644 apps/web/src/lib/timeline/creation.ts delete mode 100644 apps/web/src/lib/timeline/drop-utils.ts create mode 100644 apps/web/src/lib/timeline/scale.ts delete mode 100644 apps/web/src/lib/timeline/selection-hit-testing.ts create mode 100644 apps/web/src/lib/timeline/track-capabilities.ts delete mode 100644 apps/web/src/lib/timeline/track-utils.ts create mode 100644 apps/web/src/lib/timeline/tracks.ts (limited to 'apps/web/src/lib') 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/fps/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts new file mode 100644 index 00000000..fd082bb2 --- /dev/null +++ b/apps/web/src/lib/fps/__tests__/fps.test.ts @@ -0,0 +1,65 @@ +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/fps/utils.ts b/apps/web/src/lib/fps/utils.ts new file mode 100644 index 00000000..8e3221a1 --- /dev/null +++ b/apps/web/src/lib/fps/utils.ts @@ -0,0 +1,39 @@ +import type { MediaAsset } from "@/lib/media/types"; + +type MediaAssetFpsInput = Pick; + +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> = []; 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> = []; 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> = []; 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/project/__tests__/fps.test.ts b/apps/web/src/lib/project/__tests__/fps.test.ts deleted file mode 100644 index b30408ef..00000000 --- a/apps/web/src/lib/project/__tests__/fps.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -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(); - }); -}); diff --git a/apps/web/src/lib/project/fps.ts b/apps/web/src/lib/project/fps.ts deleted file mode 100644 index f031d348..00000000 --- a/apps/web/src/lib/project/fps.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { MediaAsset } from "@/lib/media/types"; - -type MediaAssetFpsInput = Pick; - -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/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 = { 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 = { + 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; -- cgit v1.3.1