@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

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 (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -10,13 +10,19 @@ interface PlayerProps {
10
10
  projectId?: string;
11
11
  directUrl?: string;
12
12
  onLoad: () => void;
13
+ onCompositionLoadingChange?: (loading: boolean) => void;
13
14
  portrait?: boolean;
15
+ style?: React.CSSProperties;
16
+ suppressLoadingOverlay?: boolean;
14
17
  }
15
18
 
16
19
  interface HyperframesPlayerElement extends HTMLElement {
17
20
  iframeElement: HTMLIFrameElement;
18
21
  }
19
22
 
23
+ const MEDIA_HAVE_FUTURE_DATA = 3;
24
+ const MEDIA_NETWORK_NO_SOURCE = 3;
25
+
20
26
  function isRecord(value: unknown): value is Record<string, unknown> {
21
27
  return typeof value === "object" && value !== null;
22
28
  }
@@ -30,6 +36,26 @@ function getShaderTransitionLoading(event: Event): boolean | null {
30
36
  return state.loading === true && state.ready !== true;
31
37
  }
32
38
 
39
+ export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
40
+ return compositionLoading;
41
+ }
42
+
43
+ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
44
+ const root = player.shadowRoot;
45
+ if (!root) return;
46
+
47
+ const container = root.querySelector<HTMLElement>(".hfp-container");
48
+ const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
49
+
50
+ container?.style.setProperty("pointer-events", "auto");
51
+ iframe?.style.setProperty("pointer-events", "auto");
52
+ }
53
+
54
+ function isPreviewMediaElement(el: Element): el is HTMLMediaElement {
55
+ const tagName = el.tagName.toLowerCase();
56
+ return tagName === "video" || tagName === "audio";
57
+ }
58
+
33
59
  // Assets are considered ready when every `<video>`/`<audio>` has enough data
34
60
  // to play through without buffering, and every registered Lottie animation has
35
61
  // finished loading.
@@ -38,14 +64,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
38
64
  // races so a brief access failure (e.g. an iframe that just swapped src)
39
65
  // doesn't flicker the overlay state — we keep showing whatever was most
40
66
  // recently true.
