Skip to content
Merged
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
12 changes: 12 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,18 @@ describe("createRuntimePlayer", () => {
player.seek(NaN);
expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0);
});

it("seeks to the exact safe duration without snapping back a frame", () => {
const timeline = createMockTimeline({ duration: 8 });
const deps = createMockDeps(timeline);
deps.getSafeDuration.mockReturnValue(8);
const player = createRuntimePlayer(deps);
player.seek(8);
expect(timeline.pause).toHaveBeenCalled();
expect(timeline.totalTime).toHaveBeenCalledWith(8, false);
expect(deps.onDeterministicSeek).toHaveBeenCalledWith(8);
expect(deps.onSyncMedia).toHaveBeenCalledWith(8, false);
});
});

describe("renderSeek", () => {
Expand Down
18 changes: 13 additions & 5 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
buildTrackZIndexMap,
formatTimelineAttributeNumber,
} from "./player/components/timelineEditing";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
} from "./player/components/timelineZoom";

interface EditingFile {
path: string;
Expand Down Expand Up @@ -204,9 +208,9 @@ export function StudioApp() {
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
: null;
const zoomMode = usePlayerStore((s) => s.zoomMode);
const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setPixelsPerSecond = usePlayerStore((s) => s.setPixelsPerSecond);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
const timelineElements = usePlayerStore((s) => s.elements);
const timelineDuration = usePlayerStore((s) => s.duration);
const effectiveTimelineDuration = useMemo(() => {
Expand All @@ -216,6 +220,10 @@ export function StudioApp() {
: 0;
return Math.max(timelineDuration, maxEnd);
}, [timelineDuration, timelineElements]);
const displayedTimelineZoomPercent = useMemo(
() => getTimelineZoomPercent(zoomMode, manualZoomPercent),
[zoomMode, manualZoomPercent],
);

const renderClipContent = useCallback(
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
Expand Down Expand Up @@ -336,21 +344,21 @@ export function StudioApp() {
type="button"
onClick={() => {
setZoomMode("manual");
setPixelsPerSecond(Math.max(20, Math.round(pixelsPerSecond * 0.8)));
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
}}
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
title="Zoom out"
>
-
</button>
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
{zoomMode === "fit" ? "Auto" : `${Math.round(pixelsPerSecond)} px/s`}
{`${displayedTimelineZoomPercent}%`}
</div>
<button
type="button"
onClick={() => {
setZoomMode("manual");
setPixelsPerSecond(Math.min(2000, Math.round(pixelsPerSecond * 1.25)));
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
}}
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
title="Zoom in"
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ export const NLELayout = memo(function NLELayout({
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
{/* Timeline tracks */}
<div
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
className="flex-1 min-h-0 overflow-hidden bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
Expand Down
20 changes: 20 additions & 0 deletions packages/studio/src/player/components/PlayerControls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolveSeekPercent } from "./PlayerControls";

describe("resolveSeekPercent", () => {
it("returns 0 when the track width is invalid", () => {
expect(resolveSeekPercent(100, 0, 0)).toBe(0);
});

it("snaps to the start within the edge threshold", () => {
expect(resolveSeekPercent(105, 100, 200)).toBe(0);
});

it("snaps to the end within the edge threshold", () => {
expect(resolveSeekPercent(298, 100, 200)).toBe(1);
});

it("preserves the true percent away from the edges", () => {
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
});
});
13 changes: 12 additions & 1 deletion packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";

const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8;

export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
const rawPercent = (clientX - rectLeft) / rectWidth;
const clamped = Math.max(0, Math.min(1, rawPercent));
const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
if (clamped <= snapThreshold) return 0;
if (clamped >= 1 - snapThreshold) return 1;
return clamped;
}

interface PlayerControlsProps {
onTogglePlay: () => void;
Expand Down Expand Up @@ -88,7 +99,7 @@ export const PlayerControls = memo(function PlayerControls({
const bar = seekBarRef.current;
if (!bar || duration <= 0) return;
const rect = bar.getBoundingClientRect();
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const percent = resolveSeekPercent(clientX, rect.left, rect.width);
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
const pct = percent * 100;
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
Expand Down
45 changes: 44 additions & 1 deletion packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { generateTicks } from "./Timeline";
import {
generateTicks,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomTransition,
shouldAutoScrollTimeline,
} from "./Timeline";
import { formatTime } from "../lib/time";

describe("generateTicks", () => {
Expand Down Expand Up @@ -108,3 +113,41 @@ describe("formatTime", () => {
expect(formatTime(61)).toBe("1:01");
});
});

describe("shouldAutoScrollTimeline", () => {
it("never auto-scrolls in fit mode", () => {
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
});

it("does not auto-scroll when there is no horizontal overflow", () => {
expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false);
expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false);
});

it("auto-scrolls in manual mode when horizontal overflow exists", () => {
expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true);
});
});

describe("getTimelineScrollLeftForZoomTransition", () => {
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
});

it("preserves the current scroll offset for other zoom transitions", () => {
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
});
});

describe("getTimelinePlayheadLeft", () => {
it("converts time to a pixel offset from the gutter", () => {
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
});

it("guards invalid input", () => {
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
});
});
84 changes: 75 additions & 9 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
import {
usePlayerStore,
liveTime,
type TimelineElement,
type ZoomMode,
} from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatTime } from "../lib/time";
import { TimelineClip } from "./TimelineClip";
Expand All @@ -16,6 +21,7 @@ import {
type TimelineTrackStyle,
type TimelineTheme,
} from "./timelineTheme";
import { getTimelinePixelsPerSecond } from "./timelineZoom";

/* ── Layout ─────────────────────────────────────────────────────── */
const GUTTER = 32;
Expand Down Expand Up @@ -99,6 +105,30 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
return { major, minor };
}

export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
clientWidth: number,
): boolean {
if (zoomMode === "fit") return false;
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
return scrollWidth - clientWidth > 1;
}

export function getTimelineScrollLeftForZoomTransition(
previousZoomMode: ZoomMode | null,
nextZoomMode: ZoomMode,
currentScrollLeft: number,
): number {
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
return currentScrollLeft;
}

export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
}

/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
Expand Down Expand Up @@ -171,8 +201,9 @@ export const Timeline = memo(function Timeline({
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const updateElement = usePlayerStore((s) => s.updateElement);
const currentTime = usePlayerStore((s) => s.currentTime);
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -288,24 +319,53 @@ export const Timeline = memo(function Timeline({
viewportWidth > GUTTER && effectiveDuration > 0
? (viewportWidth - GUTTER - 2) / effectiveDuration
: 100;
const pps = zoomMode === "fit" ? fitPps : manualPps;
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
const trackContentWidth = Math.max(0, effectiveDuration * pps);
const zoomModeRef = useRef(zoomMode);
zoomModeRef.current = zoomMode;
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);

const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
const ppsRef = useRef(pps);
ppsRef.current = pps;
const syncPlayheadPosition = useCallback((time: number) => {
if (!playheadRef.current || durationRef.current <= 0) return;
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
}, []);

useEffect(() => {
syncPlayheadPosition(currentTime);
}, [currentTime, pps, syncPlayheadPosition]);

useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) {
previousZoomModeRef.current = zoomMode;
return;
}
scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
previousZoomModeRef.current,
zoomMode,
scroll.scrollLeft,
);
previousZoomModeRef.current = zoomMode;
}, [zoomMode]);

useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
const dur = durationRef.current;
if (!playheadRef.current || dur <= 0) return;
const px = t * ppsRef.current;
playheadRef.current.style.left = `${GUTTER + px}px`;
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
playheadRef.current.style.left = `${playheadX}px`;

// Auto-scroll to follow playhead during playback or seeking
const scroll = scrollRef.current;
if (scroll && !isDragging.current) {
const playheadX = GUTTER + px;
if (
Comment thread
miguel-heygen marked this conversation as resolved.
scroll &&
!isDragging.current &&
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
) {
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
const visibleLeft = scroll.scrollLeft;
const edgeMargin = scroll.clientWidth * 0.12;
Expand Down Expand Up @@ -444,7 +504,13 @@ export const Timeline = memo(function Timeline({
(clientX: number) => {
cancelAnimationFrame(dragScrollRaf.current);
const el = scrollRef.current;
if (!el || !isDragging.current) return;
if (
!el ||
!isDragging.current ||
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
) {
return;
}
const rect = el.getBoundingClientRect();
const edgeZone = 40;
const maxSpeed = 12;
Expand Down
62 changes: 62 additions & 0 deletions packages/studio/src/player/components/timelineZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
clampTimelineZoomPercent,
getNextTimelineZoomPercent,
getTimelinePixelsPerSecond,
getTimelineZoomPercent,
MAX_TIMELINE_ZOOM_PERCENT,
MIN_TIMELINE_ZOOM_PERCENT,
} from "./timelineZoom";

describe("clampTimelineZoomPercent", () => {
it("defaults invalid values to 100", () => {
expect(clampTimelineZoomPercent(Number.NaN)).toBe(100);
});

it("clamps to the supported percent bounds", () => {
expect(clampTimelineZoomPercent(1)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
expect(clampTimelineZoomPercent(5000)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
});
});

describe("getTimelineZoomPercent", () => {
it("treats fit mode as 100 percent", () => {
expect(getTimelineZoomPercent("fit", 375)).toBe(100);
});

it("returns the clamped manual zoom percent", () => {
expect(getTimelineZoomPercent("manual", 125.2)).toBe(125);
});
});

describe("getTimelinePixelsPerSecond", () => {
it("uses fit pixels per second in fit mode", () => {
expect(getTimelinePixelsPerSecond(144, "fit", 250)).toBe(144);
});

it("scales from fit pixels per second in manual mode", () => {
expect(getTimelinePixelsPerSecond(144, "manual", 125)).toBe(180);
});
});

describe("getNextTimelineZoomPercent", () => {
it("zooms out from fit relative to 100 percent", () => {
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(80);
});

it("zooms in from fit relative to 100 percent", () => {
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(125);
});

it("clamps the lower bound", () => {
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT)).toBe(
MIN_TIMELINE_ZOOM_PERCENT,
);
});

it("clamps the upper bound", () => {
expect(getNextTimelineZoomPercent("in", "manual", MAX_TIMELINE_ZOOM_PERCENT)).toBe(
MAX_TIMELINE_ZOOM_PERCENT,
);
});
});
Loading
Loading