@hyperframes/studio 0.4.38 → 0.5.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 (51) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1431 -196
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.tsx +445 -0
  8. package/src/components/editor/PropertyPanel.tsx +2466 -206
  9. package/src/components/editor/colorValue.test.ts +82 -0
  10. package/src/components/editor/colorValue.ts +175 -0
  11. package/src/components/editor/domEditing.test.ts +537 -0
  12. package/src/components/editor/domEditing.ts +762 -0
  13. package/src/components/editor/floatingPanel.test.ts +34 -0
  14. package/src/components/editor/floatingPanel.ts +54 -0
  15. package/src/components/editor/fontAssets.ts +32 -0
  16. package/src/components/editor/fontCatalog.ts +126 -0
  17. package/src/components/editor/gradientValue.test.ts +89 -0
  18. package/src/components/editor/gradientValue.ts +445 -0
  19. package/src/components/nle/NLELayout.tsx +17 -47
  20. package/src/components/nle/NLEPreview.tsx +50 -5
  21. package/src/components/sidebar/AssetsTab.tsx +3 -4
  22. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  23. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  24. package/src/components/sidebar/LeftSidebar.tsx +34 -55
  25. package/src/icons/SystemIcons.tsx +0 -2
  26. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  27. package/src/player/components/CompositionThumbnail.tsx +42 -10
  28. package/src/player/components/EditModal.tsx +5 -20
  29. package/src/player/components/Player.tsx +18 -70
  30. package/src/player/components/PlayerControls.tsx +44 -3
  31. package/src/player/components/Timeline.test.ts +12 -0
  32. package/src/player/components/Timeline.tsx +51 -20
  33. package/src/player/components/TimelineClip.tsx +20 -7
  34. package/src/player/components/timelineEditing.test.ts +2 -4
  35. package/src/player/components/timelineEditing.ts +1 -3
  36. package/src/player/components/timelineTheme.ts +3 -3
  37. package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
  38. package/src/player/hooks/useTimelinePlayer.ts +74 -32
  39. package/src/player/lib/time.test.ts +1 -11
  40. package/src/player/lib/time.ts +0 -6
  41. package/src/utils/clipboard.test.ts +88 -0
  42. package/src/utils/clipboard.ts +57 -0
  43. package/src/utils/mediaTypes.ts +1 -1
  44. package/src/utils/sourcePatcher.test.ts +128 -1
  45. package/src/utils/sourcePatcher.ts +130 -18
  46. package/src/utils/timelineAssetDrop.test.ts +31 -11
  47. package/src/utils/timelineAssetDrop.ts +22 -2
  48. package/dist/assets/index-18P_dZeo.js +0 -93
  49. package/dist/assets/index-BLrgRQSu.css +0 -1
  50. package/src/utils/frameCapture.test.ts +0 -26
  51. package/src/utils/frameCapture.ts +0 -38
