@hyperframes/studio 0.5.0-alpha.7 → 0.5.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,18 @@ import {
4
4
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
5
  getTimelineToggleTitle,
6
6
  } from "../../utils/timelineDiscovery";
7
- import { formatTime } from "../lib/time";
7
+ import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
8
8
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
9
 
10
10
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
11
11
  const SEEK_EDGE_SNAP_PX = 8;
12
+ type TimeDisplayMode = "time" | "frame";
13
+ const SHORTCUT_HINTS = [
14
+ { key: "J", label: "Play backward" },
15
+ { key: "K", label: "Stop playback" },
16
+ { key: "L", label: "Play forward" },
17
+ { key: "←/→", label: "Step one frame backward or forward" },
18
+ ] as const;
12
19
 
13
20
  export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
14
21
  if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
@@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
38
45
  const duration = usePlayerStore((s) => s.duration);
39
46
  const timelineReady = usePlayerStore((s) => s.timelineReady);
40
47
  const playbackRate = usePlayerStore((s) => s.playbackRate);
48
+ const loopEnabled = usePlayerStore((s) => s.loopEnabled);
41
49
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
50
+ const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
42
51
  const [showSpeedMenu, setShowSpeedMenu] = useState(false);
52
+ const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
53
+ const [jumpFrame, setJumpFrame] = useState("");
43
54
 
44
55
  const progressFillRef = useRef<HTMLDivElement>(null);
45
56
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
49
60
  const speedMenuContainerRef = useRef<HTMLDivElement>(null);
50
61
  const isDraggingRef = useRef(false);
51
62
  const currentTimeRef = useRef(0);
63
+ const timeDisplayModeRef = useRef(timeDisplayMode);
64
+ timeDisplayModeRef.current = timeDisplayMode;
52
65
 
53
66
  const durationRef = useRef(duration);
54
67
  durationRef.current = duration;
@@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
59
72
  const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
60
73
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
61
74
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
62
- if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
75
+ if (timeDisplayRef.current) {
76
+ timeDisplayRef.current.textContent =
77
+ timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
78
+ }
63
79
  if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
64
80
  };
65
81
  const unsub = liveTime.subscribe(updateProgress);
@@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
82
98
  };
83
99
  });
84
100
 
