@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11

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.
Files changed (53) hide show
  1. package/dist/assets/index-Bl4Deziq.js +105 -0
  2. package/dist/assets/index-KioPDrX6.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +494 -185
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLELayout.tsx +43 -8
  15. package/src/components/nle/NLEPreview.tsx +5 -1
  16. package/src/components/sidebar/AssetsTab.tsx +3 -4
  17. package/src/components/sidebar/LeftSidebar.tsx +64 -36
  18. package/src/hooks/usePersistentEditHistory.test.ts +255 -0
  19. package/src/hooks/usePersistentEditHistory.ts +336 -0
  20. package/src/icons/SystemIcons.tsx +4 -0
  21. package/src/player/components/AudioWaveform.tsx +44 -29
  22. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  23. package/src/player/components/CompositionThumbnail.tsx +42 -10
  24. package/src/player/components/EditModal.tsx +5 -20
  25. package/src/player/components/PlayerControls.tsx +117 -49
  26. package/src/player/components/Timeline.test.ts +84 -0
  27. package/src/player/components/Timeline.tsx +198 -27
  28. package/src/player/components/timelineEditing.test.ts +2 -2
  29. package/src/player/components/timelineEditing.ts +1 -1
  30. package/src/player/components/timelineTheme.ts +3 -3
  31. package/src/player/components/timelineZoom.test.ts +21 -0
  32. package/src/player/components/timelineZoom.ts +11 -0
  33. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  34. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  35. package/src/player/lib/time.test.ts +29 -1
  36. package/src/player/lib/time.ts +26 -0
  37. package/src/player/store/playerStore.test.ts +11 -1
  38. package/src/player/store/playerStore.ts +5 -1
  39. package/src/styles/studio.css +9 -0
  40. package/src/utils/clipboard.test.ts +88 -0
  41. package/src/utils/clipboard.ts +57 -0
  42. package/src/utils/editHistory.test.ts +244 -0
  43. package/src/utils/editHistory.ts +218 -0
  44. package/src/utils/editHistoryStorage.test.ts +37 -0
  45. package/src/utils/editHistoryStorage.ts +99 -0
  46. package/src/utils/frameCapture.test.ts +26 -0
  47. package/src/utils/frameCapture.ts +38 -0
  48. package/src/utils/studioFileHistory.test.ts +156 -0
  49. package/src/utils/studioFileHistory.ts +61 -0
  50. package/src/utils/timelineAssetDrop.test.ts +64 -4
  51. package/src/utils/timelineAssetDrop.ts +27 -5
  52. package/dist/assets/index-Bi30tos-.js +0 -105
  53. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -53,6 +53,8 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
+ Camera as PhCamera,
57
+ ArrowClockwise,
56
58
  Gear,
57
59
  } from "@phosphor-icons/react";
58
60
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +129,6 @@ export const ChevronDown = makeIcon(CaretDown);
127
129
  export const ChevronRight = makeIcon(CaretRight);
128
130
  export const ClipboardList = makeIcon(ClipboardText);
129
131
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
132
+ export const Camera = makeIcon(PhCamera);
133
+ export const RotateCw = makeIcon(ArrowClockwise);
130
134
  export const Settings = makeIcon(Gear);
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
2
 
3
3
  interface AudioWaveformProps {
4
4
  audioUrl: string;
5
+ waveformUrl?: string;
5
6
  label: string;
6
7
  labelColor: string;
7
8
  }
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
49
50
 
50
51
  // Module-level cache so decoded audio persists across re-renders and re-mounts
51
52
  const peaksCache = new Map<string, number[]>();
53
+ const decodeInFlight = new Map<string, Promise<number[]>>();
52
54
 
53
55
  /**
54
56
  * Audio waveform rendered from real PCM data via Web Audio API.
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
57
59
  */
