Skip to content
Draft
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
26 changes: 21 additions & 5 deletions packages/studio/src/player/components/BeatStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { memo, useRef, useState } from "react";
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";

export const BEAT_BAND_H = 14; // dark band height at top of track
const BEAT_HIT_W = 12; // grab width per beat (px)
Expand All @@ -24,23 +25,30 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
beatStrengths,
pps,
highlightTime,
renderTimeRange,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
/** Snap guide time — drawn as a bright line even when it is not a beat. */
highlightTime?: number | null;
renderTimeRange?: TimelineTimeRange;
}) {
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
const highlightIsBeat =
highlightTime != null &&
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
if (!visibleBeatTimes && highlightTime == null) return null;
const beatEntries = getTimelineBeatEntries(
visibleBeatTimes ?? undefined,
beatStrengths,
renderTimeRange,
);
return (
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
{visibleBeatTimes?.map((t, i) => {
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
return (
<div
Expand Down Expand Up @@ -81,26 +89,34 @@ export const BeatStrip = memo(function BeatStrip({
beatTimes,
beatStrengths,
pps,
renderTimeRange,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
renderTimeRange?: TimelineTimeRange;
}) {
// Active drag: which beat and how far (px) it's been dragged.
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);

if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const cy = BEAT_BAND_H / 2;
const beatEntries = getTimelineBeatEntries(
beatTimes,
beatStrengths,
renderTimeRange,
drag ? new Set([drag.index]) : undefined,
);

return (
<div
className="absolute left-0 right-0 pointer-events-none"
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
>
{beatTimes.map((t, i) => {
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
const r = 1.5 + strength * 2.5;
const opacity = 0.25 + strength * 0.75;
const dxPx = drag?.index === i ? drag.dx : 0;
Expand Down
25 changes: 20 additions & 5 deletions packages/studio/src/player/components/TimelineRuler.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { RULER_H, formatTimelineTickLabel, getTimelineBeatEntries } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";

interface TimelineRulerProps {
major: number[];
Expand All @@ -16,6 +17,7 @@ interface TimelineRulerProps {
theme: TimelineTheme;
beatAnalysis?: MusicBeatAnalysis | null;
contentOrigin: number;
renderTimeRange?: TimelineTimeRange;
}

export const TimelineRuler = memo(function TimelineRuler({
Expand All @@ -29,10 +31,12 @@ export const TimelineRuler = memo(function TimelineRuler({
theme,
beatAnalysis,
contentOrigin,
renderTimeRange,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);

// Only draw beat lines when they'd be at least 5px apart
const avgBeatInterval =
Expand All @@ -51,13 +55,14 @@ export const TimelineRuler = memo(function TimelineRuler({
height={totalH}
>
{showBeats &&
beatTimes.map((t, i) => {
beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
const x = t * pps;
// Louder beats → brighter line. Gamma curve widens the contrast.
const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
const opacity = 0.08 + strength * 0.62;
return (
<line
data-timeline-grid-cell="beat"
key={`b-${t}-${i}`}
x1={x}
y1={0}
Expand Down Expand Up @@ -103,13 +108,23 @@ export const TimelineRuler = memo(function TimelineRuler({
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
a tick spans [x, x+1) and its center is half a pixel right. */}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
<div
key={`m-${t}`}
data-timeline-grid-cell="minor"
className="absolute bottom-0"
style={{ left: t * pps - 0.5 }}
>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}

{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
<div
key={`M-${t}`}
data-timeline-grid-cell="major"
className="absolute top-0"
style={{ left: t * pps - 0.5 }}
>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
Expand Down
40 changes: 40 additions & 0 deletions packages/studio/src/player/components/timelineLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,47 @@ import {
getTimelineRowGeometry,
trackHeights,
resolveTimelineAssetDrop,
generateTicks,
getTimelineBeatEntries,
getTimelineMajorTickInterval,
} from "./timelineLayout";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";

describe("horizontal timeline window", () => {
it("adds the shared half-viewport overscan on each side and clamps to duration", () => {
expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
{ start: 0, end: 8.5 },
);
expect(
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
).toEqual({ start: 14.5, end: 20 });
});

it("generates globally aligned ticks directly inside the bounded window", () => {
const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
const interval = getTimelineMajorTickInterval(10_000, 100);
expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
expect(
ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
).toBe(true);
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
});

it("slices beat records with original strength indexes and unions a pinned beat", () => {
expect(
getTimelineBeatEntries(
[0, 1, 2, 3],
[0.1, 0.2, 0.3, 0.4],
{ start: 1, end: 3 },
new Set([3]),
),
).toEqual([
{ index: 1, time: 1, strength: 0.2 },
{ index: 2, time: 2, strength: 0.3 },
{ index: 3, time: 3, strength: 0.4 },
]);
});
});

describe("variable timeline row geometry", () => {
const tracks = [
Expand Down
48 changes: 47 additions & 1 deletion packages/studio/src/player/components/timelineLayout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { ZoomMode } from "../store/playerStore";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";

export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
export {
formatTimelineTickLabel,
generateTicks,
getTimelineMajorTickInterval,
} from "./timelineRulerGeometry";

/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
Expand All @@ -11,6 +16,47 @@ export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;

export interface TimelineBeatEntry {
readonly index: number;
readonly time: number;
readonly strength: number | undefined;
}

function findFirstTimeAtOrAfter(times: readonly number[], target: number): number {
let low = 0;
let high = times.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1;
else high = mid;
}
return low;
}

/** Slice sorted beat data without allocating entries outside the render window. */
export function getTimelineBeatEntries(
beatTimes: readonly number[] | undefined,
beatStrengths: readonly number[] | undefined,
range: TimelineTimeRange | undefined,
pinnedIndexes: ReadonlySet<number> = new Set(),
): readonly TimelineBeatEntry[] {
if (!beatTimes?.length) return [];
const start = range?.start ?? Number.NEGATIVE_INFINITY;
const end = range?.end ?? Number.POSITIVE_INFINITY;
const selected = new Set<number>();
for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) {
const time = beatTimes[index];
if (time === undefined || time >= end) break;
selected.add(index);
}
for (const index of pinnedIndexes) {
if (index >= 0 && index < beatTimes.length) selected.add(index);
}
return [...selected]
.sort((left, right) => left - right)
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
}

export function getTimelineLaneTop(laneIndex: number): number {
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
Expand Down
49 changes: 38 additions & 11 deletions packages/studio/src/player/components/timelineRulerGeometry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { formatTime } from "../lib/time";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";

// fallow-ignore-next-line complexity
function getTimelineMajorTickInterval(
export function getTimelineMajorTickInterval(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
Expand Down Expand Up @@ -49,28 +50,54 @@ function roundTickValue(time: number): number {
return Math.round(time * 1e6) / 1e6;
}

function isSupportedTickDuration(duration: number): boolean {
return duration > 0 && Number.isFinite(duration) && duration <= 14400;
}

function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange {
return {
start: Math.max(0, range?.start ?? 0),
end: Math.min(duration, range?.end ?? duration),
};
}

function appendMinorTicks(
minor: number[],
majorTime: number,
minorInterval: number,
subdivisions: number,
range: TimelineTimeRange,
maxTicks: number,
majorCount: number,
): void {
for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) {
const time = majorTime + part * minorInterval;
if (time >= range.start - 0.001 && time <= range.end + 0.001) {
minor.push(roundTickValue(time));
}
}
}

export function generateTicks(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
range?: TimelineTimeRange,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) {
return { major: [], minor: [] };
}
if (!isSupportedTickDuration(duration)) 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 tickRange = getTickRange(duration, range);
const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval));
for (let index = firstMajorIndex; 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));
}
if (time > tickRange.end + 0.001) break;
if (time >= tickRange.start - 0.001) major.push(roundTickValue(time));
appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length);
}
return { major, minor };
}
Expand Down
20 changes: 20 additions & 0 deletions packages/studio/src/player/components/timelineViewportGeometry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";

export function getTimelineRenderTimeRange(
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
pixelsPerSecond: number,
contentOrigin: number,
duration: number,
): TimelineTimeRange {
if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) {
return { start: 0, end: 0 };
}
const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio;
const startPx = viewport.scrollLeft - contentOrigin - overscanPx;
const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx;
return {
start: Math.min(duration, Math.max(0, startPx / pixelsPerSecond)),
end: Math.min(duration, Math.max(0, endPx / pixelsPerSecond)),
};
}

export function getTimelineVisibleTimeRange(
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
pixelsPerSecond: number,
Expand Down
Loading
Loading