@hyperframes/studio 0.7.76 → 0.7.78

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 (138) hide show
  1. package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
  2. package/dist/assets/index-BSZrK0bx.js +428 -0
  3. package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
  4. package/dist/assets/index-DpkO2Hvw.css +1 -0
  5. package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +25030 -23214
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  38. package/src/components/sidebar/CompositionsTab.tsx +66 -32
  39. package/src/contexts/TimelineEditContext.tsx +1 -1
  40. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  41. package/src/hooks/gsapDragCommit.ts +2 -2
  42. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  43. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  44. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  45. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  46. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  47. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  48. package/src/hooks/gsapShared.test.ts +109 -1
  49. package/src/hooks/gsapShared.ts +142 -17
  50. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  51. package/src/hooks/gsapTweenSynth.ts +61 -14
  52. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  53. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  54. package/src/hooks/timelineMoveAdapter.ts +9 -2
  55. package/src/hooks/useDomEditWiring.ts +2 -2
  56. package/src/hooks/useDomSelection.ts +4 -0
  57. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  58. package/src/hooks/useEnableKeyframes.ts +116 -56
  59. package/src/hooks/useGestureCommit.ts +2 -2
  60. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  61. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  62. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  63. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  64. package/src/hooks/useGsapScriptCommits.ts +2 -0
  65. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  66. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  67. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  68. package/src/hooks/useGsapTweenCache.ts +64 -178
  69. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  70. package/src/hooks/useStudioContextValue.test.ts +93 -0
  71. package/src/hooks/useStudioContextValue.ts +17 -3
  72. package/src/hooks/useStudioTestHooks.ts +63 -0
  73. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  74. package/src/hooks/useTimelineEditing.ts +43 -32
  75. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  77. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  78. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  79. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  80. package/src/player/components/Player.test.ts +143 -12
  81. package/src/player/components/Player.tsx +84 -43
  82. package/src/player/components/Timeline.test.ts +420 -90
  83. package/src/player/components/Timeline.tsx +80 -78
  84. package/src/player/components/TimelineCanvas.tsx +33 -21
  85. package/src/player/components/TimelineClip.tsx +3 -1
  86. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  87. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  88. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  89. package/src/player/components/TimelineLanes.tsx +153 -79
  90. package/src/player/components/TimelineOverlays.tsx +4 -18
  91. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  92. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  93. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  94. package/src/player/components/TimelineRuler.tsx +11 -21
  95. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  96. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  97. package/src/player/components/TrackClipCount.tsx +17 -0
  98. package/src/player/components/timelineCallbacks.ts +21 -6
  99. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  100. package/src/player/components/timelineClipDragPreview.ts +33 -15
  101. package/src/player/components/timelineCollision.ts +4 -13
  102. package/src/player/components/timelineDiamondTypes.ts +120 -0
  103. package/src/player/components/timelineDragDrop.ts +8 -3
  104. package/src/player/components/timelineEditing.test.ts +19 -27
  105. package/src/player/components/timelineEditing.ts +7 -8
  106. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  107. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  108. package/src/player/components/timelineLayout.test.ts +162 -9
  109. package/src/player/components/timelineLayout.ts +198 -38
  110. package/src/player/components/timelineMarquee.test.ts +76 -21
  111. package/src/player/components/timelineMarquee.ts +17 -14
  112. package/src/player/components/trackHeaderLaneState.ts +121 -0
  113. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  114. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  115. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  116. package/src/player/components/useTimelineClipDrag.ts +4 -1
  117. package/src/player/components/useTimelineGeometry.ts +4 -1
  118. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  119. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  120. package/src/player/components/useTimelinePlayhead.ts +21 -12
  121. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  122. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  123. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  124. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  125. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  126. package/src/player/lib/timelineElementHelpers.ts +26 -11
  127. package/src/player/store/keyframeSlice.ts +122 -0
  128. package/src/player/store/playerStore.test.ts +25 -0
  129. package/src/player/store/playerStore.ts +7 -43
  130. package/src/telemetry/events.test.ts +9 -3
  131. package/src/telemetry/events.ts +16 -7
  132. package/src/utils/globalTimeCompiler.test.ts +10 -0
  133. package/src/utils/globalTimeCompiler.ts +3 -3
  134. package/dist/assets/index-B37rWXo4.css +0 -1
  135. package/dist/assets/index-BdOfFsR8.js +0 -428
  136. package/src/player/components/TimelineDragGhost.tsx +0 -59
  137. package/src/utils/keyframeSelection.test.ts +0 -45
  138. package/src/utils/keyframeSelection.ts +0 -29