58
60
  export const AudioWaveform = memo(function AudioWaveform({
59
61
  audioUrl,
62
+ waveformUrl,
60
63
  label,
61
64
  labelColor,
62
65
  }: AudioWaveformProps) {
63
66
  const containerRef = useRef<HTMLDivElement | null>(null);
64
67
  const barsRef = useRef<HTMLDivElement | null>(null);
65
68
  const roRef = useRef<ResizeObserver | null>(null);
66
- const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
69
+ const cacheKey = waveformUrl ?? audioUrl;
70
+ const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
67
71
 
68
- // Fetch + decode audio once
69
72
  useEffect(() => {
70
- if (peaks || !audioUrl) return;
71
-
72
- const ctrl = new AbortController();
73
- fetch(audioUrl, { signal: ctrl.signal })
74
- .then((r) => r.arrayBuffer())
75
- .then((buf) => {
76
- const ctx = new AudioContext();
77
- return ctx.decodeAudioData(buf).finally(() => ctx.close());
78
- })
79
- .then((decoded) => {
80
- if (ctrl.signal.aborted) return;
81
- const channel = decoded.getChannelData(0);
82
- // Extract enough peaks for wide clips (up to 4000 bars)
83
- const p = extractPeaks(channel, 4000);
84
- peaksCache.set(audioUrl, p);
85
- setPeaks(p);
86
- })
87
- .catch(() => {
88
- if (ctrl.signal.aborted) return;
89
- // Fallback to fake waveform
90
- const p = fakePeaks(audioUrl, 4000);
91
- peaksCache.set(audioUrl, p);
92
- setPeaks(p);
93
- });
94
-
95
- return () => ctrl.abort();
96
- }, [audioUrl, peaks]);
73
+ if (peaks || !cacheKey) return;
74
+
75
+ let cancelled = false;
76
+
77
+ let promise = decodeInFlight.get(cacheKey);
78
+ if (!promise) {
79
+ promise = (
80
+ waveformUrl
81
+ ? fetch(waveformUrl)
82
+ .then((r) => r.json())
83
+ .then((d: { peaks?: number[] }) => {
84
+ if (!Array.isArray(d.peaks)) throw new Error("bad response");
85
+ return d.peaks;
86
+ })
87
+ : fetch(audioUrl)
88
+ .then((r) => r.arrayBuffer())
89
+ .then((buf) => {
90
+ const ctx = new AudioContext();
91
+ return ctx.decodeAudioData(buf).finally(() => ctx.close());
92
+ })
93
+ .then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
94
+ )
95
+ .catch(() => fakePeaks(cacheKey, 4000))
96
+ .then((p) => {
97
+ peaksCache.set(cacheKey, p);
98
+ return p;
99
+ })
100
+ .finally(() => decodeInFlight.delete(cacheKey));
101
+
102
+ decodeInFlight.set(cacheKey, promise);
103
+ }
104
+
105
+ promise.then((p) => {
106
+ if (!cancelled) setPeaks(p);
107
+ });
108
+ return () => {
109
+ cancelled = true;
110
+ };
111
+ }, [audioUrl, waveformUrl, cacheKey, peaks]);
97
112
 
98
113
  // Draw bars into the container using innerHTML (fast, zoom-resilient)