@@ -1,4 +1,4 @@
1
- import { memo, useRef, useState } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  interface CompositionsTabProps {
4
4
  projectId: string;
@@ -8,6 +8,17 @@ interface CompositionsTabProps {
8
8
  }
9
9
 
10
10
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
11
+ const THUMBNAIL_SEEK_TIME_SECONDS = 3;
12
+ const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
13
+
14
+ type PreviewWindow = Window & {
15
+ __player?: {
16
+ play?: () => void;
17
+ pause?: () => void;
18
+ seek?: (time: number) => void;
19
+ getDuration?: () => number;
20
+ };
21
+ };
11
22
 
12
23
  export function resolveCompositionPreviewScale(input: {
13
24
  cardWidth: number;
@@ -28,6 +39,54 @@ export function resolveCompositionPreviewScale(input: {
28
39
  return Math.min(scaleX, scaleY);
29
40
  }
30
41
 
42
+ export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
43
+ if (
44
+ Number.isFinite(durationSeconds) &&
45
+ durationSeconds != null &&
46
+ durationSeconds > 0 &&
47
+ durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
48
+ ) {
49
+ return durationSeconds / 2;
50
+ }
51
+
52
+ return THUMBNAIL_SEEK_TIME_SECONDS;
53
+ }
54
+
55
+ function parsePositiveNumber(value: string | null): number | null {
56
+ if (value == null) return null;
57
+ const parsed = Number.parseFloat(value);
58
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
59
+ }
60
+
61
+ function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
62
+ const win = iframe?.contentWindow as PreviewWindow | null;
63
+ const playerDuration = win?.__player?.getDuration?.();
64
+ if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
65
+ return playerDuration;
66
+ }
67
+
68
+ const doc = iframe?.contentDocument;
69
+ const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
70
+ return (
71
+ parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
72
+ parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
73
+ );
74
+ }
75
+
76
+ function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
77
+ const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
78
+ if (!player) return false;
79
+
80
+ if (shouldPlay) {
81
+ player.play?.();
82
+ return true;
83
+ }
84
+
85
+ player.pause?.();
86
+ player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
87
+ return true;
88
+ }
89
+
31
90
  function CompCard({
32
91
  projectId,
33
92
  comp,
@@ -41,7 +100,25 @@ function CompCard({
41
100
  }) {
42
101
  const [hovered, setHovered] = useState(false);
43
102
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
103
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
44
104
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105
+ const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
106
+
107
+ const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
108
+ if (syncTimer.current) {
109
+ clearTimeout(syncTimer.current);
110
+ syncTimer.current = null;
111
+ }
112
+
113
+ const sync = (remainingAttempts: number) => {
114
+ if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
115
+
116
+ syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
117
+ };
118
+
119
+ sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
120
+ }, []);
121
+
45
122
  const handleEnter = () => {
46
123
  hoverTimer.current = setTimeout(() => setHovered(true), 300);
47
124
  };
@@ -53,7 +130,6 @@ function CompCard({
53
130
  setHovered(false);
54
131
  };
55
132
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
56
- const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
57
133
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
58
134
  const previewScale = resolveCompositionPreviewScale({
59
135
  cardWidth: 80,
@@ -62,6 +138,17 @@ function CompCard({
62
138
  stageHeight: stageSize.height,
63
139
  });
64
140
 
141
+ useEffect(() => {
142
+ requestIframePlaybackSync(hovered);
143
+ }, [hovered, requestIframePlaybackSync]);
144
+
145
+ useEffect(() => {
146
+ return () => {
147
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
148
+ if (syncTimer.current) clearTimeout(syncTimer.current);
149
+ };
150
+ }, []);
151
+
65
152
  return (
66
153
  <div
67
154
  onClick={onSelect}
@@ -74,49 +161,34 @@ function CompCard({
74
161
  }`}
75
162
  >
76
163
  <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
77
- {/* Live iframe preview on hover */}
78
- {hovered && (
79
- <iframe
80
- src={previewUrl}
81
- sandbox="allow-scripts allow-same-origin"
82
- className="absolute left-0 top-0 border-none pointer-events-none"
83
- style={{
84
- transformOrigin: "0 0",
85
- width: stageSize.width,
86
- height: stageSize.height,
87
- transform: `scale(${previewScale})`,
88
- }}
89
- onLoad={(e) => {
90
- try {
91
- const iframe = e.currentTarget;
92
- const root = iframe.contentDocument?.querySelector("[data-composition-id]");
93
- const width =
94
- Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
95
- const height =
96
- Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
97
- setStageSize({ width, height });
98
- } catch {
99
- setStageSize(DEFAULT_PREVIEW_STAGE);
100
- }
101
- }}
102
- tabIndex={-1}
103
- />
104
- )}
105
- {/* Static thumbnail — hidden while hovering */}
106
- <div
107
- className="absolute inset-0 transition-opacity duration-150"
108
- style={{ opacity: hovered ? 0 : 1 }}
109
- >
110
- <img
111
- src={thumbnailUrl}
112
- alt={name}
113
- loading="lazy"
114
- className="w-full h-full object-contain"
115
- onError={(e) => {
116
- (e.target as HTMLImageElement).style.display = "none";
117
- }}
118
- />
119
- </div>
164
+ <iframe
165
+ ref={iframeRef}
166
+ src={previewUrl}
167
+ sandbox="allow-scripts allow-same-origin"
168
+ loading="lazy"
169
+ className="absolute left-0 top-0 border-none pointer-events-none"
170
+ style={{
171
+ transformOrigin: "0 0",
172
+ width: stageSize.width,
173
+ height: stageSize.height,
174
+ transform: `scale(${previewScale})`,
175
+ }}
176
+ onLoad={(e) => {
177
+ try {
178
+ const iframe = e.currentTarget;
179
+ const root = iframe.contentDocument?.querySelector("[data-composition-id]");
180
+ const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
181
+ const height =
182
+ Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
183
+ setStageSize({ width, height });
184
+ requestIframePlaybackSync(hovered);
185
+ } catch {
186
+ setStageSize(DEFAULT_PREVIEW_STAGE);
187
+ }
188
+ }}
189
+ title={`${name} preview`}
190
+ tabIndex={-1}
191
+ />
120
192
  </div>
121
193
  <div className="min-w-0 flex-1">
122
194
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
@@ -35,7 +35,6 @@ interface LeftSidebarProps {
35
35
  codeChildren?: ReactNode;
36
36
  onLint?: () => void;
37
37
  linting?: boolean;
38
- onToggleCollapse?: () => void;
39
38
  }
40
39
 
41
40
  export const LeftSidebar = memo(function LeftSidebar({
@@ -58,7 +57,6 @@ export const LeftSidebar = memo(function LeftSidebar({
58
57
  codeChildren,
59
58
  onLint,
60
59
  linting,
61
- onToggleCollapse,
62
60
  }: LeftSidebarProps) {
63
61
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
64
62
 
@@ -90,64 +88,45 @@ export const LeftSidebar = memo(function LeftSidebar({
90
88
  style={{ width }}
91
89
  >
92
90
  {/* Tabs — Code first */}
93
- <div className="flex border-b border-neutral-800/50 flex-shrink-0">
94
- <button
95
- type="button"
96
- onClick={() => selectTab("code")}
97
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
98
- tab === "code"
99
- ? "text-neutral-200 border-b-2 border-studio-accent"
100
- : "text-neutral-500 hover:text-neutral-400"
101
- }`}
91
+ <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
92
+ <div
93
+ className="grid gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
94
+ style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
102
95
  >
103
- Code
104
- </button>
105
- <button
106
- type="button"
107
- onClick={() => selectTab("compositions")}
108
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
109
- tab === "compositions"
110
- ? "text-neutral-200 border-b-2 border-studio-accent"
111
- : "text-neutral-500 hover:text-neutral-400"
112
- }`}
113
- >
114
- Compositions
115
- </button>
116
- <button
117
- type="button"
118
- onClick={() => selectTab("assets")}
119
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
120
- tab === "assets"
121
- ? "text-neutral-200 border-b-2 border-studio-accent"
122
- : "text-neutral-500 hover:text-neutral-400"
123
- }`}
124
- >
125
- Assets
126
- </button>
127
- {onToggleCollapse && (
128
96
  <button
129
97
  type="button"
130
- onClick={onToggleCollapse}
131
- className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
132
- title="Hide sidebar"
133
- aria-label="Hide sidebar"
98
+ onClick={() => selectTab("code")}
99
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
100
+ tab === "code"
101
+ ? "bg-neutral-800 text-white"
102
+ : "text-neutral-500 hover:text-neutral-200"
103
+ }`}
134
104
  >
135
- <svg
136
- width="14"
137
- height="14"
138
- viewBox="0 0 24 24"
139
- fill="none"
140
- stroke="currentColor"
141
- strokeWidth="1.5"
142
- strokeLinecap="round"
143
- strokeLinejoin="round"
144
- aria-hidden="true"
145
- >
146
- <path d="m14 7-5 5 5 5" />
147
- <path d="M19 4v16" />
148
- </svg>
105
+ Code
149
106
  </button>
150
- )}
107
+ <button
108
+ type="button"
109
+ onClick={() => selectTab("compositions")}
110
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
111
+ tab === "compositions"
112
+ ? "bg-neutral-800 text-white"
113
+ : "text-neutral-500 hover:text-neutral-200"
114
+ }`}
115
+ >
116
+ Compositions
117
+ </button>
118
+ <button
119
+ type="button"
120
+ onClick={() => selectTab("assets")}
121
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
122
+ tab === "assets"
123
+ ? "bg-neutral-800 text-white"
124
+ : "text-neutral-500 hover:text-neutral-200"
125
+ }`}
126
+ >
127
+ Assets
128
+ </button>
129
+ </div>
151
130
  </div>
152
131
 
153
132
  {/* Tab content */}
@@ -53,7 +53,6 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
- Camera as PhCamera,
57
56
  Gear,
58
57
  } from "@phosphor-icons/react";
59
58
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -128,5 +127,4 @@ export const ChevronDown = makeIcon(CaretDown);
128
127
  export const ChevronRight = makeIcon(CaretRight);
129
128
  export const ClipboardList = makeIcon(ClipboardText);
130
129
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
131
- export const Camera = makeIcon(PhCamera);
132
130
  export const Settings = makeIcon(Gear);
@@ -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,36 +1,29 @@
1
1
  import { forwardRef, useRef, useState } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
4
- // at module load, which throws under SSR. Defer the import to the mount effect
5
- // so it only runs in the browser.
6
3
 
7
4
  interface PlayerProps {
8
5
  projectId?: string;
9
6
  directUrl?: string;
10
7
  onLoad: () => void;
11
8
  portrait?: boolean;
9
+ style?: React.CSSProperties;
12
10
  }
13
11
 
14
12
  interface HyperframesPlayerElement extends HTMLElement {
15
13
  iframeElement: HTMLIFrameElement;
16
14
  }
17
15
 
18
- /**
19
- * Readiness check for a Lottie animation instance. Duck-types both supported
20
- * player shapes:
21
- *
22
- * - `lottie-web` exposes a boolean `isLoaded` on `AnimationItem`.
23
- * - `@dotlottie/player-component` doesn't; we infer readiness from
24
- * `totalFrames > 0` since that value is only populated once the animation
25
- * JSON has been parsed.
26
- *
27
- * Kept in sync with the runtime adapter's own checks in
28
- * `@hyperframes/core/runtime/adapters/lottie.ts` — that module would be a
29
- * more canonical home for the helper, but importing from the core package's
30
- * root index pulls Node-only submodules (path, url) into this browser bundle
31
- * and breaks Vite. If the helper grows, split a browser-safe submodule
32
- * export in core and switch this to import it.
33
- */
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");
25
+ }
26
+
34
27
  function isLottieAnimationReady(anim: unknown): boolean {
35
28
  if (typeof anim !== "object" || anim === null) return true;
36
29
  const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
@@ -39,14 +32,6 @@ function isLottieAnimationReady(anim: unknown): boolean {
39
32
  return false;
40
33
  }
41
34
 
42
- // Assets are considered ready when every `<video>`/`<audio>` has enough data
43
- // to play through without buffering, and every registered Lottie animation has
44
- // finished loading.
45
- //
46
- // Returns whichever value was returned last on cross-origin / transient DOM
47
- // races so a brief access failure (e.g. an iframe that just swapped src)
48
- // doesn't flicker the overlay state — we keep showing whatever was most
49
- // recently true.
50
35
  function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
51
36
  try {
52
37
  const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
@@ -72,18 +57,9 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
72
57
  }
73
58
  }
74
59
 
75
- /**
76
- * Renders a composition preview using the <hyperframes-player> web component.
77
- *
78
- * The web component handles iframe scaling, dimension detection, and
79
- * ResizeObserver internally. This wrapper bridges its inner iframe to the
80
- * forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
81
- * timeline probing, and DOM inspection.
82
- */
83
60
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
84
- ({ projectId, directUrl, onLoad, portrait }, ref) => {
61
+ ({ projectId, directUrl, onLoad, portrait, style }, ref) => {
85
62
  const containerRef = useRef<HTMLDivElement>(null);
86
- const loadCountRef = useRef(0);
87
63
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
88
64
  const [assetsLoading, setAssetsLoading] = useState(false);
89
65
 
@@ -94,11 +70,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
94
70
  let canceled = false;
95
71
  let cleanup: (() => void) | undefined;
96
72
 
97
- // Dynamic import registers the custom element in the browser only.
98
73
  import("@hyperframes/player").then(() => {
99
74
  if (canceled) return;
100
75
 
101
- // Create the web component imperatively to avoid JSX custom-element typing.
102
76
  const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
103
77
  const src = directUrl || `/api/projects/${projectId}/preview`;
104
78
  player.setAttribute("src", src);
@@ -108,8 +82,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
108
82
  player.style.height = "100%";
109
83
  player.style.display = "block";
110
84
  container.appendChild(player);
85
+ enableInteractiveIframe(player);
111
86
 
112
- // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
113
87
  const iframe = player.iframeElement;
114
88
  if (typeof ref === "function") {
115
89
  ref(iframe);
@@ -117,35 +91,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
117
91
  (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
118
92
  }
119
93
 
120
- // Prevent the web component's built-in click-to-toggle behavior.
121
- // The studio manages playback exclusively via useTimelinePlayer.
122
94
  const preventToggle = (e: Event) => e.stopImmediatePropagation();
123
95
  player.addEventListener("click", preventToggle, { capture: true });
124
96
 
125
- // Forward the iframe's native load event to the studio's onIframeLoad.
126
97
  const handleLoad = () => {
127
- loadCountRef.current++;
128
- // Reveal animation on reload (hot-reload, composition switch)
129
- if (loadCountRef.current > 1) {
130
- container.classList.remove("preview-revealing");
131
- void container.offsetWidth;
132
- container.classList.add("preview-revealing");
133
- const onEnd = () => container.classList.remove("preview-revealing");
134
- container.addEventListener("animationend", onEnd, { once: true });
135
- }
136
98
  onLoad();
137
99
 
138
- // Show a loading overlay until every `<video>`/`<audio>` and Lottie
139
- // asset is ready. Without this users can click play before audio has
140
- // buffered — the runtime is resilient (queued play() resolves once
141
- // data arrives), but the overlay communicates why the first frame
142
- // or first audio beat may lag.
143
- //
144
- // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
145
- // trips we hide the overlay so the UI doesn't appear stuck forever,
146
- // but we log a debug warning so the case is diagnosable — a long
147
- // cold video or a broken asset can legitimately exceed 10 s on a
148
- // slow network.
149
100
  if (assetPollRef.current) clearInterval(assetPollRef.current);
150
101
  let lastUnloaded = hasUnloadedAssets(iframe, false);
151
102
  if (lastUnloaded) {
@@ -158,11 +109,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
158
109
  if (assetPollRef.current) clearInterval(assetPollRef.current);
159
110
  assetPollRef.current = null;
160
111
  setAssetsLoading(false);
161
- if (lastUnloaded) {
162
- console.debug(
163
- "[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
164
- );
165
- }
166
112
  }
167
113
  }, 100);
168
114
  } else {
@@ -177,7 +123,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
177
123
  if (assetPollRef.current) clearInterval(assetPollRef.current);
178
124
  assetPollRef.current = null;
179
125
  container.removeChild(player);
180
- // Clear the forwarded ref
181
126
  if (typeof ref === "function") {
182
127
  ref(null);
183
128
  } else if (ref) {
@@ -193,7 +138,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
193
138
  });
194
139
 
195
140
  return (
196
- <div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
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
+ >
197
145
  <div ref={containerRef} className="w-full h-full" />
198
146
  {assetsLoading && (
199
147
  <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">