summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.cursor/rules/codebase-index.mdc217
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts81
2 files changed, 161 insertions, 137 deletions
diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc
index cd6ed84b..c4b8de48 100644
--- a/.cursor/rules/codebase-index.mdc
+++ b/.cursor/rules/codebase-index.mdc
@@ -16,16 +16,6 @@ When implementing new features:
3. Understand the API surface of your codebase
```typescript
-## apps/web/src/components/editor/timeline
-
-audio-waveform.tsx
- export function AudioWaveform({
- audioUrl,
- audioBuffer,
- height = 32,
- className = "",
- }: AudioWaveformProps)
-
## apps/web/src/constants
editor-constants.ts
@@ -464,46 +454,46 @@ rate-limit.ts
scenes.ts
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null
export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[]
- export function buildDefaultScene({
- name,
- isMain,
- }: {
- name: string;
- isMain: boolean;
+ export function buildDefaultScene({
+ name,
+ isMain,
+ }: {
+ name: string;
+ isMain: boolean;
}): TScene
- export function canDeleteScene({ scene }: { scene: TScene }): {
- canDelete: boolean;
- reason?: string;
+ export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
}
- export function getFallbackSceneAfterDelete({
- scenes,
- deletedSceneId,
- currentSceneId,
- }: {
- scenes: TScene[];
- deletedSceneId: string;
- currentSceneId: string | null;
+ export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+ }: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
}): TScene | null
- export function findCurrentScene({
- scenes,
- currentSceneId,
- }: {
- scenes: TScene[];
- currentSceneId: string;
+ export function findCurrentScene({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: TScene[];
+ currentSceneId: string;
}): TScene | null
- export function getProjectDurationFromScenes({
- scenes,
- }: {
- scenes: TScene[];
+ export function getProjectDurationFromScenes({
+ scenes,
+ }: {
+ scenes: TScene[];
}): number
- export function updateSceneInArray({
- scenes,
- sceneId,
- updates,
- }: {
- scenes: TScene[];
- sceneId: string;
- updates: Partial<TScene>;
+ export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+ }: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
}): TScene[]
time.ts
@@ -699,27 +689,28 @@ parser.ts
## apps/web/src/lib/media
audio.ts
- export type CollectedAudioElement = Omit<
- AudioElement,
- "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl...
+ export type CollectedAudioElement = Omit<
+ AudioElement,
+ "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
+ ...
export function createAudioContext(): AudioContext
export interface DecodedAudio {
samples: Float32Array
sampleRate: number
}
- export function decodeAudioToFloat32({
- audioBlob,
- }: {
- audioBlob: Blob;
+ export function decodeAudioToFloat32({
+ audioBlob,
+ }: {
+ audioBlob: Blob;
}): Promise<DecodedAudio>
- export function collectAudioElements({
- tracks,
- mediaAssets,
- audioContext,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- audioContext: AudioContext;
+ export function collectAudioElements({
+ tracks,
+ mediaAssets,
+ audioContext,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ audioContext: AudioContext;
}): Promise<CollectedAudioElement[]>
export interface AudioClipSource {
id: string
@@ -731,32 +722,32 @@ audio.ts
trimEnd: number
muted: boolean
}
- export function collectAudioMixSources({
- tracks,
- mediaAssets,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
+ export function collectAudioMixSources({
+ tracks,
+ mediaAssets,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
}): Promise<AudioMixSource[]>
- export function collectAudioClips({
- tracks,
- mediaAssets,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
+ export function collectAudioClips({
+ tracks,
+ mediaAssets,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
}): Promise<AudioClipSource[]>
- export function createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration,
- sampleRate = 44100,
- audioContext,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: number;
- sampleRate?: number;
- audioContext?: AudioContext;
+ export function createTimelineAudioBuffer({
+ tracks,
+ mediaAssets,
+ duration,
+ sampleRate = 44100,
+ audioContext,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ sampleRate?: number;
+ audioContext?: AudioContext;
}): Promise<AudioBuffer | null>
media-utils.ts
@@ -1091,28 +1082,46 @@ track-utils.ts
}): { isValid: boolean; errorMessage?: string }
zoom-utils.ts
- export function getTimelineZoomMin({
- duration,
- containerWidth,
- }: {
- duration: number;
- containerWidth: number | null | undefined;
+ export function getTimelineZoomMin({
+ duration,
+ containerWidth,
+ }: {
+ duration: number;
+ containerWidth: number | null | undefined;
}): number
- export function getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom,
- }: {
- containerWidth: number;
- zoomLevel: number;
- minZoom: number;
+ export function getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom,
+ }: {
+ containerWidth: number;
+ zoomLevel: number;
+ minZoom: number;
}): number
- export function getZoomPercent({
- zoomLevel,
- minZoom,
- }: {
- zoomLevel: number;
- minZoom: number;
+ export function getZoomPercent({
+ zoomLevel,
+ minZoom,
+ }: {
+ zoomLevel: number;
+ minZoom: number;
+ }): number
+ export function sliderToZoom({
+ sliderPosition,
+ minZoom,
+ maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
+ }: {
+ sliderPosition: number;
+ minZoom: number;
+ maxZoom?: number;
+ }): number
+ export function zoomToSlider({
+ zoomLevel,
+ minZoom,
+ maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
+ }: {
+ zoomLevel: number;
+ minZoom: number;
+ maxZoom?: number;
}): number
## apps/web/src/lib/transcription
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
index d0a67857..8f4fc86e 100644
--- a/apps/web/src/lib/timeline/ruler-utils.ts
+++ b/apps/web/src/lib/timeline/ruler-utils.ts
@@ -1,51 +1,51 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
/**
- * 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
* one tick between labels even at max zoom.
- * Pattern: 2, 3, 5, 10, 15 (matches CapCut)
+ * pattern: 2, 3, 5, 10, 15 (matches CapCut)
*/
const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
/**
- * Frame intervals for ticks - can go down to 1 for max granularity.
+ * frame intervals for ticks - can go down to 1 for max granularity.
*/
const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
/**
- * Second intervals for when we're zoomed out past frame-level detail.
+ * second intervals for when we're zoomed out past frame-level detail.
*/
const SECOND_MULTIPLIERS = [1, 2, 3, 5, 10, 15, 30, 60] as const;
/**
- * Minimum pixel spacing between labels to keep them readable
+ * minimum pixel spacing between labels to keep them readable
*/
const MIN_LABEL_SPACING_PX = 120;
/**
- * Minimum pixel spacing between ticks. Much denser than labels.
+ * minimum pixel spacing between ticks. much denser than labels.
*/
const MIN_TICK_SPACING_PX = 18;
export interface RulerConfig {
- /** Time interval in seconds between each label */
+ /** time interval in seconds between each label */
labelIntervalSeconds: number;
- /** Time interval in seconds between each tick */
+ /** time interval in seconds between each tick */
tickIntervalSeconds: number;
}
/**
- * Determines the optimal label and tick intervals based on zoom level and FPS.
+ * determines the optimal label and tick intervals based on zoom level and FPS.
*
- * Labels and ticks scale independently:
- * - Labels need wide spacing (~50px) to stay readable
- * - Ticks can be denser (~8px) to show finer subdivisions
+ * labels and ticks scale independently:
+ * - labels need wide spacing (~50px) to stay readable
+ * - ticks can be denser (~8px) to show finer subdivisions
*
- * Example at different zoom levels:
- * - Very zoomed in: labels every 2f, ticks every 1f
- * - Zoomed in: labels every 10f, ticks every 1f
- * - Zoomed out: labels every 15f, ticks every 3f
- * - Very zoomed out: labels every 1s, ticks every 5f
+ * example at different zoom levels:
+ * - very zoomed in: labels every 2f, ticks every 1f
+ * - zoomed in: labels every 10f, ticks every 1f
+ * - zoomed out: labels every 15f, ticks every 3f
+ * - very zoomed out: labels every 1s, ticks every 5f
*/
export function getRulerConfig({
zoomLevel,
@@ -73,11 +73,12 @@ export function getRulerConfig({
frameIntervals: TICK_FRAME_INTERVALS,
});
- // Ensure tick interval divides evenly into label interval so labels always land on ticks
+ // ensure tick interval divides evenly into label interval so labels always land on ticks
const tickIntervalSeconds = ensureTickDividesLabel({
tickIntervalSeconds: rawTickIntervalSeconds,
labelIntervalSeconds,
pixelsPerFrame,
+ pixelsPerSecond,
fps,
});
@@ -85,40 +86,54 @@ export function getRulerConfig({
}
/**
- * Adjusts tick interval to ensure it divides evenly into the label interval.
- * This guarantees labels always land on tick positions.
+ * adjusts tick interval to ensure it divides evenly into the label interval.
+ * this guarantees labels always land on tick positions.
*/
function ensureTickDividesLabel({
tickIntervalSeconds,
labelIntervalSeconds,
pixelsPerFrame,
+ pixelsPerSecond,
fps,
}: {
tickIntervalSeconds: number;
labelIntervalSeconds: number;
pixelsPerFrame: number;
+ pixelsPerSecond: number;
fps: number;
}): number {
const labelFrames = Math.round(labelIntervalSeconds * fps);
const tickFrames = Math.round(tickIntervalSeconds * fps);
- // If tick already divides label evenly, we're good
+ // if tick already divides label evenly, we're good
if (labelFrames % tickFrames === 0) {
return tickIntervalSeconds;
}
- // Find the smallest tick interval that divides the label interval and has adequate spacing
+ // find the smallest tick interval that divides the label interval and has adequate spacing
for (const candidateFrames of TICK_FRAME_INTERVALS) {
if (labelFrames % candidateFrames === 0) {
const candidateSpacing = pixelsPerFrame * candidateFrames;
- // Accept if spacing meets minimum threshold
+ // accept if spacing meets minimum threshold
if (candidateSpacing >= MIN_TICK_SPACING_PX) {
return candidateFrames / fps;
}
}
}
- // Fallback: use the label interval itself (no intermediate ticks)
+ // try second-level tick intervals that divide the label interval cleanly
+ for (const candidateSeconds of SECOND_MULTIPLIERS) {
+ const ratio = labelIntervalSeconds / candidateSeconds;
+ const isDivisor = Math.abs(ratio - Math.round(ratio)) < 0.0001;
+ if (isDivisor) {
+ const candidateSpacing = pixelsPerSecond * candidateSeconds;
+ if (candidateSpacing >= MIN_TICK_SPACING_PX) {
+ return candidateSeconds;
+ }
+ }
+ }
+
+ // fallback: use the label interval itself (no intermediate ticks)
return labelIntervalSeconds;
}
@@ -135,7 +150,7 @@ function findOptimalInterval({
minSpacingPx: number;
frameIntervals: readonly number[];
}): number {
- // Try frame-level intervals first
+ // try frame-level intervals first
for (const frameInterval of frameIntervals) {
const pixelSpacing = pixelsPerFrame * frameInterval;
if (pixelSpacing >= minSpacingPx) {
@@ -143,7 +158,7 @@ function findOptimalInterval({
}
}
- // Then try second-level intervals
+ // then try second-level intervals
for (const secondMultiplier of SECOND_MULTIPLIERS) {
const pixelSpacing = pixelsPerSecond * secondMultiplier;
if (pixelSpacing >= minSpacingPx) {
@@ -155,7 +170,7 @@ function findOptimalInterval({
}
/**
- * Checks if a time should have a label based on the label interval.
+ * checks if a time should have a label based on the label interval.
*/
export function shouldShowLabel({
time,
@@ -170,10 +185,10 @@ export function shouldShowLabel({
}
/**
- * Formats a ruler tick label.
+ * formats a ruler tick label.
*
- * - On second boundaries: "MM:SS" (e.g., "00:00", "01:30")
- * - Between seconds: "Xf" (e.g., "5f", "15f")
+ * - on second boundaries: "MM:SS" (e.g., "00:00", "01:30")
+ * - between seconds: "Xf" (e.g., "5f", "15f")
*/
export function formatRulerLabel({
timeInSeconds,
@@ -191,7 +206,7 @@ export function formatRulerLabel({
}
/**
- * Checks if a time falls exactly on a second boundary.
+ * checks if a time falls exactly on a second boundary.
*/
function isSecondBoundary({
timeInSeconds,
@@ -204,7 +219,7 @@ function isSecondBoundary({
}
/**
- * Gets the frame number within the current second.
+ * gets the frame number within the current second.
*/
function getFrameWithinSecond({
timeInSeconds,
@@ -218,7 +233,7 @@ function getFrameWithinSecond({
}
/**
- * Formats a timestamp as MM:SS.
+ * formats a timestamp as MM:SS.
*/
function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
const totalSeconds = Math.round(timeInSeconds);