@hyperframes/studio 0.4.24 → 0.5.0-alpha.2

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 (36) hide show
  1. package/dist/assets/index-BExHzIDS.js +105 -0
  2. package/dist/assets/index-BpcIkyVP.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1327 -76
  6. package/src/components/editor/DomEditOverlay.tsx +410 -0
  7. package/src/components/editor/PropertyPanel.tsx +2462 -206
  8. package/src/components/editor/colorValue.test.ts +82 -0
  9. package/src/components/editor/colorValue.ts +175 -0
  10. package/src/components/editor/domEditing.test.ts +427 -0
  11. package/src/components/editor/domEditing.ts +733 -0
  12. package/src/components/editor/floatingPanel.test.ts +34 -0
  13. package/src/components/editor/floatingPanel.ts +54 -0
  14. package/src/components/editor/fontAssets.ts +32 -0
  15. package/src/components/editor/fontCatalog.ts +126 -0
  16. package/src/components/editor/gradientValue.test.ts +89 -0
  17. package/src/components/editor/gradientValue.ts +445 -0
  18. package/src/components/nle/NLELayout.tsx +9 -4
  19. package/src/components/nle/NLEPreview.tsx +50 -5
  20. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  22. package/src/components/sidebar/LeftSidebar.tsx +38 -33
  23. package/src/player/components/Player.tsx +18 -70
  24. package/src/player/components/Timeline.test.ts +0 -1
  25. package/src/player/components/Timeline.tsx +0 -3
  26. package/src/player/components/TimelineClip.tsx +20 -7
  27. package/src/player/components/timelineEditing.test.ts +0 -2
  28. package/src/player/components/timelineEditing.ts +0 -2
  29. package/src/player/hooks/useTimelinePlayer.ts +0 -17
  30. package/src/utils/mediaTypes.ts +1 -1
  31. package/src/utils/sourcePatcher.test.ts +128 -1
  32. package/src/utils/sourcePatcher.ts +130 -18
  33. package/src/utils/timelineAssetDrop.test.ts +31 -11
  34. package/src/utils/timelineAssetDrop.ts +22 -2
  35. package/dist/assets/index-CAscydDF.js +0 -115
  36. package/dist/assets/index-dpgHnQGg.css +0 -1
@@ -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>
@@ -88,40 +88,45 @@ export const LeftSidebar = memo(function LeftSidebar({
88
88
  style={{ width }}
89
89
  >
90
90
  {/* Tabs — Code first */}
91
- <div className="flex border-b border-neutral-800/50 flex-shrink-0">
92
- <button
93
- type="button"
94
- onClick={() => selectTab("code")}
95
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
96
- tab === "code"
97
- ? "text-neutral-200 border-b-2 border-studio-accent"
98
- : "text-neutral-500 hover:text-neutral-400"
99
- }`}
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" }}
100
95
  >
101
- Code
102
- </button>
103
- <button
104
- type="button"
105
- onClick={() => selectTab("compositions")}
106
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
107
- tab === "compositions"
108
- ? "text-neutral-200 border-b-2 border-studio-accent"
109
- : "text-neutral-500 hover:text-neutral-400"
110
- }`}
111
- >
112
- Compositions
113
- </button>
114
- <button
115
- type="button"
116
- onClick={() => selectTab("assets")}
117
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
118
- tab === "assets"
119
- ? "text-neutral-200 border-b-2 border-studio-accent"
120
- : "text-neutral-500 hover:text-neutral-400"
121
- }`}
122
- >
123
- Assets
124
- </button>
96
+ <button
97
+ type="button"
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
+ }`}
104
+ >
105
+ Code
106
+ </button>
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>
125
130
  </div>
126
131
 
127
132
  {/* Tab content */}
@@ -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">
@@ -144,7 +144,6 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
144
144
  expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
145
145
  });
146
146
  });