@@ -0,0 +1,69 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import {
7
+ KeyframeDiamondContextMenu,
8
+ type KeyframeDiamondContextMenuState,
9
+ } from "./KeyframeDiamondContextMenu";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
14
+
15
+ const state: KeyframeDiamondContextMenuState = {
16
+ x: 10,
17
+ y: 10,
18
+ element,
19
+ elementId: "box",
20
+ percentage: 50,
21
+ tweenPercentage: 25,
22
+ propertyGroup: "position",
23
+ animationId: "box-to-1-position",
24
+ };
25
+
26
+ function clickMenuItem(label: string, props: Partial<Record<string, unknown>>) {
27
+ const host = document.createElement("div");
28
+ document.body.appendChild(host);
29
+ const root = createRoot(host);
30
+ act(() =>
31
+ root.render(
32
+ <KeyframeDiamondContextMenu
33
+ state={state}
34
+ onClose={() => {}}
35
+ onDelete={vi.fn()}
36
+ onDeleteAll={vi.fn()}
37
+ {...props}
38
+ />,
39
+ ),
40
+ );
41
+ const button = Array.from(document.body.querySelectorAll("button")).find(
42
+ (candidate) => candidate.textContent === label,
43
+ );
44
+ act(() => button?.click());
45
+ act(() => root.unmount());
46
+ host.remove();
47
+ }
48
+
49
+ describe("KeyframeDiamondContextMenu", () => {
50
+ // Two animations can carry a keyframe at the same clip percentage. Dropping the
51
+ // property group / tween percentage / animation id here sends the mutation back
52
+ // to first-match-by-percentage, which retimes or deletes the wrong tween.
53
+ it("hands every action the full keyframe identity, not just the percentage", () => {
54
+ const onDelete = vi.fn();
55
+ const onMoveToPlayhead = vi.fn();
56
+
57
+ clickMenuItem("Delete Keyframe", { onDelete });
58
+ clickMenuItem("Move to Playhead", { onMoveToPlayhead });
59
+
60
+ const target = {
61
+ percentage: 50,
62
+ tweenPercentage: 25,
63
+ propertyGroup: "position",
64
+ animationId: "box-to-1-position",
65
+ };
66
+ expect(onDelete).toHaveBeenCalledWith("box", target);
67
+ expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
68
+ });
69
+ });
@@ -1,25 +1,28 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+ import type { TimelineElement } from "../store/playerStore";
5
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
4
6
 
5
7
  export interface KeyframeDiamondContextMenuState {
6
8
  x: number;
7
9
  y: number;
10
+ element: TimelineElement;
8
11
  elementId: string;
9
12
  percentage: number;
10
13
  tweenPercentage?: number;
14
+ propertyGroup?: string;
15
+ animationId?: string;
11
16
  currentEase?: string;
12
17
  }
13
18
 
14
19
  interface KeyframeDiamondContextMenuProps {
15
20
  state: KeyframeDiamondContextMenuState;
16
21
  onClose: () => void;
17
- onDelete: (elementId: string, percentage: number) => void;
18
- onDeleteAll: (elementId: string) => void;
19
- onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
20
- onCopyProperties?: (elementId: string, percentage: number) => void;
22
+ onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
23
+ onDeleteAll: (element: TimelineElement) => void;
21
24
  /** Retime the keyframe to the current playhead, preserving its value + ease. */
22
- onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
25
+ onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
23
26
  }
24
27
 
25
28
  export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
@@ -30,6 +33,14 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
30
33
  onMoveToPlayhead,
31
34
  }: KeyframeDiamondContextMenuProps) {
32
35
  const menuRef = useContextMenuDismiss(onClose);
36
+ // The clicked diamond's identity, built once: the menu's two mutating entries
37
+ // both act on it, and they must not disagree about which keyframe was clicked.
38
+ const keyframe: TimelineKeyframeTarget = {
39
+ percentage: state.percentage,
40
+ tweenPercentage: state.tweenPercentage,
41
+ propertyGroup: state.propertyGroup,
42
+ animationId: state.animationId,
43
+ };
33
44
 
34
45
  const menuWidth = 200;
35
46
  const menuHeight = onMoveToPlayhead ? 100 : 70;
@@ -51,7 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
51
62
  // Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
52
63
  // and returns the tween-% for the mutation. Passing tween-% here would
53
64
  // miss the lookup on any tween whose window is shorter than the clip.
54
- onMoveToPlayhead(state.elementId, state.percentage);
65
+ onMoveToPlayhead(state.element, keyframe);
55
66
  onClose();
56
67
  }}
