Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 22 additions & 73 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { isMusicTrack } from "../../utils/timelineInspector";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { useMountEffect } from "../../hooks/useMountEffect";
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
Expand All @@ -16,14 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { TimelineOverlays } from "./TimelineOverlays";
import { animationContributesLane } from "./TimelinePropertyLanes";
import { useTimelineEditPinning } from "./useTimelineEditPinning";
import { useTimelineStackingSync } from "./useTimelineStackingSync";
import { useTimelineGeometry } from "./useTimelineGeometry";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
import {
Expand All @@ -37,11 +34,17 @@ import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
import {
getEffectiveTimelineDuration,
getTimelinePreviewElement,
hasKeyframedTimelineClips,
} from "./timelineViewModel";
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
generateTicks,
formatTimelineTickLabel,
shouldAutoScrollTimeline,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
Expand All @@ -52,6 +55,7 @@ export {
shouldHandleTimelineDeleteKey,
getDefaultDroppedTrack,
} from "./timelineLayout";
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";

export const Timeline = memo(function Timeline({
onSeek,
Expand Down Expand Up @@ -117,13 +121,7 @@ export const Timeline = memo(function Timeline({
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
const hasKeyframedClips = useMemo(
() =>
Array.from(gsapAnimations.values()).some((list) =>
// Same lane-contribution predicate the layout uses: real keyframes OR a
// synthesizable flat tween. Checking animation.keyframes alone left a
// flat-tween-only comp without its reserved label column.
list.some((animation) => animationContributesLane(animation)),
),
() => hasKeyframedTimelineClips(gsapAnimations),
[gsapAnimations],
);
const labelMode = hasKeyframedClips;
Expand All @@ -142,20 +140,7 @@ export const Timeline = memo(function Timeline({
const activeTool = usePlayerStore((s) => s.activeTool);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const [shiftHeld, setShiftHeld] = useState(false);

useMountEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
const blur = () => setShiftHeld(false);
window.addEventListener("keydown", key);
window.addEventListener("keyup", key);
window.addEventListener("blur", blur);
return () => {
window.removeEventListener("keydown", key);
window.removeEventListener("keyup", key);
window.removeEventListener("blur", blur);
};
});
const shiftHeld = useTimelineShiftModifier();

const [showPopover, setShowPopover] = useState(false);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
Expand All @@ -172,12 +157,10 @@ export const Timeline = memo(function Timeline({
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
const lastScrollLeftRef = useRef(0);

const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
if (rawElements.length === 0) return safeDur;
const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
return Number.isFinite(result) ? result : safeDur;
}, [rawElements, duration]);
const effectiveDuration = useMemo(
() => getEffectiveTimelineDuration(duration, rawElements),
[duration, rawElements],
);

const keyframeCache = usePlayerStore((s) => s.keyframeCache);
useAutoExpandKeyframedClips(gsapAnimations);
Expand Down Expand Up @@ -300,14 +283,6 @@ export const Timeline = memo(function Timeline({
toggleSelectedKeyframe,
});

const selectedElement = useMemo(
() =>
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[expandedElements, selectedElementId],
);
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
selectedElementRef.current = selectedElement;

const {
pps,
fitPps,
Expand Down Expand Up @@ -406,41 +381,15 @@ export const Timeline = memo(function Timeline({
});
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag

const prevSelectedRef = useRef(selectedElementRef.current);
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
useEffect(() => {
const prev = prevSelectedRef.current;
const curr = selectedElementRef.current;
prevSelectedRef.current = curr;
if (prev && !curr) {
setShowPopover(false);
setRangeSelection(null);
}
});

// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
const { major, minor } = useMemo(
() => generateTicks(displayDuration, pps, tickFps),
[displayDuration, pps, tickFps],
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
setRangeSelection(null),
);

const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;

const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (
resizingClip &&
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
) {
return {
...element,
start: resizingClip.previewStart,
duration: resizingClip.previewDuration,
playbackStart: resizingClip.previewPlaybackStart,
};
}
return element;
},
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
[resizingClip],
);

Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/components/TimelineRuler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { RULER_H } from "./timelineLayout";
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
Expand Down
118 changes: 0 additions & 118 deletions packages/studio/src/player/components/timelineLayout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { formatTime } from "../lib/time";
import type { ZoomMode } from "../store/playerStore";

/* ── Layout constants ──────────────────────────────────────────────── */
Expand Down Expand Up @@ -193,123 +192,6 @@ export const MIN_TIMELINE_EXTENT_S = 60;
export const FIT_ZOOM_HEADROOM = 1.2;

/* ── Tick generation ──────────────────────────────────────────────── */
// fallow-ignore-next-line complexity
function getMajorTickInterval(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
const zoomIntervals = [
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
];
let interval: number;
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 88;
interval =
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
3600;
} else {
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
}
// Frame display mode: labels are frame numbers, so a major step must be a
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
// the label spacing never drops below the readability target.
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
const fps = frameRate ?? 0;
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
}
return interval;
}

// How many equal parts to split each major interval into for minor ticks. Prefer
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
// sub-frame time is not a seekable position), so only divisors of the major
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
// thirds, halves.
// fallow-ignore-next-line complexity
function getMinorSubdivisions(
majorInterval: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
for (const parts of candidates) {
if (fps > 0 && majorFrames % parts !== 0) continue;
if ((majorInterval / parts) * pps >= 8) return parts;
}
return 0;
}

// Ticks are exact multiples of the interval (multiplied per index, never
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
function roundTickValue(t: number): number {
return Math.round(t * 1e6) / 1e6;
}

export function generateTicks(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
return { major: [], minor: [] };
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (let i = 0; major.length < maxTicks; i++) {
const t = i * majorInterval;
if (t > duration + 0.001) break;
major.push(roundTickValue(t));
// Emit the (subdivisions - 1) minor ticks between this major and the next.
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
const m = t + k * minorInterval;
if (m <= duration + 0.001) minor.push(roundTickValue(m));
}
}
return { major, minor };
}

export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "00:00";
const safeTime = Math.max(0, time);
if (majorInterval < 0.1) {
const totalHundredths = Math.round(safeTime * 100);
const wholeSeconds = Math.floor(totalHundredths / 100);
const hundredth = totalHundredths % 100;
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
}
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
const tenth = totalTenths % 10;
return `${formatTime(wholeSeconds)}.${tenth}`;
}
if (duration >= 3600 || safeTime >= 3600) {
const totalSeconds = Math.floor(safeTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return formatTime(safeTime);
}

/* ── Width / duration derivation ──────────────────────────────────── */
/**
* Fit-mode pixels-per-second: fill the viewport with the composition plus
Expand Down
Loading
Loading