diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 1e3f0719f9..e0ebe2c18b 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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"; @@ -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, generateTicks } from "./timelineLayout"; +import { GUTTER, LABEL_COL_W } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; -import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; import { @@ -37,6 +34,14 @@ import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; +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 { @@ -118,13 +123,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 = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; @@ -140,20 +139,7 @@ export const Timeline = memo(function Timeline({ const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(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(null); @@ -170,12 +156,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); @@ -293,14 +277,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(selectedElement); - selectedElementRef.current = selectedElement; - const { pps, fitPps, @@ -399,41 +375,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], ); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 181ca290b8..73b268e81e 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -20,7 +20,8 @@ import { type MultiDragPreviewInput } from "./timelineMultiDragPreview"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import type { Rect } from "../../utils/marqueeGeometry"; import { TimelineClip } from "./TimelineClip"; -import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes"; +import { TimelineLanes } from "./TimelineLanes"; +import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; import { renderClipChildren } from "./timelineClipChildren"; import { useTimelineRevealClip } from "./useTimelineRevealClip"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; diff --git a/packages/studio/src/player/components/TimelineLaneTypes.ts b/packages/studio/src/player/components/TimelineLaneTypes.ts new file mode 100644 index 0000000000..2b35f89c16 --- /dev/null +++ b/packages/studio/src/player/components/TimelineLaneTypes.ts @@ -0,0 +1,75 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; +import type { ReactNode } from "react"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; +import type { TimelineTheme } from "./timelineTheme"; +import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; +import type { TrackVisualStyle } from "./timelineIcons"; +import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore"; + +/** Props shared by TimelineCanvas and its lane renderer. */ +export interface TimelineLaneBaseProps { + pps: number; + contentOrigin: number; + contentGutter: number; + trackContentWidth: number; + theme: TimelineTheme; + displayTrackOrder: number[]; + rowHeights: readonly number[]; + trackOrder: number[]; + tracks: [number, TimelineElement[]][]; + trackStyles: Map; + laneCounts: ReadonlyMap; + selectedElementId: string | null; + selectedElementIds: Set; + hoveredClip: string | null; + draggedClip: DraggedClipState | null; + blockedClipRef: React.RefObject; + suppressClickRef: React.RefObject; + scrollRef: React.RefObject; + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + renderClipOverlay?: (element: TimelineElement) => ReactNode; + onDrillDown?: (element: TimelineElement) => void; + onSelectElement?: (element: TimelineElement | null) => void; + setHoveredClip: (key: string | null) => void; + setShowPopover: (value: boolean) => void; + setRangeSelection: (value: null) => void; + setResizingClip: (value: ResizingClipState | null) => void; + setDraggedClip: (value: DraggedClipState | null) => void; + setSelectedElementId: (id: string | null) => void; + syncClipDragAutoScroll: (x: number, y: number) => void; + shiftClickClipRef: React.RefObject<{ + element: TimelineElement; + anchorX: number; + anchorY: number; + } | null>; + getPreviewElement: (element: TimelineElement) => TimelineElement; + getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + gsapAnimations: Map; + selectedKeyframes: Set; + currentTime: number; + onSeek?: (time: number) => void; + onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: ( + event: React.MouseEvent, + elementId: string, + target: TimelineKeyframeTarget, + ) => void; + onMoveKeyframe?: ( + elementId: string, + fromClipPercentage: number, + toClipPercentage: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, + ) => Promise; + onContextMenuClip?: (event: React.MouseEvent, element: TimelineElement) => void; + onContextMenuLane?: (event: React.MouseEvent, track: number, time: number) => void; + beatAnalysis?: MusicBeatAnalysis | null; +} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 17f77f384d..2428c85669 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,111 +1,24 @@ -import { type ReactNode } from "react"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; -import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; -import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; -import type { TimelineTheme } from "./timelineTheme"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; -import { - usePlayerStore, - type TimelineElement, - type KeyframeCacheEntry, -} from "../store/playerStore"; -import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { isMultiDragPassenger, multiDragPassengerOffsetPx, type MultiDragPreviewInput, } from "./timelineMultiDragPreview"; -import type { TrackVisualStyle } from "./timelineIcons"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; import { renderClipChildren } from "./timelineClipChildren"; - -/** - * Props shared by the scroll container ({@link TimelineCanvas}) and the lane - * renderer below. TimelineCanvas passes these straight through via spread, so - * they are declared once here and both prop types compose from this base — no - * duplicated prop list. - */ -export interface TimelineLaneBaseProps { - pps: number; - contentOrigin: number; - contentGutter: number; - trackContentWidth: number; - theme: TimelineTheme; - displayTrackOrder: number[]; - rowHeights: readonly number[]; - trackOrder: number[]; - tracks: [number, TimelineElement[]][]; - trackStyles: Map; - laneCounts: ReadonlyMap; - selectedElementId: string | null; - selectedElementIds: Set; - hoveredClip: string | null; - draggedClip: DraggedClipState | null; - blockedClipRef: React.RefObject; - suppressClickRef: React.RefObject; - scrollRef: React.RefObject; - renderClipContent?: ( - element: TimelineElement, - style: { clip: string; label: string }, - ) => ReactNode; - renderClipOverlay?: (element: TimelineElement) => ReactNode; - onDrillDown?: (element: TimelineElement) => void; - onSelectElement?: (element: TimelineElement | null) => void; - setHoveredClip: (key: string | null) => void; - setShowPopover: (v: boolean) => void; - setRangeSelection: (v: null) => void; - setResizingClip: (v: ResizingClipState | null) => void; - setDraggedClip: (v: DraggedClipState | null) => void; - setSelectedElementId: (id: string | null) => void; - syncClipDragAutoScroll: (x: number, y: number) => void; - shiftClickClipRef: React.RefObject<{ - element: TimelineElement; - anchorX: number; - anchorY: number; - } | null>; - getPreviewElement: (element: TimelineElement) => TimelineElement; - getTrackStyle: (tag: string) => TrackVisualStyle; - keyframeCache?: Map; - gsapAnimations: Map; - selectedKeyframes: Set; - currentTime: number; - onSeek?: (time: number) => void; - onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void; - onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void; - onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void; - onContextMenuKeyframe?: ( - e: React.MouseEvent, - elementId: string, - target: TimelineKeyframeTarget, - ) => void; - onMoveKeyframe?: ( - elementId: string, - fromClipPercentage: number, - toClipPercentage: number, - propertyGroup?: string, - tweenPercentage?: number, - animationId?: string, - ) => Promise; - onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; - /** - * Right-click on EMPTY lane space (not on a clip — those preventDefault - * before this fires — not the gutter/ruler, not below the lanes). `time` is - * the timeline time (seconds) under the pointer on that lane. - */ - onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; - beatAnalysis?: MusicBeatAnalysis | null; -} +import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 5b629f48ce..c5d6ea6576 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,6 +1,7 @@ -import { formatTime } from "../lib/time"; import type { ZoomMode } from "../store/playerStore"; +export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; + /* ── Layout constants ──────────────────────────────────────────────── */ export const GUTTER = 32; export const LABEL_COL_W = 232; @@ -180,123 +181,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 diff --git a/packages/studio/src/player/components/timelineRulerGeometry.ts b/packages/studio/src/player/components/timelineRulerGeometry.ts new file mode 100644 index 0000000000..59d1c4794c --- /dev/null +++ b/packages/studio/src/player/components/timelineRulerGeometry.ts @@ -0,0 +1,105 @@ +import { formatTime } from "../lib/time"; + +// fallow-ignore-next-line complexity +function getTimelineMajorTickInterval( + duration: number, + pixelsPerSecond?: number, + frameRate?: number, +): number { + 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; + } + if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) { + const fps = frameRate ?? 0; + return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps; + } + return interval; +} + +// 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; + 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; +} + +function roundTickValue(time: number): number { + return Math.round(time * 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 = getTimelineMajorTickInterval(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; + for (let index = 0; major.length < maxTicks; index++) { + const time = index * majorInterval; + if (time > duration + 0.001) break; + major.push(roundTickValue(time)); + for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) { + const minorTime = time + part * minorInterval; + if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime)); + } + } + return { major, minor }; +} + +export function formatTimelineTickLabel( + time: number, + duration: number, + majorInterval: number, +): string { + 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); +} diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts new file mode 100644 index 0000000000..86b1860399 --- /dev/null +++ b/packages/studio/src/player/components/timelineViewModel.ts @@ -0,0 +1,43 @@ +import type { TimelineElement } from "../store/playerStore"; +import type { ResizingClipState } from "./timelineClipDragTypes"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { animationContributesLane } from "./TimelinePropertyLanes"; + +export function hasKeyframedTimelineClips( + animationsByElement: ReadonlyMap, +): boolean { + return Array.from(animationsByElement.values()).some((animations) => + animations.some(animationContributesLane), + ); +} + +export function getEffectiveTimelineDuration( + duration: number, + elements: readonly TimelineElement[], +): number { + const safeDuration = Number.isFinite(duration) ? duration : 0; + if (elements.length === 0) return safeDuration; + const result = Math.max( + safeDuration, + ...elements.map((element) => element.start + element.duration), + ); + return Number.isFinite(result) ? result : safeDuration; +} + +export function getTimelinePreviewElement( + element: TimelineElement, + resizingClip: ResizingClipState | null, +): 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; +} diff --git a/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts new file mode 100644 index 0000000000..3781c87b2f --- /dev/null +++ b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { TimelineElement } from "../store/playerStore"; + +export function useTimelineSelectionLifecycle( + elements: TimelineElement[], + selectedElementId: string | null, + setShowPopover: (show: boolean) => void, + clearRangeSelection: () => void, +): void { + const selectedElement = useMemo( + () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, + [elements, selectedElementId], + ); + const selectedElementRef = useRef(selectedElement); + selectedElementRef.current = selectedElement; + const previousSelectedRef = useRef(selectedElementRef.current); + // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps + useEffect(() => { + const previous = previousSelectedRef.current; + const current = selectedElementRef.current; + previousSelectedRef.current = current; + if (previous && !current) { + setShowPopover(false); + clearRangeSelection(); + } + }); +} diff --git a/packages/studio/src/player/components/useTimelineShiftModifier.ts b/packages/studio/src/player/components/useTimelineShiftModifier.ts new file mode 100644 index 0000000000..7c334e4989 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineShiftModifier.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; + +export function useTimelineShiftModifier(): boolean { + const [shiftHeld, setShiftHeld] = useState(false); + useMountEffect(() => { + const handleKey = (event: KeyboardEvent) => + event.key === "Shift" && setShiftHeld(event.type === "keydown"); + const handleBlur = () => setShiftHeld(false); + window.addEventListener("keydown", handleKey); + window.addEventListener("keyup", handleKey); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKey); + window.removeEventListener("keyup", handleKey); + window.removeEventListener("blur", handleBlur); + }; + }); + return shiftHeld; +} diff --git a/packages/studio/src/player/components/useTimelineTicks.ts b/packages/studio/src/player/components/useTimelineTicks.ts new file mode 100644 index 0000000000..86c1caad56 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineTicks.ts @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { STUDIO_PREVIEW_FPS } from "../lib/time"; +import { generateTicks } from "./timelineLayout"; + +export function useTimelineTicks( + duration: number, + pixelsPerSecond: number, + timeDisplayMode: "time" | "frame", +): { major: number[]; minor: number[] } { + const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; + const { major, minor } = useMemo( + () => generateTicks(duration, pixelsPerSecond, frameRate), + [duration, frameRate, pixelsPerSecond], + ); + return { major, minor }; +} diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts new file mode 100644 index 0000000000..cf937f8882 --- /dev/null +++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts @@ -0,0 +1,20 @@ +import type { TimelineElement } from "../store/playerStore"; + +/** Whether a derived timeline changes any field that affects rendering. */ +export function timelineElementsChanged( + previous: TimelineElement[], + next: TimelineElement[], +): boolean { + if (next.length !== previous.length) return true; + return next.some((element, index) => { + const prior = previous[index]; + return ( + !prior || + element.id !== prior.id || + element.start !== prior.start || + element.duration !== prior.duration || + element.track !== prior.track || + element.sourceDuration !== prior.sourceDuration + ); + }); +} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 44bb17717d..bc1dbd7f37 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -47,26 +47,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol"; - -/** - * Whether the derived elements differ from the current ones in any field that - * affects rendering (identity, timing, track, or source length) — used to skip - * redundant store writes. - */ -function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean { - if (next.length !== prev.length) return true; - return next.some((el, i) => { - const p = prev[i]; - return ( - !p || - el.id !== p.id || - el.start !== p.start || - el.duration !== p.duration || - el.track !== p.track || - el.sourceDuration !== p.sourceDuration - ); - }); -} +import { timelineElementsChanged } from "./timelinePlayerSync"; export function useTimelinePlayer() { const iframeRef = useRef(null);