57
68
  >
@@ -64,7 +75,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
64
75
  type="button"
65
76
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
66
77
  onClick={() => {
67
- onDelete(state.elementId, state.percentage);
78
+ onDelete(state.elementId, keyframe);
68
79
  onClose();
69
80
  }}
70
81
  >
@@ -75,7 +86,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
75
86
  type="button"
76
87
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
77
88
  onClick={() => {
78
- onDeleteAll(state.elementId);
89
+ onDeleteAll(state.element);
79
90
  onClose();
80
91
  }}
81
92
  >
@@ -0,0 +1,76 @@
1
+ import { CaretRight } from "@phosphor-icons/react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { TRACK_H } from "./timelineLayout";
4
+ import { TrackClipCount } from "./TrackClipCount";
5
+
6
+ // Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
7
+ // here, not on the clip bar, and re-expands a collapsed layer.
8
+ export function LayerDisclosureRow({
9
+ keyframeClip,
10
+ clipCount,
11
+ isExpanded,
12
+ gutterBackground,
13
+ columnWidth,
14
+ lanesId,
15
+ onToggleClipExpanded,
16
+ children,
17
+ }: {
18
+ keyframeClip: TimelineElement;
19
+ clipCount: number;
20
+ isExpanded: boolean;
21
+ gutterBackground: string;
22
+ /** Same adaptive width the lane rows use: a narrowed header column must not
23
+ * leave this row hanging over the clips it labels. */
24
+ columnWidth: number;
25
+ /** Id of the element holding the lanes this row's caret expands. */
26
+ lanesId: string;
27
+ onToggleClipExpanded: () => void;
28
+ /** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
29
+ children?: React.ReactNode;
30
+ }) {
31
+ const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
32
+ return (
33
+ <div
34
+ className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
35
+ style={{
36
+ width: columnWidth,
37
+ height: TRACK_H,
38
+ color: "#ffffff",
39
+ background: gutterBackground,
40
+ }}
41
+ >
42
+ <button
43
+ type="button"
44
+ aria-expanded={isExpanded}
45
+ aria-controls={lanesId}
46
+ aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
47
+ title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
48
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
49
+ // only the hit box grows.
50
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
51
+ onPointerDown={(event) => event.stopPropagation()}
52
+ onClick={(event) => {
53
+ event.stopPropagation();
54
+ onToggleClipExpanded();
55
+ }}
56
+ >
57
+ <CaretRight
58
+ size={11}
59
+ weight="bold"
60
+ aria-hidden="true"
61
+ style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
62
+ />
63
+ </button>
64
+ {/* Decorative: the disclosure button above already names the row's keyframe
65
+ state, and aria-label on a plain span is not exposed reliably anyway. */}
66
+ <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
67
+
68
+ </span>
69
+ <span className="min-w-0 flex-1 truncate font-medium" title={name}>
70
+ {name}
71
+ </span>
72
+ <TrackClipCount clipCount={clipCount} />
73
+ {children}
74
+ </div>
75
+ );
76
+ }
@@ -1,7 +1,146 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it } from "vitest";
4
- import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ hasUnloadedAssets,
8
+ Player,
9
+ readPreviewErrorMessage,
10
+ shouldShowCompositionLoadingOverlay,
11
+ } from "./Player";
12
+
13
+ vi.mock("@hyperframes/player", () => ({}));
14
+
15
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
16
+
17
+ let root: Root | null = null;
18
+ let lifecycleLog: string[] = [];
19
+
20
+ class TestHyperframesPlayer extends HTMLElement {
21
+ readonly iframeElement = document.createElement("iframe");
22
+
23
+ constructor() {
24
+ super();
25
+
26
+ const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
27
+ this.iframeElement.addEventListener = ((type, listener, options) => {
28
+ lifecycleLog.push(`iframe:${type}`);
29
+ addIframeListener(type, listener, options);
30
+ }) as typeof this.iframeElement.addEventListener;
31
+
32
+ const addPlayerListener = this.addEventListener.bind(this);
33
+ this.addEventListener = ((type, listener, options) => {
34
+ lifecycleLog.push(`player:${type}`);
35
+ addPlayerListener(type, listener, options);
36
+ }) as typeof this.addEventListener;
37
+
38
+ const setPlayerAttribute = this.setAttribute.bind(this);
39
+ this.setAttribute = (name, value) => {
40
+ if (name === "src") lifecycleLog.push("src");
41
+ setPlayerAttribute(name, value);
42
+ };
43
+ }
44
+ }
45
+
46
+ if (!customElements.get("hyperframes-player")) {
47
+ customElements.define("hyperframes-player", TestHyperframesPlayer);
48
+ }
49
+
50
+ afterEach(() => {
51
+ if (root) act(() => root?.unmount());
52
+ root = null;
53
+ lifecycleLog = [];
54
+ document.body.innerHTML = "";
55
+ });
56
+
57
+ async function mountPlayer() {
58
+ const host = document.createElement("div");
59
+ document.body.append(host);
60
+ root = createRoot(host);
61
+ await act(async () => {
62
+ root?.render(
63
+ createElement(Player, {
64
+ directUrl: "/api/projects/demo/preview",
65
+ onLoad: vi.fn(),
66
+ suppressLoadingOverlay: true,
67
+ }),
68
+ );
69
+ await Promise.resolve();
70
+ });
71
+
72
+ const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
73
+ if (!player) throw new Error("player did not mount");
74
+ return { host, player };
75
+ }
76
+
77
+ function createAudioIframe() {
78
+ const iframe = document.createElement("iframe");
79
+ document.body.appendChild(iframe);
80
+ const audio = iframe.contentDocument?.createElement("audio");
81
+ expect(audio).toBeDefined();
82
+ iframe.contentDocument?.body.appendChild(audio!);
83
+ return { audio: audio!, iframe };
84
+ }
85
+
86
+ describe("preview errors", () => {
87
+ it("reads the player probe error for the visible retry state", () => {
88
+ expect(
89
+ readPreviewErrorMessage(
90
+ new CustomEvent("error", {
91
+ detail: { message: "Composition timeline not found after 8s" },
92
+ }),
93
+ ),
94
+ ).toBe("Composition timeline not found after 8s");
95
+ });
96
+
97
+ it("falls back when the player emits an unstructured error", () => {
98
+ expect(readPreviewErrorMessage(new Event("error"))).toBe(
99
+ "The composition preview did not become ready.",
100
+ );
101
+ });
102
+
103
+ it("attaches lifecycle listeners before navigating the player", async () => {
104
+ await mountPlayer();
105
+ const srcIndex = lifecycleLog.indexOf("src");
106
+
107
+ expect(srcIndex).toBeGreaterThan(-1);
108
+ for (const listener of [
109
+ "iframe:load",
110
+ "player:click",
111
+ "player:shadertransitionstate",
112
+ "player:ready",
113
+ "player:error",
114
+ ]) {
115
+ expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
116
+ expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
117
+ }
118
+ });
119
+
120
+ it("retries a failed preview with a fresh player URL", async () => {
121
+ const { host, player } = await mountPlayer();
122
+
123
+ act(() => {
124
+ player.dispatchEvent(
125
+ new CustomEvent("error", {
126
+ detail: { message: "Composition timeline not found after 8s" },
127
+ }),
128
+ );
129
+ });
130
+
131
+ expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
132
+ const retry = Array.from(host.querySelectorAll("button")).find(
133
+ (button) => button.textContent === "Retry preview",
134
+ );
135
+ if (!retry) throw new Error("retry action did not render");
136
+
137
+ act(() => retry.click());
138
+
139
+ const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
140
+ expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
141
+ expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
142
+ });
143
+ });
5
144
 