147
-
148
147
  describe("getTimelinePlayheadLeft", () => {
149
148
  it("converts time to a pixel offset from the gutter", () => {
150
149
  expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
@@ -131,7 +131,6 @@ export function getTimelineScrollLeftForZoomTransition(
131
131
  if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
132
132
  return currentScrollLeft;
133
133
  }
134
-
135
134
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
136
135
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
137
136
  return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
@@ -204,7 +203,6 @@ export function resolveTimelineAssetDrop(
204
203
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
205
204
  };
206
205
  }
207
-
208
206
  /* ── Component ──────────────────────────────────────────────────── */
209
207
  interface TimelineProps {
210
208
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -463,7 +461,6 @@ export const Timeline = memo(function Timeline({
463
461
  );
464
462
  previousZoomModeRef.current = zoomMode;
465
463
  }, [zoomMode]);
466
-
467
464
  useMountEffect(() => {
468
465
  const unsub = liveTime.subscribe((t) => {
469
466
  const dur = durationRef.current;
@@ -62,6 +62,25 @@ export const TimelineClip = memo(function TimelineClip({
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
64
  const showHandles = handleOpacity > 0.01;
65
+ const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
66
+ const glossBackgroundImage = isSelected
67
+ ? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
68
+ : "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
69
+ const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
70
+ isSelected ? "22" : "1e"
71
+ }, transparent 28%)`;
72
+ const compositionStripeBackgroundImage =
73
+ isComposition && !hasCustomContent
74
+ ? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
75
+ : undefined;
76
+ const clipBackgroundImage = [
77
+ compositionStripeBackgroundImage,
78
+ glossBackgroundImage,
79
+ accentBackgroundImage,
80
+ baseBackgroundImage,
81
+ ]
82
+ .filter(Boolean)
83
+ .join(", ");
65
84
 
66
85
  return (
67
86
  <div
@@ -75,13 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
75
94
  top: clipY,
76
95
  bottom: clipY,
77
96
  borderRadius: theme.clipRadius,
78
- background: isSelected
79
- ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
80
- : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
81
- backgroundImage:
82
- isComposition && !hasCustomContent
83
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
84
- : undefined,
97
+ backgroundImage: clipBackgroundImage,
85
98
  border: `1px solid ${borderColor}`,
86
99
  boxShadow,
87
100
  transition:
@@ -428,7 +428,6 @@ describe("buildClipRangeSelection", () => {
428
428
  });
429
429
  });
430
430
  });
431
-
432
431
  describe("resolveTimelineAutoScroll", () => {
433
432
  it("does not scroll when the pointer stays away from the edges", () => {
434
433
  expect(
@@ -512,7 +511,6 @@ describe("buildTimelineElementAgentPrompt", () => {
512
511
  ).toContain("If this clip is animated with GSAP");
513
512
  });
514
513
  });
515
-
516
514
  describe("resolveTimelineResize", () => {
517
515
  it("shrinks clip duration from the right edge", () => {
518
516
  expect(
@@ -273,7 +273,6 @@ export function buildClipRangeSelection(
273
273
  anchorY: anchor.anchorY,
274
274
  };
275
275
  }
276
-
277
276
  export function buildTimelineAgentPrompt({
278
277
  rangeStart,
279
278
  rangeEnd,
@@ -347,7 +346,6 @@ export function buildTimelineElementAgentPrompt(element: {
347
346
 
348
347
  return lines.join("\n");
349
348
  }
350
-
351
349
  export function formatTimelineAttributeNumber(value: number): string {
352
350
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
353
351
  }
@@ -244,7 +244,6 @@ function buildTimelineElementKey(params: {
244
244
  if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
245
245
  return `${scope}:${params.id}:${params.fallbackIndex}`;
246
246
  }
247
-
248
247
  function findTimelineDomNode(doc: Document, id: string): Element | null {
249
248
  return (
250
249
  doc.getElementById(id) ??
@@ -290,7 +289,6 @@ export function buildStandaloneRootTimelineElement(params: {
290
289
  sourceFile: compositionSrc,
291
290
  };
292
291
  }
293
-
294
292
  function normalizePreviewViewport(doc: Document, win: Window): void {
295
293
  if (doc.documentElement) {
296
294
  doc.documentElement.style.overflow = "hidden";
@@ -944,18 +942,6 @@ export function useTimelinePlayer() {
944
942
  setIsPlaying(false);
945
943
  }, [getAdapter, stopRAFLoop, setIsPlaying]);
946
944
 
947
- const refreshPlayer = useCallback(() => {
948
- const iframe = iframeRef.current;
949
- if (!iframe) return;
950
-
951
- saveSeekPosition();
952
-
953
- const src = iframe.src;
954
- const url = new URL(src, window.location.origin);
955
- url.searchParams.set("_t", String(Date.now()));
956
- iframe.src = url.toString();
957
- }, [saveSeekPosition]);
958
-
959
945
  const togglePlayRef = useRef(togglePlay);
960
946
  togglePlayRef.current = togglePlay;
961
947
  const getAdapterRef = useRef(getAdapter);
@@ -1053,8 +1039,6 @@ export function useTimelinePlayer() {
1053
1039
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1054
1040
  stopRAFLoop();
1055
1041
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1056
- // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1057
- // to prevent blink. New data will replace old when the iframe reloads.
1058
1042
  };
1059
1043
  });
1060
1044
 
@@ -1072,7 +1056,6 @@ export function useTimelinePlayer() {
1072
1056
  togglePlay,
1073
1057
  seek,
1074
1058
  onIframeLoad,
1075
- refreshPlayer,
1076
1059
  saveSeekPosition,
1077
1060
  resetPlayer,
1078
1061
  };
@@ -1,7 +1,7 @@
1
1
  export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
2
2
  export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
3
  export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
4
- export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
4
+ export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
5
5
  export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
6
6
 
7
7
  export function isMediaFile(path: string): boolean {