101
+ useEffect(() => {
102
+ if (!timeDisplayRef.current) return;
103
+ const t = currentTimeRef.current;
104
+ timeDisplayRef.current.textContent =
105
+ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
106
+ }, [duration, timeDisplayMode]);
107
+
85
108
  useEffect(() => {
86
109
  if (!showSpeedMenu) return;
87
110
  const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
190
213
  const handleKeyDown = useCallback(
191
214
  (e: React.KeyboardEvent) => {
192
215
  if (!timelineReady || duration <= 0) return;
193
- const step = e.shiftKey ? 5 : 1;
216
+ const step = e.shiftKey ? 10 : 1;
194
217
  if (e.key === "ArrowLeft") {
195
218
  e.preventDefault();
196
- onSeek(Math.max(0, currentTimeRef.current - step));
219
+ onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
197
220
  } else if (e.key === "ArrowRight") {
198
221
  e.preventDefault();
199
- onSeek(Math.min(duration, currentTimeRef.current + step));
222
+ onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
200
223
  }
201
224
  },
202
225
  [timelineReady, duration, onSeek],
203
226
  );
204
227
 
228
+ const commitJumpFrame = useCallback(() => {
229
+ const frame = Number.parseInt(jumpFrame, 10);
230
+ if (!Number.isFinite(frame) || duration <= 0) return;
231
+ onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
232
+ }, [duration, jumpFrame, onSeek]);
233
+
234
+ const handleJumpSubmit = useCallback(
235
+ (e: React.FormEvent) => {
236
+ e.preventDefault();
237
+ commitJumpFrame();
238
+ },
239
+ [commitJumpFrame],
240
+ );
241
+
242
+ const handleJumpKeyDown = useCallback(
243
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
244
+ if (e.key !== "Enter") return;
245
+ e.preventDefault();
246
+ commitJumpFrame();
247
+ },
248
+ [commitJumpFrame],
249
+ );
250
+
205
251
  return (
206
252
  <div
207
- className="px-4 py-2 flex items-center gap-3"
253
+ className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
208
254
  style={{
209
255
  borderTop: "1px solid rgba(255,255,255,0.04)",
210
256
  // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({
236
282
 
237
283
  {/* Time display */}
238
284
  <span
239
- className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
285
+ className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
240
286
  style={{ color: "#A1A1AA" }}
241
287
  >
242
288
  <span ref={timeDisplayRef}>{formatTime(0)}</span>
243
- <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
244
- <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
289
+ {timeDisplayMode === "time" ? (
290
+ <>
291
+ <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
292
+ <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
293
+ </>
294
+ ) : null}
245
295
  </span>
246
296
 
247
297
  {/* Seek bar — teal progress fill */}
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
256
306
  aria-valuemin={0}
257
307
  aria-valuemax={Math.round(duration)}
258
308
  aria-valuenow={0}
259
- className="flex-1 h-6 flex items-center cursor-pointer group"
309
+ className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
260
310
  // `touch-action: none` tells the browser we're handling every
261
311
  // pointer gesture on this element ourselves. Without it, iOS
262
312
  // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
292
342
  <button
293
343
  type="button"
294
344
  onClick={() => setShowSpeedMenu((v) => !v)}
295
- className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
345
+ className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
296
346
  style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
297
347
  >
298
348
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
329
379
  )}
330
380
  </div>
331
381
 
382
+ <button
383
+ type="button"
384
+ onClick={() => setLoopEnabled(!loopEnabled)}
385
+ className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
386
+ loopEnabled
387
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
388
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
389
+ }`}
390
+ title="Loop playback"
391
+ aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
392
+ aria-pressed={loopEnabled}
393
+ >
394
+ Loop
395
+ </button>
396
+
397
+ <button
398
+ type="button"
399
+ onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
400
+ className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
401
+ title="Toggle time/frame display"
402
+ aria-label="Toggle time and frame display"
403
+ >
404
+ {timeDisplayMode === "time" ? "m:ss" : "frames"}
405
+ </button>
406
+
407
+ <form
408
+ onSubmit={handleJumpSubmit}
409
+ className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
410
+ >
411
+ <input
412
+ value={jumpFrame}
413
+ onChange={(e) => setJumpFrame(e.target.value)}
414
+ inputMode="numeric"
415
+ pattern="[0-9]*"
416
+ aria-label="Jump to frame"
417
+ placeholder="frame"
418
+ className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
419
+ onKeyDown={handleJumpKeyDown}
420
+ onBlur={commitJumpFrame}
421
+ />
422
+ </form>
423
+
424
+ <div
425
+ className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
426
+ aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
427
+ >
428
+ {SHORTCUT_HINTS.map((shortcut) => (
429
+ <span
430
+ key={shortcut.key}
431
+ className="group relative rounded border border-neutral-800 px-1 py-0.5"
432
+ >
433
+ {shortcut.key}
434
+ <span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
435
+ {shortcut.label}
436
+ </span>
437
+ </span>
438
+ ))}
439
+ </div>
440
+
332
441
  {/* Timeline toggle */}
333
442
  {onToggleTimeline !== undefined && (
334
443
  <button
@@ -1,11 +1,14 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ formatTimelineTickLabel,
3
4
  generateTicks,
4
5
  getDefaultDroppedTrack,
5
6
  getTimelineCanvasHeight,
6
7
  resolveTimelineAssetDrop,
7
8
  getTimelinePlayheadLeft,
9
+ getTimelineScrollLeftForZoomAnchor,
8
10
  getTimelineScrollLeftForZoomTransition,
11
+ shouldShowTimelineShortcutHint,
9
12
  shouldHandleTimelineDeleteKey,
10
13
  shouldAutoScrollTimeline,
11
14
  } from "./Timeline";
@@ -78,6 +81,20 @@ describe("generateTicks", () => {
78
81
  expect(major[0]).toBe(0);
79
82
  }
80
83
  });
84
+
85
+ it("uses denser major labels as timeline zoom increases", () => {
86
+ const fitTicks = generateTicks(180, 10);
87
+ const zoomedTicks = generateTicks(180, 48);
88
+ expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
89
+ expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
90
+ expect(zoomedTicks.minor).toContain(1);
91
+ expect(zoomedTicks.minor).toContain(4);
92
+ });
93
+
94
+ it("keeps labels readable instead of placing one at every tiny tick", () => {
95
+ const { major } = generateTicks(180, 80);
96
+ expect(major[1] - major[0]).toBe(2);
97
+ });
81
98
  });
82
99
 
83
100
  describe("formatTime", () => {
@@ -118,6 +135,20 @@ describe("formatTime", () => {
118
135
  });
119
136
  });
120
137
 
138
+ describe("formatTimelineTickLabel", () => {
139
+ it("uses minute-second labels for normal timeline intervals", () => {
140
+ expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
141
+ });
142
+
143
+ it("uses hour labels for long timelines", () => {
144
+ expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
145
+ });
146
+
147
+ it("shows subsecond labels when the major ruler interval is below one second", () => {
148
+ expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
149
+ });
150
+ });
151
+
121
152
  describe("shouldAutoScrollTimeline", () => {
122
153
  it("never auto-scrolls in fit mode", () => {
123
154
  expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
@@ -144,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
144
175
  expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
145
176
  });
146
177
  });
178
+
179
+ describe("getTimelineScrollLeftForZoomAnchor", () => {
180
+ it("preserves the time under the pointer when zooming in", () => {
181
+ expect(
182
+ getTimelineScrollLeftForZoomAnchor({
183
+ pointerX: 300,
184
+ currentScrollLeft: 200,
185
+ gutter: 32,
186
+ currentPixelsPerSecond: 10,
187
+ nextPixelsPerSecond: 20,
188
+ duration: 120,
189
+ }),
190
+ ).toBe(668);
191
+ });
192
+
193
+ it("clamps negative scroll targets", () => {
194
+ expect(
195
+ getTimelineScrollLeftForZoomAnchor({
196
+ pointerX: 300,
197
+ currentScrollLeft: 0,
198
+ gutter: 32,
199
+ currentPixelsPerSecond: 20,
200
+ nextPixelsPerSecond: 5,
201
+ duration: 120,
202
+ }),
203
+ ).toBe(0);
204
+ });
205
+
206
+ it("preserves current scroll when inputs are invalid", () => {
207
+ expect(
208
+ getTimelineScrollLeftForZoomAnchor({
209
+ pointerX: 300,
210
+ currentScrollLeft: 120,
211
+ gutter: 32,
212
+ currentPixelsPerSecond: 0,
213
+ nextPixelsPerSecond: 20,
214
+ duration: 120,
215
+ }),
216
+ ).toBe(120);
217
+ });
218
+ });
219
+
147
220
  describe("getTimelinePlayheadLeft", () => {
148
221
  it("converts time to a pixel offset from the gutter", () => {
149
222
  expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
@@ -165,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
165
238
  });
166
239
  });
167
240
 
241
+ describe("shouldShowTimelineShortcutHint", () => {
242
+ it("shows the hint when the timeline does not vertically overflow", () => {
243
+ expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
244
+ expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
245
+ });
246
+
247
+ it("hides the hint when timeline tracks need vertical scrolling", () => {
248
+ expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
249
+ });
250
+ });
251
+
168
252
  describe("shouldHandleTimelineDeleteKey", () => {
169
253
  it("handles Delete and Backspace when focus is not in an editor", () => {
170
254
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
@@ -26,7 +26,7 @@ import {
26
26
  type TimelineTrackStyle,
27
27
  type TimelineTheme,
28
28
  } from "./timelineTheme";
29
- import { getTimelinePixelsPerSecond } from "./timelineZoom";
29
+ import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
30
30
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
31
31
 
32
32
  /* ── Layout ─────────────────────────────────────────────────────── */
@@ -35,7 +35,7 @@ const TRACK_H = 72;
35
35
  const RULER_H = 24;
36
36
  const CLIP_Y = 3; // vertical inset inside track
37
37
  const CLIP_HANDLE_W = 18;
38
- const TIMELINE_SCROLL_BUFFER = 24;
38
+ const TIMELINE_SCROLL_BUFFER = 20;
39
39
 
40
40
  interface TrackVisualStyle extends TimelineTrackStyle {
41
41
  icon: ReactNode;
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
88
88
  }
89
89
 
90
90
  /* ── Tick Generation ────────────────────────────────────────────── */
91
- export function generateTicks(duration: number): { major: number[]; minor: number[] } {
91
+ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
92
+ const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
93
+ if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
94
+ const targetMajorPx = 128;
95
+ return (
96
+ zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
97
+ );
98
+ }
99
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
100
+ const target = duration / 6;
101
+ return durationIntervals.find((interval) => interval >= target) ?? 60;
102
+ }
103
+
104
+ function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
105
+ let interval = majorInterval / 2;
106
+ if (majorInterval >= 30) interval = majorInterval / 6;
107
+ else if (majorInterval >= 15) interval = majorInterval / 3;
108
+ else if (majorInterval >= 5) interval = majorInterval / 5;
109
+ else if (majorInterval >= 1) interval = majorInterval / 4;
110
+
111
+ if (
112
+ Number.isFinite(pixelsPerSecond) &&
113
+ (pixelsPerSecond ?? 0) > 0 &&
114
+ interval * (pixelsPerSecond ?? 0) < 20
115
+ ) {
116
+ return Math.max(0.25, majorInterval / 2);
117
+ }
118
+ return Math.max(0.25, interval);
119
+ }
120
+
121
+ export function generateTicks(
122
+ duration: number,
123
+ pixelsPerSecond?: number,
124
+ ): { major: number[]; minor: number[] } {
92
125
  if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
93
126
  return { major: [], minor: [] };
94
- const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
95
- const target = duration / 6;
96
- const majorInterval = intervals.find((i) => i >= target) ?? 60;
97
- const minorInterval = Math.max(0.25, majorInterval / 2);
127
+ const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
128
+ const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
98
129
  const major: number[] = [];
99
130
  const minor: number[] = [];
100
- const maxTicks = 500; // Safety cap to prevent infinite loop
131
+ const maxTicks = 2000; // Safety cap to prevent runaway tick generation
101
132
  for (
102
133
  let t = 0;
103
134
  t <= duration + 0.001 && major.length + minor.length < maxTicks;
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
113
144
  return { major, minor };
114
145
  }
115
146
 
147
+ export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
148
+ if (!Number.isFinite(time)) return "0:00";
149
+ const safeTime = Math.max(0, time);
150
+ if (majorInterval < 1) {
151
+ const totalTenths = Math.round(safeTime * 10);
152
+ const wholeSeconds = Math.floor(totalTenths / 10);
153
+ const tenth = totalTenths % 10;
154
+ return `${formatTime(wholeSeconds)}.${tenth}`;
155
+ }
156
+ if (duration >= 3600 || safeTime >= 3600) {
157
+ const totalSeconds = Math.floor(safeTime);
158
+ const hours = Math.floor(totalSeconds / 3600);
159
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
160
+ const seconds = totalSeconds % 60;
161
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
162
+ }
163
+ return formatTime(safeTime);
164
+ }
165
+
116
166
  export function shouldAutoScrollTimeline(
117
167
  zoomMode: ZoomMode,
118
168
  scrollWidth: number,
@@ -131,6 +181,32 @@ export function getTimelineScrollLeftForZoomTransition(
131
181
  if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
132
182
  return currentScrollLeft;
133
183
  }
184
+
185
+ export function getTimelineScrollLeftForZoomAnchor(input: {
186
+ pointerX: number;
187
+ currentScrollLeft: number;
188
+ gutter: number;
189
+ currentPixelsPerSecond: number;
190
+ nextPixelsPerSecond: number;
191
+ duration: number;
192
+ }): number {
193
+ const currentPps = Math.max(0, input.currentPixelsPerSecond);
194
+ const nextPps = Math.max(0, input.nextPixelsPerSecond);
195
+ if (
196
+ !Number.isFinite(input.pointerX) ||
197
+ !Number.isFinite(input.currentScrollLeft) ||
198
+ !Number.isFinite(input.duration) ||
199
+ input.duration <= 0 ||
200
+ currentPps <= 0 ||
201
+ nextPps <= 0
202
+ ) {
203
+ return Math.max(0, input.currentScrollLeft);
204
+ }
205
+ const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
206
+ const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
207
+ return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
208
+ }
209
+
134
210
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
135
211
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
136
212
  return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
@@ -140,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
140
216
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
141
217
  }
142
218
 
219
+ export function shouldShowTimelineShortcutHint(
220
+ scrollHeight: number,
221
+ clientHeight: number,
222
+ ): boolean {
223
+ if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
224
+ return scrollHeight - clientHeight <= 1;
225
+ }
226
+
143
227
  export function shouldHandleTimelineDeleteKey(input: {
144
228
  key: string;
145
229
  metaKey?: boolean;
@@ -304,6 +388,8 @@ export const Timeline = memo(function Timeline({
304
388
  const currentTime = usePlayerStore((s) => s.currentTime);
305
389
  const zoomMode = usePlayerStore((s) => s.zoomMode);
306
390
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
391
+ const setZoomMode = usePlayerStore((s) => s.setZoomMode);
392
+ const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
307
393
  const playheadRef = useRef<HTMLDivElement>(null);
308
394
  const containerRef = useRef<HTMLDivElement>(null);
309
395
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -348,30 +434,51 @@ export const Timeline = memo(function Timeline({
348
434
  onDeleteElementRef.current = onDeleteElement;
349
435
  const suppressClickRef = useRef(false);
350
436
  const [showPopover, setShowPopover] = useState(false);
437
+ const [showShortcutHint, setShowShortcutHint] = useState(true);
351
438
  const [viewportWidth, setViewportWidth] = useState(0);
352
439
  const roRef = useRef<ResizeObserver | null>(null);
440
+ const shortcutHintRafRef = useRef(0);
441
+ const syncShortcutHintVisibility = useCallback(() => {
442
+ const scroll = scrollRef.current;
443
+ setShowShortcutHint(
444
+ scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
445
+ );
446
+ }, []);
447
+ const scheduleShortcutHintVisibilitySync = useCallback(() => {
448
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
449
+ shortcutHintRafRef.current = requestAnimationFrame(() => {
450
+ shortcutHintRafRef.current = 0;
451
+ syncShortcutHintVisibility();
452
+ });
453
+ }, [syncShortcutHintVisibility]);
353
454
 
354
455
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
355
456
  // useMountEffect can't work here because the component returns null on first
356
457
  // render (timelineReady=false), so containerRef.current is null when the
357
458
  // effect fires and the ResizeObserver is never created.
358
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
359
- if (roRef.current) {
360
- roRef.current.disconnect();
361
- roRef.current = null;
362
- }
363
- containerRef.current = el;
364
- if (!el) return;
365
- setViewportWidth(el.clientWidth);
366
- roRef.current = new ResizeObserver(([entry]) => {
367
- setViewportWidth(entry.contentRect.width);
368
- });
369
- roRef.current.observe(el);
370
- }, []);
459
+ const setContainerRef = useCallback(
460
+ (el: HTMLDivElement | null) => {
461
+ if (roRef.current) {
462
+ roRef.current.disconnect();
463
+ roRef.current = null;
464
+ }
465
+ containerRef.current = el;
466
+ if (!el) return;
467
+ setViewportWidth(el.clientWidth);
468
+ scheduleShortcutHintVisibilitySync();
469
+ roRef.current = new ResizeObserver(([entry]) => {
470
+ setViewportWidth(entry.contentRect.width);
471
+ scheduleShortcutHintVisibilitySync();
472
+ });
473
+ roRef.current.observe(el);
474
+ },
475
+ [scheduleShortcutHintVisibilitySync],
476
+ );
371
477
 
372
478
  // Clean up ResizeObserver on unmount
373
479
  useMountEffect(() => () => {
374
480
  roRef.current?.disconnect();
481
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
375
482
  });
376
483
 
377
484
  // Effective duration: max of store duration and the furthest element end.
@@ -416,6 +523,7 @@ export const Timeline = memo(function Timeline({
416
523
  }
417
524
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
418
525
  }, [draggedClip, trackOrder]);
526
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
419
527
  const selectedElement = useMemo(
420
528
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
421
529
  [elements, selectedElementId],
@@ -433,7 +541,11 @@ export const Timeline = memo(function Timeline({
433
541
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
434
542
  const zoomModeRef = useRef(zoomMode);
435
543
  zoomModeRef.current = zoomMode;
544
+ const manualZoomPercentRef = useRef(manualZoomPercent);
545
+ manualZoomPercentRef.current = manualZoomPercent;
436
546
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
547
+ const fitPpsRef = useRef(fitPps);
548
+ fitPpsRef.current = fitPps;
437
549
 
438
550
  const durationRef = useRef(effectiveDuration);
439
551
  durationRef.current = effectiveDuration;
@@ -922,7 +1034,15 @@ export const Timeline = memo(function Timeline({
922
1034
  cancelAnimationFrame(dragScrollRaf.current);
923
1035
  }, []);
924
1036
 
925
- const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
1037
+ const { major, minor } = useMemo(
1038
+ () => generateTicks(effectiveDuration, pps),
1039
+ [effectiveDuration, pps],
1040
+ );
1041
+ const majorTickInterval =
1042
+ major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1043
+ useEffect(() => {
1044
+ syncShortcutHintVisibility();
1045
+ }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
926
1046
  const getPreviewElement = useCallback(
927
1047
  (element: TimelineElement): TimelineElement => {
928
1048
  if (resizingClip?.element.id === element.id) {
@@ -1008,6 +1128,57 @@ export const Timeline = memo(function Timeline({
1008
1128
  [onAssetDrop, onFileDrop],
1009
1129
  );
1010
1130
 
1131
+ const handlePinchWheel = useCallback(
1132
+ (e: WheelEvent) => {
1133
+ if (!e.ctrlKey) return;
1134
+ const scroll = scrollRef.current;
1135
+ if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
1136
+ return;
1137
+ }
1138
+
1139
+ e.preventDefault();
1140
+ e.stopPropagation();
1141
+
1142
+ const rect = scroll.getBoundingClientRect();
1143
+ const pointerX = e.clientX - rect.left;
1144
+ const nextZoomPercent = getPinchTimelineZoomPercent(
1145
+ e.deltaY,
1146
+ zoomModeRef.current,
1147
+ manualZoomPercentRef.current,
1148
+ );
1149
+ if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
1150
+ return;
1151
+ }
1152
+
1153
+ const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
1154
+ const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
1155
+ pointerX,
1156
+ currentScrollLeft: scroll.scrollLeft,
1157
+ gutter: GUTTER,
1158
+ currentPixelsPerSecond: ppsRef.current,
1159
+ nextPixelsPerSecond: nextPps,
1160
+ duration: durationRef.current,
1161
+ });
1162
+
1163
+ setZoomMode("manual");
1164
+ setManualZoomPercent(nextZoomPercent);
1165
+ requestAnimationFrame(() => {
1166
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
1167
+ scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
1168
+ });
1169
+ },
1170
+ [setManualZoomPercent, setZoomMode],
1171
+ );
1172
+
1173
+ useEffect(() => {
1174
+ const scroll = scrollRef.current;
1175
+ if (!scroll) return;
1176
+ scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
1177
+ return () => {
1178
+ scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
1179
+ };
1180
+ }, [handlePinchWheel, timelineReady, elements.length]);
1181
+
1011
1182
  if (!timelineReady || elements.length === 0) {
1012
1183
  return (
1013
1184
  <div
@@ -1096,7 +1267,6 @@ export const Timeline = memo(function Timeline({
1096
1267
  );
1097
1268
  }
1098
1269
 
1099
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1100
1270
  const draggedElement = draggedClip?.element ?? null;
1101
1271
  const activeDraggedElement =
1102
1272
  draggedClip?.started === true && draggedElement
@@ -1170,7 +1340,7 @@ export const Timeline = memo(function Timeline({
1170
1340
  <div
1171
1341
  ref={setContainerRef}
1172
1342
  aria-label="Timeline"
1173
- className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1343
+ className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1174
1344
  style={{
1175
1345
  touchAction: "pan-x pan-y",
1176
1346
  background: theme.shellBackground,
@@ -1239,7 +1409,7 @@ export const Timeline = memo(function Timeline({
1239
1409
  className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
1240
1410
  style={{ color: theme.tickText }}
1241
1411
  >
1242
- {formatTime(t)}
1412
+ {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
1243
1413
  </span>
1244
1414
  <div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
1245
1415
  </div>
@@ -1509,8 +1679,8 @@ export const Timeline = memo(function Timeline({
1509
1679
  </div>
1510
1680
  </div>
1511
1681
 
1512
- {/* Keyboard shortcut hint — always visible */}
1513
- {!showPopover && !rangeSelection && (
1682
+ {/* Keyboard shortcut hint */}
1683
+ {showShortcutHint && !showPopover && !rangeSelection && (
1514
1684
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1515
1685
  <div
1516
1686
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"