6
145
  describe("composition loading overlay", () => {
7
146
  it("shows while the composition is loading", () => {
@@ -13,10 +152,7 @@ describe("composition loading overlay", () => {
13
152
  });
14
153
 
15
154
  it("keeps the asset overlay up while media is still buffering", () => {
16
- const iframe = document.createElement("iframe");
17
- document.body.appendChild(iframe);
18
- const audio = iframe.contentDocument?.createElement("audio");
19
- expect(audio).toBeDefined();
155
+ const { audio, iframe } = createAudioIframe();
20
156
  Object.defineProperty(audio, "readyState", {
21
157
  value: 0,
22
158
  configurable: true,
@@ -25,7 +161,6 @@ describe("composition loading overlay", () => {
25
161
  value: 2,
26
162
  configurable: true,
27
163
  });
28
- iframe.contentDocument?.body.appendChild(audio!);
29
164
 
30
165
  expect(hasUnloadedAssets(iframe, false)).toBe(true);
31
166
 
@@ -33,10 +168,7 @@ describe("composition loading overlay", () => {
33
168
  });
34
169
 
35
170
  it("does not keep the asset overlay stuck on failed media sources", () => {
36
- const iframe = document.createElement("iframe");
37
- document.body.appendChild(iframe);
38
- const audio = iframe.contentDocument?.createElement("audio");
39
- expect(audio).toBeDefined();
171
+ const { audio, iframe } = createAudioIframe();
40
172
  Object.defineProperty(audio, "error", {
41
173
  value: { code: 4, message: "format error" },
42
174
  configurable: true,
@@ -49,7 +181,6 @@ describe("composition loading overlay", () => {
49
181
  value: 3,
50
182
  configurable: true,
51
183
  });
52
- iframe.contentDocument?.body.appendChild(audio!);
53
184
 
54
185
  expect(hasUnloadedAssets(iframe, false)).toBe(false);
55
186
 
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
38
38
  }
39
39
 
40
40
  const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
41
+ const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
41
42
 
42
43
  export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
43
44
  return compositionLoading;
44
45
  }
45
46
 
47
+ export function readPreviewErrorMessage(event: Event): string {
48
+ if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
49
+ return typeof event.detail.message === "string" && event.detail.message.trim()
50
+ ? event.detail.message
51
+ : DEFAULT_PREVIEW_ERROR;
52
+ }
53
+
46
54
  function enableInteractiveIframe(player: HyperframesPlayerElement): void {
47
55
  const root = player.shadowRoot;
48
56
  if (!root) return;
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
122
130
  const loadCountRef = useRef(0);
123
131
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
124
132
  const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+ const retryPreviewRef = useRef<(() => void) | null>(null);
134
+ const retryCountRef = useRef(0);
125
135
  const [assetsLoading, setAssetsLoading] = useState(false);
126
136
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
127
137
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
128
138
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
129
139
  const [compositionLoading, setCompositionLoading] = useState(true);
130
140
  const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
141
+ const [previewError, setPreviewError] = useState<string | null>(null);
131
142
 
132
143
  // eslint-disable-next-line no-restricted-syntax
133
144
  useEffect(() => {
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
161
172
  );
162
173
  applyPreviewVariablesToUrl(srcUrl);
163
174
  const src = srcUrl.pathname + srcUrl.search;
164
- player.setAttribute("shader-capture-scale", "1");
165
- player.setAttribute("shader-loading", "player");
166
- player.setAttribute("src", src);
167
- player.setAttribute("width", String(portrait ? 1080 : 1920));
168
- player.setAttribute("height", String(portrait ? 1920 : 1080));
169
- player.style.width = "100%";
170
- player.style.height = "100%";
171
- player.style.display = "block";
172
- player.style.background = "transparent";
173
- container.appendChild(player);
174
-
175
- // Inject pasteboard shadow: let the shadow around the canvas bleed
176
- // into the surrounding pasteboard area (overflow: visible on the container)
177
- // and add a subtle outline + drop-shadow so the canvas boundary reads
178
- // against the gray pasteboard, consistent with professional editors.
179
- if (player.shadowRoot) {
180
- const pasteboardStyle = document.createElement("style");
181
- pasteboardStyle.textContent =
182
- ".hfp-container{overflow:visible}" +
183
- ".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
184
- player.shadowRoot.appendChild(pasteboardStyle);
185
- }
186
-
187
- enableInteractiveIframe(player);
188
-
189
- // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
175
+ const retryPreview = () => {
176
+ retryCountRef.current += 1;
177
+ const retryUrl = new URL(src, window.location.origin);
178
+ retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
179
+ setPreviewError(null);
180
+ setCompositionLoading(true);
181
+ player.setAttribute("src", retryUrl.pathname + retryUrl.search);
182
+ };
183
+ retryPreviewRef.current = retryPreview;
190
184
  const iframe = player.iframeElement;
191
- if (typeof ref === "function") {
192
- ref(iframe);
193
- } else if (ref) {
194
- (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
195
- }
196
-
197
- // Prevent the web component's built-in click-to-toggle behavior.
198
- // The studio manages playback exclusively via useTimelinePlayer.
199
185
  const preventToggle = (e: Event) => e.stopImmediatePropagation();
200
- player.addEventListener("click", preventToggle, { capture: true });
201
-
202
186
  const handleShaderTransitionState = (event: Event) => {
203
187
  const loading = getShaderTransitionLoading(event);
204
188
  if (loading !== null) setShaderTransitionLoading(loading);
205
189
  };
206
- player.addEventListener("shadertransitionstate", handleShaderTransitionState);
207
-
208
190
  const handleReady = () => {
191
+ setPreviewError(null);
209
192
  setCompositionLoading(false);
210
193
  };
211
- const handleError = () => {
194
+ const handleError = (event: Event) => {
195
+ setPreviewError(readPreviewErrorMessage(event));
212
196
  setCompositionLoading(false);
213
197
  };
214
- player.addEventListener("ready", handleReady);
215
- player.addEventListener("error", handleError);
216
-
217
- // Forward the iframe's native load event to the studio's onIframeLoad.
218
198
  const handleLoad = () => {
219
199
  loadCountRef.current++;
200
+ setPreviewError(null);
220
201
  setShaderTransitionLoading(false);
221
202
  setCompositionLoading(true);
222
203
  // Reveal animation on reload (hot-reload, composition switch)
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
264
245
  setAssetsLoading(false);
265
246
  }
266
247
  };
248
+
249
+ // Attach lifecycle listeners before assigning src or connecting the
250
+ // custom element. A warm local iframe can otherwise finish before
251
+ // Studio observes its load and never initialize the timeline.
267
252
  iframe.addEventListener("load", handleLoad);
253
+ player.addEventListener("click", preventToggle, { capture: true });
254
+ player.addEventListener("shadertransitionstate", handleShaderTransitionState);
255
+ player.addEventListener("ready", handleReady);
256
+ player.addEventListener("error", handleError);
257
+
258
+ // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
259
+ if (typeof ref === "function") {
260
+ ref(iframe);
261
+ } else if (ref) {
262
+ (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
263
+ }
264
+
265
+ player.setAttribute("shader-capture-scale", "1");
266
+ player.setAttribute("shader-loading", "player");
267
+ player.setAttribute("width", String(portrait ? 1080 : 1920));
268
+ player.setAttribute("height", String(portrait ? 1920 : 1080));
269
+ player.style.width = "100%";
270
+ player.style.height = "100%";
271
+ player.style.display = "block";
272
+ player.style.background = "transparent";
273
+ player.setAttribute("src", src);
274
+ container.appendChild(player);
275
+
276
+ // Inject pasteboard shadow: let the shadow around the canvas bleed
277
+ // into the surrounding pasteboard area (overflow: visible on the container)
278
+ // and add a subtle outline + drop-shadow so the canvas boundary reads
279
+ // against the gray pasteboard, consistent with professional editors.
280
+ if (player.shadowRoot) {
281
+ const pasteboardStyle = document.createElement("style");
282
+ pasteboardStyle.textContent =
283
+ ".hfp-container{overflow:visible}" +
284
+ ".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
285
+ player.shadowRoot.appendChild(pasteboardStyle);
286
+ }
287
+
288
+ enableInteractiveIframe(player);
268
289
 
269
290
  cleanup = () => {
270
291
  iframe.removeEventListener("load", handleLoad);
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
275
296
  if (assetPollRef.current) clearInterval(assetPollRef.current);
276
297
  assetPollRef.current = null;
277
298
  container.removeChild(player);
299
+ if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
278
300
  // Clear the forwarded ref only if it still points to THIS iframe.
279
301
  // During crossfade refreshes the retiring Player unmounts after the
280
302
  // new Player has already assigned its iframe to the same ref — blindly
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
381
403
  />
382
404
  </div>
383
405
  )}
406
+ {previewError && (
407
+ <div
408
+ className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
409
+ data-hyperframes-ignore=""
410
+ data-testid="composition-preview-error"
411
+ >
412
+ <div className="max-w-sm">
413
+ <p className="text-sm font-semibold text-white">Preview failed to load</p>
414
+ <p className="mt-1 text-xs text-neutral-400">{previewError}</p>
415
+ <button
416
+ type="button"
417
+ className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
418
+ onClick={() => retryPreviewRef.current?.()}
419
+ >
420
+ Retry preview
421
+ </button>
422
+ </div>
423
+ </div>
424
+ )}
384
425
  </div>
385
426
  );
386
427
  },