41
- function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
67
+ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
42
68
  try {
43
69
  const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
44
70
  const doc = iframe.contentDocument;
45
71
  if (!win || !doc) return lastResult;
46
72
 
47
73
  for (const el of doc.querySelectorAll("video, audio")) {
48
- if (el instanceof HTMLMediaElement && el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
74
+ if (
75
+ isPreviewMediaElement(el) &&
76
+ !el.error &&
77
+ el.networkState !== MEDIA_NETWORK_NO_SOURCE &&
78
+ el.readyState < MEDIA_HAVE_FUTURE_DATA
79
+ ) {
49
80
  return true;
50
81
  }
51
82
  }
@@ -72,7 +103,18 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
72
103
  * timeline probing, and DOM inspection.
73
104
  */
74
105
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
75
- ({ projectId, directUrl, onLoad, portrait }, ref) => {
106
+ (
107
+ {
108
+ projectId,
109
+ directUrl,
110
+ onLoad,
111
+ onCompositionLoadingChange,
112
+ portrait,
113
+ style,
114
+ suppressLoadingOverlay,
115
+ },
116
+ ref,
117
+ ) => {
76
118
  const containerRef = useRef<HTMLDivElement>(null);
77
119
  const loadCountRef = useRef(0);
78
120
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -81,6 +123,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
81
123
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
82
124
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
83
125
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
126
+ const [compositionLoading, setCompositionLoading] = useState(true);
84
127
 
85
128
  useMountEffect(() => {
86
129
  const container = containerRef.current;
@@ -105,6 +148,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
105
148
  player.style.height = "100%";
106
149
  player.style.display = "block";
107
150
  container.appendChild(player);
151
+ enableInteractiveIframe(player);
108
152
 
109
153
  // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
110
154
  const iframe = player.iframeElement;
@@ -125,10 +169,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
125
169
  };
126
170
  player.addEventListener("shadertransitionstate", handleShaderTransitionState);
127
171
 
172
+ const handleReady = () => {
173
+ setCompositionLoading(false);
174
+ };
175
+ const handleError = () => {
176
+ setCompositionLoading(false);
177
+ };
178
+ player.addEventListener("ready", handleReady);
179
+ player.addEventListener("error", handleError);
180
+
128
181
  // Forward the iframe's native load event to the studio's onIframeLoad.
129
182
  const handleLoad = () => {
130
183
  loadCountRef.current++;
131
184
  setShaderTransitionLoading(false);
185
+ setCompositionLoading(true);
132
186
  // Reveal animation on reload (hot-reload, composition switch)
133
187
  if (loadCountRef.current > 1) {
134
188
  container.classList.remove("preview-revealing");
@@ -179,6 +233,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
179
233
  iframe.removeEventListener("load", handleLoad);
180
234
  player.removeEventListener("click", preventToggle, { capture: true });
181
235
  player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
236
+ player.removeEventListener("ready", handleReady);
237
+ player.removeEventListener("error", handleError);
182
238
  if (assetPollRef.current) clearInterval(assetPollRef.current);
183
239
  assetPollRef.current = null;
184
240
  container.removeChild(player);
@@ -224,11 +280,38 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
224
280
  };
225
281
  }, [assetsLoading]);
226
282
 
227
- const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
283
+ const showCompositionOverlay =
284
+ !suppressLoadingOverlay && shouldShowCompositionLoadingOverlay(compositionLoading);
285
+ const showAssetOverlay =
286
+ assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
287
+
288
+ useEffect(() => {
289
+ onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
290
+ }, [onCompositionLoadingChange, showCompositionOverlay, showAssetOverlay]);
228
291
 
229
292
  return (
230
- <div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
293
+ <div
294
+ className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
295
+ style={style}
296
+ >
231
297
  <div ref={containerRef} className="w-full h-full" />
298
+ {showCompositionOverlay && (
299
+ <div
300
+ className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
301
+ data-hyperframes-ignore=""
302
+ data-testid="composition-loading-overlay"
303
+ draggable={false}
304
+ onDragStart={(event) => event.preventDefault()}
305
+ onMouseDown={(event) => event.preventDefault()}
306
+ onPointerDown={(event) => event.preventDefault()}
307
+ >
308
+ <HyperframesLoader
309
+ title="Loading composition"
310
+ detail="Preparing the Studio preview."
311
+ size={56}
312
+ />
313
+ </div>
314
+ )}
232
315
  {showAssetOverlay && (
233
316
  <div
234
317
  className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
@@ -26,11 +26,13 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
26
26
  interface PlayerControlsProps {
27
27
  onTogglePlay: () => void;
28
28
  onSeek: (time: number) => void;
29
+ disabled?: boolean;
29
30
  }
30
31
 
31
32
  export const PlayerControls = memo(function PlayerControls({
32
33
  onTogglePlay,
33
34
  onSeek,
35
+ disabled = false,
34
36
  }: PlayerControlsProps) {
35
37
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
36
38
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -57,6 +59,7 @@ export const PlayerControls = memo(function PlayerControls({
57
59
 
58
60
  const durationRef = useRef(duration);
59
61
  durationRef.current = duration;
62
+ const controlsDisabled = disabled || !timelineReady;
60
63
  useMountEffect(() => {
61
64
  const updateProgress = (t: number) => {
62
65
  currentTimeRef.current = t;
@@ -115,6 +118,7 @@ export const PlayerControls = memo(function PlayerControls({
115
118
 
116
119
  const seekFromClientX = useCallback(
117
120
  (clientX: number) => {
121
+ if (disabled) return;
118
122
  const bar = seekBarRef.current;
119
123
  if (!bar || duration <= 0) return;
120
124
  const rect = bar.getBoundingClientRect();
@@ -125,7 +129,7 @@ export const PlayerControls = memo(function PlayerControls({
125
129
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
126
130
  onSeek(percent * duration);
127
131
  },
128
- [duration, onSeek],
132
+ [disabled, duration, onSeek],
129
133
  );
130
134
 
131
135
  const handlePointerDown = useCallback(
@@ -204,7 +208,7 @@ export const PlayerControls = memo(function PlayerControls({
204
208
 
205
209
  const handleKeyDown = useCallback(
206
210
  (e: React.KeyboardEvent) => {
207
- if (!timelineReady || duration <= 0) return;
211
+ if (disabled || !timelineReady || duration <= 0) return;
208
212
  const step = e.shiftKey ? 10 : 1;
209
213
  if (e.key === "ArrowLeft") {
210
214
  e.preventDefault();
@@ -214,14 +218,15 @@ export const PlayerControls = memo(function PlayerControls({
214
218
  onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
215
219
  }
216
220
  },
217
- [timelineReady, duration, onSeek],
221
+ [disabled, timelineReady, duration, onSeek],
218
222
  );
219
223
 
220
224
  const commitJumpFrame = useCallback(() => {
225
+ if (disabled) return;
221
226
  const frame = Number.parseInt(jumpFrame, 10);
222
227
  if (!Number.isFinite(frame) || duration <= 0) return;
223
228
  onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
224
- }, [duration, jumpFrame, onSeek]);
229
+ }, [disabled, duration, jumpFrame, onSeek]);
225
230
 
226
231
  const handleJumpSubmit = useCallback(
227
232
  (e: React.FormEvent) => {
@@ -243,6 +248,7 @@ export const PlayerControls = memo(function PlayerControls({
243
248
  return (
244
249
  <div
245
250
  className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
251
+ aria-disabled={disabled || undefined}
246
252
  style={{
247
253
  borderTop: "1px solid rgba(255,255,255,0.04)",
248
254
  // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -256,7 +262,7 @@ export const PlayerControls = memo(function PlayerControls({
256
262
  type="button"
257
263
  aria-label={isPlaying ? "Pause" : "Play"}
258
264
  onClick={onTogglePlay}
259
- disabled={!timelineReady}
265
+ disabled={controlsDisabled}
260
266
  className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
261
267
  style={{ background: "rgba(255,255,255,0.06)" }}
262
268
  >
@@ -293,12 +299,15 @@ export const PlayerControls = memo(function PlayerControls({
293
299
  (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
294
300
  }}
295
301
  role="slider"
296
- tabIndex={0}
302
+ tabIndex={disabled ? -1 : 0}
297
303
  aria-label="Seek"
304
+ aria-disabled={disabled || undefined}
298
305
  aria-valuemin={0}
299
306
  aria-valuemax={Math.round(duration)}
300
307
  aria-valuenow={0}
301
- className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
308
+ className={`min-w-[96px] flex-1 h-6 flex items-center group ${
309
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
310
+ }`}
302
311
  // `touch-action: none` tells the browser we're handling every
303
312
  // pointer gesture on this element ourselves. Without it, iOS
304
313
  // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -334,6 +343,7 @@ export const PlayerControls = memo(function PlayerControls({
334
343
  <button
335
344
  type="button"
336
345
  onClick={() => setShowSpeedMenu((v) => !v)}
346
+ disabled={disabled}
337
347
  className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
338
348
  style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
339
349
  >
@@ -374,6 +384,7 @@ export const PlayerControls = memo(function PlayerControls({
374
384
  <button
375
385
  type="button"
376
386
  onClick={() => setLoopEnabled(!loopEnabled)}
387
+ disabled={disabled}
377
388
  className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
378
389
  loopEnabled
379
390
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -389,6 +400,7 @@ export const PlayerControls = memo(function PlayerControls({
389
400
  <button
390
401
  type="button"
391
402
  onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
403
+ disabled={disabled}
392
404
  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
405
  title="Toggle time/frame display"
394
406
  aria-label="Toggle time and frame display"
@@ -403,6 +415,7 @@ export const PlayerControls = memo(function PlayerControls({
403
415
  <input
404
416
  value={jumpFrame}
405
417
  onChange={(e) => setJumpFrame(e.target.value)}
418
+ disabled={disabled}
406
419
  inputMode="numeric"
407
420
  pattern="[0-9]*"
408
421
  aria-label="Jump to frame"
@@ -8,9 +8,12 @@ import {
8
8
  getTimelinePlayheadLeft,
9
9
  getTimelineScrollLeftForZoomAnchor,
10
10
  getTimelineScrollLeftForZoomTransition,
11
+ shouldShowTimelineShortcutHint,
11
12
  shouldHandleTimelineDeleteKey,
12
13
  shouldAutoScrollTimeline,
13
14
  } from "./Timeline";
15
+ import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip";
16
+ import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail";
14
17
  import { formatTime } from "../lib/time";
15
18
 
16
19
  describe("generateTicks", () => {
@@ -163,6 +166,12 @@ describe("shouldAutoScrollTimeline", () => {
163
166
  });
164
167
  });
165
168
 
169
+ describe("timeline clip controls", () => {
170
+ it("renders layer controls above composition thumbnail chrome", () => {
171
+ expect(TIMELINE_CLIP_CONTROL_Z_INDEX).toBeGreaterThan(COMPOSITION_THUMBNAIL_LABEL_Z_INDEX);
172
+ });
173
+ });
174
+
166
175
  describe("getTimelineScrollLeftForZoomTransition", () => {
167
176
  it("resets horizontal scroll when switching from manual zoom back to fit", () => {
168
177
  expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
@@ -237,6 +246,17 @@ describe("getTimelineCanvasHeight", () => {
237
246
  });
238
247
  });
239
248
 
249
+ describe("shouldShowTimelineShortcutHint", () => {
250
+ it("shows the hint when the timeline does not vertically overflow", () => {
251
+ expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
252
+ expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
253
+ });
254
+
255
+ it("hides the hint when timeline tracks need vertical scrolling", () => {
256
+ expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
257
+ });
258
+ });
259
+
240
260
  describe("shouldHandleTimelineDeleteKey", () => {
241
261
  it("handles Delete and Backspace when focus is not in an editor", () => {
242
262
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);