@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

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 (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
@@ -53,6 +53,7 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
+ Camera as PhCamera,
56
57
  Gear,
57
58
  } from "@phosphor-icons/react";
58
59
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
127
128
  export const ChevronRight = makeIcon(CaretRight);
128
129
  export const ClipboardList = makeIcon(ClipboardText);
129
130
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
131
+ export const Camera = makeIcon(PhCamera);
130
132
  export const Settings = makeIcon(Gear);
@@ -7,7 +7,6 @@ interface CompositionThumbnailProps {
7
7
  labelColor: string;
8
8
  accentColor?: string;
9
9
  selector?: string;
10
- selectorIndex?: number;
11
10
  seekTime?: number;
12
11
  duration?: number;
13
12
  width?: number;
@@ -17,44 +16,12 @@ interface CompositionThumbnailProps {
17
16
  const CLIP_HEIGHT = 66;
18
17
  const THUMBNAIL_URL_VERSION = "v2";
19
18
 
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
-
51
19
  export const CompositionThumbnail = memo(function CompositionThumbnail({
52
20
  previewUrl,
53
21
  label,
54
22
  labelColor,
55
23
  accentColor = "#6B7280",
56
24
  selector,
57
- selectorIndex,
58
25
  seekTime = 2,
59
26
  duration = 5,
60
27
  }: CompositionThumbnailProps) {
@@ -81,14 +48,15 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
81
48
  roRef.current?.disconnect();
82
49
  });
83
50
 
84
- const url = buildCompositionThumbnailUrl({
85
- previewUrl,
86
- seekTime,
87
- duration,
88
- selector,
89
- selectorIndex,
90
- origin: window.location.origin,
91
- });
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();
92
60
  const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
93
61
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
94
62
 
@@ -98,7 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
98
66
  src={url}
99
67
  alt=""
100
68
  draggable={false}
101
- loading="eager"
69
+ loading="lazy"
102
70
  onLoad={(e) => {
103
71
  const img = e.currentTarget;
104
72
  if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,7 +3,6 @@ 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";
7
6
 
8
7
  interface EditPopoverProps {
9
8
  rangeStart: number;
@@ -63,8 +62,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
63
62
  }, [start, end, elementsInRange, prompt]);
64
63
 
65
64
  const handleCopy = useCallback(async () => {
66
- const copied = await copyTextToClipboard(buildClipboardText());
67
- if (!copied) return;
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
+ }
68
75
  setCopiedAgentPrompt(true);
69
76
  setTimeout(() => {
70
77
  setCopiedAgentPrompt(false);
@@ -75,8 +82,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
75
82
  const handleCopyPrompt = useCallback(async () => {
76
83
  const promptText = buildPromptCopyText(prompt);
77
84
  if (!promptText) return;
78
- const copied = await copyTextToClipboard(promptText);
79
- if (!copied) 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
+ }
80
95
  setCopiedPromptOnly(true);
81
96
  setTimeout(() => {
82
97
  setCopiedPromptOnly(false);
@@ -1,37 +1,43 @@
1
- import { forwardRef, useRef, useState } from "react";
1
+ import { forwardRef, useEffect, useRef, useState } from "react";
2
+ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
2
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { HyperframesLoader } from "../../components/ui";
5
+ // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
6
+ // at module load, which throws under SSR. Defer the import to the mount effect
7
+ // so it only runs in the browser.
3
8
 
4
9
  interface PlayerProps {
5
10
  projectId?: string;
6
11
  directUrl?: string;
7
12
  onLoad: () => void;
8
13
  portrait?: boolean;
9
- style?: React.CSSProperties;
10
14
  }
11
15
 
12
16
  interface HyperframesPlayerElement extends HTMLElement {
13
17
  iframeElement: HTMLIFrameElement;
14
18
  }
15
19
 
16
- function enableInteractiveIframe(player: HyperframesPlayerElement): void {
17
- const root = player.shadowRoot;
18
- if (!root) return;
19
-
20
- const container = root.querySelector<HTMLElement>(".hfp-container");
21
- const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
22
-
23
- container?.style.setProperty("pointer-events", "auto");
24
- iframe?.style.setProperty("pointer-events", "auto");
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null;
25
22
  }
26
23
 
27
- function isLottieAnimationReady(anim: unknown): boolean {
28
- if (typeof anim !== "object" || anim === null) return true;
29
- const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
30
- if (maybe.isLoaded === true) return true;
31
- if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true;
32
- return false;
24
+ function getShaderTransitionLoading(event: Event): boolean | null {
25
+ if (!(event instanceof CustomEvent)) return null;
26
+ const detail: unknown = event.detail;
27
+ if (!isRecord(detail)) return null;
28
+ const state = detail.state;
29
+ if (!isRecord(state)) return null;
30
+ return state.loading === true && state.ready !== true;
33
31
  }
34
32
 
33
+ // Assets are considered ready when every `<video>`/`<audio>` has enough data
34
+ // to play through without buffering, and every registered Lottie animation has
35
+ // finished loading.
36
+ //
37
+ // Returns whichever value was returned last on cross-origin / transient DOM
38
+ // races so a brief access failure (e.g. an iframe that just swapped src)
39
+ // doesn't flicker the overlay state — we keep showing whatever was most
40
+ // recently true.
35
41
  function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
36
42
  try {
37
43
  const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
@@ -47,7 +53,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
47
53
  const lotties = win.__hfLottie;
48
54
  if (lotties?.length) {
49
55
  for (const anim of lotties) {
50
- if (!isLottieAnimationReady(anim)) return true;
56
+ if (!isLottieAnimationLoaded(anim)) return true;
51
57
  }
52
58
  }
53
59
 
@@ -57,11 +63,24 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
57
63
  }
58
64
  }
59
65
 
66
+ /**
67
+ * Renders a composition preview using the <hyperframes-player> web component.
68
+ *
69
+ * The web component handles iframe scaling, dimension detection, and
70
+ * ResizeObserver internally. This wrapper bridges its inner iframe to the
71
+ * forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
72
+ * timeline probing, and DOM inspection.
73
+ */
60
74
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
61
- ({ projectId, directUrl, onLoad, portrait, style }, ref) => {
75
+ ({ projectId, directUrl, onLoad, portrait }, ref) => {
62
76
  const containerRef = useRef<HTMLDivElement>(null);
77
+ const loadCountRef = useRef(0);
63
78
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
79
+ const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
80
  const [assetsLoading, setAssetsLoading] = useState(false);
81
+ const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
82
+ const [assetOverlayFading, setAssetOverlayFading] = useState(false);
83
+ const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
65
84
 
66
85
  useMountEffect(() => {
67
86
  const container = containerRef.current;
@@ -70,11 +89,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
70
89
  let canceled = false;
71
90
  let cleanup: (() => void) | undefined;
72
91
 
92
+ // Dynamic import registers the custom element in the browser only.
73
93
  import("@hyperframes/player").then(() => {
74
94
  if (canceled) return;
75
95
 
96
+ // Create the web component imperatively to avoid JSX custom-element typing.
76
97
  const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
77
98
  const src = directUrl || `/api/projects/${projectId}/preview`;
99
+ player.setAttribute("shader-capture-scale", "1");
100
+ player.setAttribute("shader-loading", "player");
78
101
  player.setAttribute("src", src);
79
102
  player.setAttribute("width", String(portrait ? 1080 : 1920));
80
103
  player.setAttribute("height", String(portrait ? 1920 : 1080));
@@ -82,8 +105,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
82
105
  player.style.height = "100%";
83
106
  player.style.display = "block";
84
107
  container.appendChild(player);
85
- enableInteractiveIframe(player);
86
108
 
109
+ // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
87
110
  const iframe = player.iframeElement;
88
111
  if (typeof ref === "function") {
89
112
  ref(iframe);
@@ -91,12 +114,42 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
91
114
  (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
92
115
  }
93
116
 
117
+ // Prevent the web component's built-in click-to-toggle behavior.
118
+ // The studio manages playback exclusively via useTimelinePlayer.
94
119
  const preventToggle = (e: Event) => e.stopImmediatePropagation();
95
120
  player.addEventListener("click", preventToggle, { capture: true });
96
121
 
122
+ const handleShaderTransitionState = (event: Event) => {
123
+ const loading = getShaderTransitionLoading(event);
124
+ if (loading !== null) setShaderTransitionLoading(loading);
125
+ };
126
+ player.addEventListener("shadertransitionstate", handleShaderTransitionState);
127
+
128
+ // Forward the iframe's native load event to the studio's onIframeLoad.
97
129
  const handleLoad = () => {
130
+ loadCountRef.current++;
131
+ setShaderTransitionLoading(false);
132
+ // Reveal animation on reload (hot-reload, composition switch)
133
+ if (loadCountRef.current > 1) {
134
+ container.classList.remove("preview-revealing");
135
+ void container.offsetWidth;
136
+ container.classList.add("preview-revealing");
137
+ const onEnd = () => container.classList.remove("preview-revealing");
138
+ container.addEventListener("animationend", onEnd, { once: true });
139
+ }
98
140
  onLoad();
99
141
 
142
+ // Show a loading overlay until every `<video>`/`<audio>` and Lottie
143
+ // asset is ready. Without this users can click play before audio has
144
+ // buffered — the runtime is resilient (queued play() resolves once
145
+ // data arrives), but the overlay communicates why the first frame
146
+ // or first audio beat may lag.
147
+ //
148
+ // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
149
+ // trips we hide the overlay so the UI doesn't appear stuck forever,
150
+ // but we log a debug warning so the case is diagnosable — a long
151
+ // cold video or a broken asset can legitimately exceed 10 s on a
152
+ // slow network.
100
153
  if (assetPollRef.current) clearInterval(assetPollRef.current);
101
154
  let lastUnloaded = hasUnloadedAssets(iframe, false);
102
155
  if (lastUnloaded) {
@@ -109,6 +162,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
109
162
  if (assetPollRef.current) clearInterval(assetPollRef.current);
110
163
  assetPollRef.current = null;
111
164
  setAssetsLoading(false);
165
+ if (lastUnloaded) {
166
+ console.debug(
167
+ "[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
168
+ );
169
+ }
112
170
  }
113
171
  }, 100);
114
172
  } else {
@@ -120,9 +178,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
120
178
  cleanup = () => {
121
179
  iframe.removeEventListener("load", handleLoad);
122
180
  player.removeEventListener("click", preventToggle, { capture: true });
181
+ player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
123
182
  if (assetPollRef.current) clearInterval(assetPollRef.current);
124
183
  assetPollRef.current = null;
125
184
  container.removeChild(player);
185
+ // Clear the forwarded ref
126
186
  if (typeof ref === "function") {
127
187
  ref(null);
128
188
  } else if (ref) {
@@ -137,16 +197,57 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
137
197
  };
138
198
  });
139
199
 
200
+ useEffect(() => {
201
+ if (assetFadeRef.current) {
202
+ clearTimeout(assetFadeRef.current);
203
+ assetFadeRef.current = null;
204
+ }
205
+
206
+ if (assetsLoading) {
207
+ setAssetOverlayVisible(true);
208
+ setAssetOverlayFading(false);
209
+ return;
210
+ }
211
+
212
+ setAssetOverlayFading(true);
213
+ assetFadeRef.current = setTimeout(() => {
214
+ setAssetOverlayVisible(false);
215
+ setAssetOverlayFading(false);
216
+ assetFadeRef.current = null;
217
+ }, 240);
218
+
219
+ return () => {
220
+ if (assetFadeRef.current) {
221
+ clearTimeout(assetFadeRef.current);
222
+ assetFadeRef.current = null;
223
+ }
224
+ };
225
+ }, [assetsLoading]);
226
+
227
+ const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
228
+
140
229
  return (
141
- <div
142
- className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
143
- style={style}
144
- >
230
+ <div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
145
231
  <div ref={containerRef} className="w-full h-full" />
146
- {assetsLoading && (
147
- <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
148
- <div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
149
- <span className="text-white/60 text-xs mt-3">Loading assets…</span>
232
+ {showAssetOverlay && (
233
+ <div
234
+ className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
235
+ data-hyperframes-ignore=""
236
+ draggable={false}
237
+ style={{
238
+ opacity: assetOverlayFading ? 0 : 1,
239
+ pointerEvents: assetOverlayFading ? "none" : "auto",
240
+ transition: "opacity 240ms ease-out",
241
+ }}
242
+ onDragStart={(event) => event.preventDefault()}
243
+ onMouseDown={(event) => event.preventDefault()}
244
+ onPointerDown={(event) => event.preventDefault()}
245
+ >
246
+ <HyperframesLoader
247
+ title="Preparing preview assets"
248
+ detail="Waiting for media and motion assets before playback starts."
249
+ size={56}
250
+ />
150
251
  </div>
151
252
  )}
152
253
  </div>
@@ -1,10 +1,6 @@
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 { formatFrameTime, frameToSeconds, 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;
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
30
26
  interface PlayerControlsProps {
31
27
  onTogglePlay: () => void;
32
28
  onSeek: (time: number) => void;
33
- timelineVisible?: boolean;
34
- onToggleTimeline?: () => void;
35
29
  }
36
30
 
37
31
  export const PlayerControls = memo(function PlayerControls({
38
32
  onTogglePlay,
39
33
  onSeek,
40
- timelineVisible,
41
- onToggleTimeline,
42
34
  }: PlayerControlsProps) {
43
35
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
44
36
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
216
208
  const step = e.shiftKey ? 10 : 1;
217
209
  if (e.key === "ArrowLeft") {
218
210
  e.preventDefault();
219
- onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
220
212
  } else if (e.key === "ArrowRight") {
221
213
  e.preventDefault();
222
- onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
214
+ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
223
215
  }
224
216
  },
225
217
  [timelineReady, duration, onSeek],
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
437
429
  </span>
438
430
  ))}
439
431
  </div>
440
-
441
- {/* Timeline toggle */}
442
- {onToggleTimeline !== undefined && (
443
- <button
444
- type="button"
445
- onClick={onToggleTimeline}
446
- className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
447
- timelineVisible
448
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
449
- : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
450
- }`}
451
- title={getTimelineToggleTitle(Boolean(timelineVisible))}
452
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
453
- >
454
- <svg
455
- width="13"
456
- height="13"
457
- viewBox="0 0 24 24"
458
- fill="none"
459
- stroke="currentColor"
460
- strokeWidth="2"
461
- strokeLinecap="round"
462
- >
463
- <rect x="3" y="13" width="18" height="8" rx="1" />
464
- <line x1="3" y1="9" x2="21" y2="9" />
465
- <line x1="3" y1="5" x2="21" y2="5" />
466
- </svg>
467
- <span>Timeline</span>
468
- <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
469
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
470
- </span>
471
- </button>
472
- )}
473
432
  </div>
474
433
  );
475
434
  });
@@ -8,7 +8,6 @@ import {
8
8
  getTimelinePlayheadLeft,
9
9
  getTimelineScrollLeftForZoomAnchor,
10
10
  getTimelineScrollLeftForZoomTransition,
11
- shouldShowTimelineShortcutHint,
12
11
  shouldHandleTimelineDeleteKey,
13
12
  shouldAutoScrollTimeline,
14
13
  } from "./Timeline";
@@ -238,17 +237,6 @@ describe("getTimelineCanvasHeight", () => {
238
237
  });
239
238
  });
240
239
 
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
-
252
240
  describe("shouldHandleTimelineDeleteKey", () => {
253
241
  it("handles Delete and Backspace when focus is not in an editor", () => {
254
242
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
@@ -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 = 20;
38
+ const TIMELINE_SCROLL_BUFFER = 24;
39
39
 
40
40
  interface TrackVisualStyle extends TimelineTrackStyle {
41
41
  icon: ReactNode;
@@ -216,14 +216,6 @@ export function getTimelineCanvasHeight(trackCount: number): number {
216
216
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
217
217
  }
218
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
-
227
219
  export function shouldHandleTimelineDeleteKey(input: {
228
220
  key: string;
229
221
  metaKey?: boolean;
@@ -287,6 +279,7 @@ export function resolveTimelineAssetDrop(
287
279
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
288
280
  };
289
281
  }
282
+
290
283
  /* ── Component ──────────────────────────────────────────────────── */
291
284
  interface TimelineProps {
292
285
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -434,51 +427,30 @@ export const Timeline = memo(function Timeline({
434
427
  onDeleteElementRef.current = onDeleteElement;
435
428
  const suppressClickRef = useRef(false);
436
429
  const [showPopover, setShowPopover] = useState(false);
437
- const [showShortcutHint, setShowShortcutHint] = useState(true);
438
430
  const [viewportWidth, setViewportWidth] = useState(0);
439
431
  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]);
454
432
 
455
433
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
456
434
  // useMountEffect can't work here because the component returns null on first
457
435
  // render (timelineReady=false), so containerRef.current is null when the
458
436
  // effect fires and the ResizeObserver is never created.
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
- );
437
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
438
+ if (roRef.current) {
439
+ roRef.current.disconnect();
440
+ roRef.current = null;
441
+ }
442
+ containerRef.current = el;
443
+ if (!el) return;
444
+ setViewportWidth(el.clientWidth);
445
+ roRef.current = new ResizeObserver(([entry]) => {
446
+ setViewportWidth(entry.contentRect.width);
447
+ });
448
+ roRef.current.observe(el);
449
+ }, []);
477
450
 
478
451
  // Clean up ResizeObserver on unmount
479
452
  useMountEffect(() => () => {
480
453
  roRef.current?.disconnect();
481
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
482
454
  });
483
455
 
484
456
  // Effective duration: max of store duration and the furthest element end.
@@ -523,7 +495,6 @@ export const Timeline = memo(function Timeline({
523
495
  }
524
496
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
525
497
  }, [draggedClip, trackOrder]);
526
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
527
498
  const selectedElement = useMemo(
528
499
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
529
500
  [elements, selectedElementId],
@@ -573,6 +544,7 @@ export const Timeline = memo(function Timeline({
573
544
  );
574
545
  previousZoomModeRef.current = zoomMode;
575
546
  }, [zoomMode]);
547
+
576
548
  useMountEffect(() => {
577
549
  const unsub = liveTime.subscribe((t) => {
578
550
  const dur = durationRef.current;
@@ -1040,12 +1012,12 @@ export const Timeline = memo(function Timeline({
1040
1012
  );
1041
1013
  const majorTickInterval =
1042
1014
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1043
- useEffect(() => {
1044
- syncShortcutHintVisibility();
1045
- }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
1046
1015
  const getPreviewElement = useCallback(
1047
1016
  (element: TimelineElement): TimelineElement => {
1048
- if (resizingClip?.element.id === element.id) {
1017
+ if (
1018
+ resizingClip &&
1019
+ (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
1020
+ ) {
1049
1021
  return {
1050
1022
  ...element,
1051
1023
  start: resizingClip.previewStart,
@@ -1267,12 +1239,13 @@ export const Timeline = memo(function Timeline({
1267
1239
  );
1268
1240
  }
1269
1241
 
1242
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1270
1243
  const draggedElement = draggedClip?.element ?? null;
1271
1244
  const activeDraggedElement =
1272
1245
  draggedClip?.started === true && draggedElement
1273
1246
  ? getRenderedTimelineElement({
1274
1247
  element: draggedElement,
1275
- draggedElementId: draggedElement.id,
1248
+ draggedElementId: draggedElement.key ?? draggedElement.id,
1276
1249
  previewStart: draggedClip.previewStart,
1277
1250
  previewTrack: draggedClip.previewTrack,
1278
1251
  })
@@ -1340,7 +1313,7 @@ export const Timeline = memo(function Timeline({
1340
1313
  <div
1341
1314
  ref={setContainerRef}
1342
1315
  aria-label="Timeline"
1343
- className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1316
+ className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1344
1317
  style={{
1345
1318
  touchAction: "pan-x pan-y",
1346
1319
  background: theme.shellBackground,
@@ -1679,8 +1652,8 @@ export const Timeline = memo(function Timeline({
1679
1652
  </div>
1680
1653
  </div>
1681
1654
 
1682
- {/* Keyboard shortcut hint */}
1683
- {showShortcutHint && !showPopover && !rangeSelection && (
1655
+ {/* Keyboard shortcut hint — always visible */}
1656
+ {!showPopover && !rangeSelection && (
1684
1657
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1685
1658
  <div
1686
1659
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"