99
114
  const draw = useCallback(() => {
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
3
+
4
+ describe("buildCompositionThumbnailUrl", () => {
5
+ it("includes selector and occurrence index for precise element thumbnails", () => {
6
+ expect(
7
+ buildCompositionThumbnailUrl({
8
+ previewUrl: "/api/projects/demo/preview",
9
+ seekTime: 1,
10
+ duration: 2,
11
+ selector: ".card",
12
+ selectorIndex: 2,
13
+ origin: "http://localhost:3000",
14
+ }),
15
+ ).toBe(
16
+ "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
17
+ );
18
+ });
19
+ });
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
7
7
  labelColor: string;
8
8
  accentColor?: string;
9
9
  selector?: string;
10
+ selectorIndex?: number;
10
11
  seekTime?: number;
11
12
  duration?: number;
12
13
  width?: number;
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
16
17
  const CLIP_HEIGHT = 66;
17
18
  const THUMBNAIL_URL_VERSION = "v2";
18
19
 
20
+ export function buildCompositionThumbnailUrl({
21
+ previewUrl,
22
+ seekTime = 2,
23
+ duration = 5,
24
+ selector,
25
+ selectorIndex,
26
+ origin,
27
+ }: {
28
+ previewUrl: string;
29
+ seekTime?: number;
30
+ duration?: number;
31
+ selector?: string;
32
+ selectorIndex?: number;
33
+ origin: string;
34
+ }): string {
35
+ const thumbnailBase = previewUrl
36
+ .replace("/preview/comp/", "/thumbnail/")
37
+ .replace(/\/preview$/, "/thumbnail/index.html");
38
+ const midTime = seekTime + duration / 2;
39
+ const thumbnailUrl = new URL(thumbnailBase, origin);
40
+ thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
41
+ thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
42
+ if (selector) {
43
+ thumbnailUrl.searchParams.set("selector", selector);
44
+ if (selectorIndex != null && selectorIndex > 0) {
45
+ thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
46
+ }
47
+ }
48
+ return thumbnailUrl.toString();
49
+ }
50
+
19
51
  export const CompositionThumbnail = memo(function CompositionThumbnail({
20
52
  previewUrl,
21
53
  label,
22
54
  labelColor,
23
55
  accentColor = "#6B7280",
24
56
  selector,
57
+ selectorIndex,
25
58
  seekTime = 2,
26
59
  duration = 5,
27
60
  }: CompositionThumbnailProps) {
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
48
81
  roRef.current?.disconnect();
49
82
  });
50
83
 
51
- const thumbnailBase = previewUrl
52
- .replace("/preview/comp/", "/thumbnail/")
53
- .replace(/\/preview$/, "/thumbnail/index.html");
54
- const midTime = seekTime + duration / 2;
55
- const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
- thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
- thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
- if (selector) thumbnailUrl.searchParams.set("selector", selector);
59
- const url = thumbnailUrl.toString();
84
+ const url = buildCompositionThumbnailUrl({
85
+ previewUrl,
86
+ seekTime,
87
+ duration,
88
+ selector,
89
+ selectorIndex,
90
+ origin: window.location.origin,
91
+ });
60
92
  const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
61
93
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
62
94
 
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
66
98
  src={url}
67
99
  alt=""
68
100
  draggable={false}
69
- loading="lazy"
101
+ loading="eager"
70
102
  onLoad={(e) => {
71
103
  const img = e.currentTarget;
72
104
  if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { usePlayerStore } from "../store/playerStore";
4
4
  import { formatTime } from "../lib/time";
5
5
  import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
6
+ import { copyTextToClipboard } from "../../utils/clipboard";
6
7
 
7
8
  interface EditPopoverProps {
8
9
  rangeStart: number;
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
62
63
  }, [start, end, elementsInRange, prompt]);
63
64
 
64
65
  const handleCopy = useCallback(async () => {
65
- try {
66
- await navigator.clipboard.writeText(buildClipboardText());
67
- } catch {
68
- const ta = document.createElement("textarea");
69
- ta.value = buildClipboardText();
70
- document.body.appendChild(ta);
71
- ta.select();
72
- document.execCommand("copy");
73
- document.body.removeChild(ta);
74
- }
66
+ const copied = await copyTextToClipboard(buildClipboardText());
67
+ if (!copied) return;
75
68
  setCopiedAgentPrompt(true);
76
69
  setTimeout(() => {
77
70
  setCopiedAgentPrompt(false);
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
82
75
  const handleCopyPrompt = useCallback(async () => {
83
76
  const promptText = buildPromptCopyText(prompt);
84
77
  if (!promptText) return;
85
- try {
86
- await navigator.clipboard.writeText(promptText);
87
- } catch {
88
- const ta = document.createElement("textarea");
89
- ta.value = promptText;
90
- document.body.appendChild(ta);
91
- ta.select();
92
- document.execCommand("copy");
93
- document.body.removeChild(ta);
94
- }
78
+ const copied = await copyTextToClipboard(promptText);
79
+ if (!copied) return;
95
80
  setCopiedPromptOnly(true);
96
81
  setTimeout(() => {
97
82
  setCopiedPromptOnly(false);
@@ -1,14 +1,17 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import {
4
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
- getTimelineToggleTitle,
6
- } from "../../utils/timelineDiscovery";
7
- import { formatTime } from "../lib/time";
3
+ import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
8
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
5
 
10
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
11
7
  const SEEK_EDGE_SNAP_PX = 8;
8
+ type TimeDisplayMode = "time" | "frame";
9
+ const SHORTCUT_HINTS = [
10
+ { key: "J", label: "Play backward" },
11
+ { key: "K", label: "Stop playback" },
12
+ { key: "L", label: "Play forward" },
13
+ { key: "←/→", label: "Step one frame backward or forward" },
14
+ ] as const;
12
15
 
13
16
  export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
14
17
  if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
@@ -23,23 +26,23 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
23
26
  interface PlayerControlsProps {
24
27
  onTogglePlay: () => void;
25
28
  onSeek: (time: number) => void;
26
- timelineVisible?: boolean;
27
- onToggleTimeline?: () => void;
28
29
  }
29
30
 
30
31
  export const PlayerControls = memo(function PlayerControls({
31
32
  onTogglePlay,
32
33
  onSeek,
33
- timelineVisible,
34
- onToggleTimeline,
35
34
  }: PlayerControlsProps) {
36
35
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
37
36
  const isPlaying = usePlayerStore((s) => s.isPlaying);
38
37
  const duration = usePlayerStore((s) => s.duration);
39
38
  const timelineReady = usePlayerStore((s) => s.timelineReady);
40
39
  const playbackRate = usePlayerStore((s) => s.playbackRate);
40
+ const loopEnabled = usePlayerStore((s) => s.loopEnabled);
41
41
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
42
+ const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
42
43
  const [showSpeedMenu, setShowSpeedMenu] = useState(false);
44
+ const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
45
+ const [jumpFrame, setJumpFrame] = useState("");
43
46
 
44
47
  const progressFillRef = useRef<HTMLDivElement>(null);
45
48
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +52,8 @@ export const PlayerControls = memo(function PlayerControls({
49
52
  const speedMenuContainerRef = useRef<HTMLDivElement>(null);
50
53
  const isDraggingRef = useRef(false);
51
54
  const currentTimeRef = useRef(0);
55
+ const timeDisplayModeRef = useRef(timeDisplayMode);
56
+ timeDisplayModeRef.current = timeDisplayMode;
52
57
 
53
58
  const durationRef = useRef(duration);
54
59
  durationRef.current = duration;
@@ -59,7 +64,10 @@ export const PlayerControls = memo(function PlayerControls({
59
64
  const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
60
65
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
61
66
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
62
- if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
67
+ if (timeDisplayRef.current) {
68
+ timeDisplayRef.current.textContent =
69
+ timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
70
+ }
63
71
  if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
64
72
  };
65
73
  const unsub = liveTime.subscribe(updateProgress);
@@ -82,6 +90,13 @@ export const PlayerControls = memo(function PlayerControls({
82
90
  };
83
91
  });
84
92
 
93
+ useEffect(() => {
94
+ if (!timeDisplayRef.current) return;
95
+ const t = currentTimeRef.current;
96
+ timeDisplayRef.current.textContent =
97
+ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
98
+ }, [duration, timeDisplayMode]);
99
+
85
100
  useEffect(() => {
86
101
  if (!showSpeedMenu) return;
87
102
  const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +205,44 @@ export const PlayerControls = memo(function PlayerControls({
190
205
  const handleKeyDown = useCallback(
191
206
  (e: React.KeyboardEvent) => {
192
207
  if (!timelineReady || duration <= 0) return;
193
- const step = e.shiftKey ? 5 : 1;
208
+ const step = e.shiftKey ? 10 : 1;
194
209
  if (e.key === "ArrowLeft") {
195
210
  e.preventDefault();
196
- onSeek(Math.max(0, currentTimeRef.current - step));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
197
212
  } else if (e.key === "ArrowRight") {
198
213
  e.preventDefault();
199
- onSeek(Math.min(duration, currentTimeRef.current + step));
214
+ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
200
215
  }
201
216
  },
202
217
  [timelineReady, duration, onSeek],
203
218
  );
204
219
 
220
+ const commitJumpFrame = useCallback(() => {
221
+ const frame = Number.parseInt(jumpFrame, 10);
222
+ if (!Number.isFinite(frame) || duration <= 0) return;
223
+ onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
224
+ }, [duration, jumpFrame, onSeek]);
225
+
226
+ const handleJumpSubmit = useCallback(
227
+ (e: React.FormEvent) => {
228
+ e.preventDefault();
229
+ commitJumpFrame();
230
+ },
231
+ [commitJumpFrame],
232
+ );
233
+
234
+ const handleJumpKeyDown = useCallback(
235
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
236
+ if (e.key !== "Enter") return;
237
+ e.preventDefault();
238
+ commitJumpFrame();
239
+ },
240
+ [commitJumpFrame],
241
+ );
242
+
205
243
  return (
206
244
  <div
207
- className="px-4 py-2 flex items-center gap-3"
245
+ className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
208
246
  style={{
209
247
  borderTop: "1px solid rgba(255,255,255,0.04)",
210
248
  // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -236,12 +274,16 @@ export const PlayerControls = memo(function PlayerControls({
236
274
 
237
275
  {/* Time display */}
238
276
  <span
239
- className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
277
+ className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
240
278
  style={{ color: "#A1A1AA" }}
241
279
  >
242
280
  <span ref={timeDisplayRef}>{formatTime(0)}</span>
243
- <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
244
- <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
281
+ {timeDisplayMode === "time" ? (
282
+ <>
283
+ <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
284
+ <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
285
+ </>
286
+ ) : null}
245
287
  </span>
246
288
 
247
289
  {/* Seek bar — teal progress fill */}
@@ -256,7 +298,7 @@ export const PlayerControls = memo(function PlayerControls({
256
298
  aria-valuemin={0}
257
299
  aria-valuemax={Math.round(duration)}
258
300
  aria-valuenow={0}
259
- className="flex-1 h-6 flex items-center cursor-pointer group"
301
+ className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
260
302
  // `touch-action: none` tells the browser we're handling every
261
303
  // pointer gesture on this element ourselves. Without it, iOS
262
304
  // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +334,7 @@ export const PlayerControls = memo(function PlayerControls({
292
334
  <button
293
335
  type="button"
294
336
  onClick={() => setShowSpeedMenu((v) => !v)}
295
- className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
337
+ className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
296
338
  style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
297
339
  >
298
340
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,38 +371,64 @@ export const PlayerControls = memo(function PlayerControls({
329
371
  )}
330
372
  </div>
331
373
 
332
- {/* Timeline toggle */}
333
- {onToggleTimeline !== undefined && (
334
- <button
335
- type="button"
336
- onClick={onToggleTimeline}
337
- className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
338
- timelineVisible
339
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
340
- : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
341
- }`}
342
- title={getTimelineToggleTitle(Boolean(timelineVisible))}
343
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
344
- >
345
- <svg
346
- width="13"
347
- height="13"
348
- viewBox="0 0 24 24"
349
- fill="none"
350
- stroke="currentColor"
351
- strokeWidth="2"
352
- strokeLinecap="round"
374
+ <button
375
+ type="button"
376
+ onClick={() => setLoopEnabled(!loopEnabled)}
377
+ className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
378
+ loopEnabled
379
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
380
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
381
+ }`}
382
+ title="Loop playback"
383
+ aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
384
+ aria-pressed={loopEnabled}
385
+ >
386
+ Loop
387
+ </button>
388
+
389
+ <button
390
+ type="button"
391
+ onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
392
+ 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"
393
+ title="Toggle time/frame display"
394
+ aria-label="Toggle time and frame display"
395
+ >
396
+ {timeDisplayMode === "time" ? "m:ss" : "frames"}
397
+ </button>
398
+
399
+ <form
400
+ onSubmit={handleJumpSubmit}
401
+ className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
402
+ >
403
+ <input
404
+ value={jumpFrame}
405
+ onChange={(e) => setJumpFrame(e.target.value)}
406
+ inputMode="numeric"
407
+ pattern="[0-9]*"
408
+ aria-label="Jump to frame"
409
+ placeholder="frame"
410
+ 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"
411
+ onKeyDown={handleJumpKeyDown}
412
+ onBlur={commitJumpFrame}
413
+ />
414
+ </form>
415
+
416
+ <div
417
+ className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
418
+ aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
419
+ >
420
+ {SHORTCUT_HINTS.map((shortcut) => (
421
+ <span
422
+ key={shortcut.key}
423
+ className="group relative rounded border border-neutral-800 px-1 py-0.5"
353
424
  >
354
- <rect x="3" y="13" width="18" height="8" rx="1" />
355
- <line x1="3" y1="9" x2="21" y2="9" />
356
- <line x1="3" y1="5" x2="21" y2="5" />
357
- </svg>
358
- <span>Timeline</span>
359
- <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
360
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
425
+ {shortcut.key}
426
+ <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">
427
+ {shortcut.label}
428
+ </span>
361
429
  </span>
362
- </button>
363
- )}
430
+ ))}
431
+ </div>
364
432
  </div>
365
433
  );
366
434
  });
@@ -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);