@hyperframes/studio 0.7.77 → 0.7.79

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 (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.css +0 -1
@@ -1,6 +1,11 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../player";
3
3
  import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
4
+ import {
5
+ timelineTrackOrder,
6
+ trackDisplayNumber,
7
+ trackDisplaySuffix,
8
+ } from "../player/components/timelineTrackDisplay";
4
9
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
10
  import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
6
11
  import {
@@ -208,12 +213,17 @@ export async function toggleTimelineTrackHidden({
208
213
  domEditSaveTimestampRef,
209
214
  pendingTimelineEditPathRef,
210
215
  }: ToggleTimelineTrackHiddenInput): Promise<string[]> {
216
+ // `track` is the fractional sort key the callback needs; the history entry is
217
+ // read by a human, so it gets the display row instead.
218
+ const suffix = trackDisplaySuffix(
219
+ trackDisplayNumber(timelineTrackOrder(timelineElements), track),
220
+ );
211
221
  return setElementsHidden({
212
222
  projectId,
213
223
  activeCompPath,
214
224
  elements: timelineElements.filter((element) => element.track === track),
215
225
  hidden,
216
- label: hidden ? `Hide track ${track}` : `Show track ${track}`,
226
+ label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
217
227
  previewIframe,
218
228
  writeProjectFile,
219
229
  recordEdit,
@@ -14,7 +14,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
- import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
17
+ import {
18
+ selectorFromSelection,
19
+ computeElementPercentage,
20
+ isInstantHold,
21
+ writeTargetSelector,
22
+ tweenTargetsElement,
23
+ } from "./gsapShared";
18
24
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
25
  import { roundTo3 } from "../utils/rounding";
20
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
@@ -185,7 +191,9 @@ async function commitStaticSet(
185
191
  batch.push(entry);
186
192
  byGroup.set(group, batch);
187
193
  }
188
- const staticWrites = animations.filter((a) => isInstantHold(a) && a.targetSelector === selector);
194
+ const staticWrites = animations.filter(
195
+ (a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
196
+ );
189
197
  // Resolve every group's target BEFORE committing anything, and coalesce
190
198
  // groups that land on the SAME write into one commit: the snapshot is captured
191
199
  // once, so if two groups resolved to one legacy mixed write, a first
@@ -206,7 +214,7 @@ async function commitStaticSet(
206
214
  }
207
215
  // Fresh adds don't reshape existing sets, so their ids can't go stale.
208
216
  for (const batch of newSetBatches) {
209
- await addGlobalStaticSet(selection, batch, selector, commit);
217
+ await addGlobalStaticSet(selection, batch, commit);
210
218
  }
211
219
  }
212
220
 
@@ -236,18 +244,22 @@ function findGroupOwningStaticWrite(
236
244
  async function addGlobalStaticSet(
237
245
  selection: DomEditSelection,
238
246
  batch: [string, number | string][],
239
- selector: string,
240
247
  commit: Commit,
241
248
  ): Promise<void> {
242
249
  const numericProps: SetPatchProps = {};
243
250
  for (const [k, v] of batch) {
244
251
  if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
245
252
  }
253
+ // A brand-new write, so it must address ONE element: the selection's own
254
+ // selector is the bare class an id-less element yields, which would hold every
255
+ // sibling. No one-element form means no write at all (see writeTargetSelector).
256
+ const target = writeTargetSelector(selection);
257
+ if (!target) return;
246
258
  await commit(
247
259
  selection,
248
260
  {
249
261
  type: "add",
250
- targetSelector: selector,
262
+ targetSelector: target,
251
263
  method: "set",
252
264
  position: 0,
253
265
  properties: Object.fromEntries(batch),
@@ -259,7 +271,7 @@ async function addGlobalStaticSet(
259
271
  ...(Object.keys(numericProps).length > 0
260
272
  ? {
261
273
  instantPatch: {
262
- selector,
274
+ selector: target,
263
275
  change: { kind: "global-set" as const, props: numericProps },
264
276
  },
265
277
  }
@@ -482,7 +494,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
482
494
  // contaminating a foreign-group tween. Mirror an existing keyframed tween's
483
495
  // time range so the new group animates over the same span. The 0% baseline is
484
496
  // an `_auto` endpoint so it tracks the nearest keyframe as you add more.
485
- if (selector) {
497
+ // A fresh tween, so its target must address ONE element; with no
498
+ // one-element form the edit is dropped rather than written onto every
499
+ // class sibling (see writeTargetSelector).
500
+ const newTweenTarget = writeTargetSelector(selection);
501
+ if (selector && newTweenTarget) {
486
502
  const template = selectedGsapAnimations.find((a) => !!a.keyframes);
487
503
  const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
488
504
  const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -503,7 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
503
519
  selection,
504
520
  {
505
521
  type: "add-with-keyframes",
506
- targetSelector: selector,
522
+ targetSelector: newTweenTarget,
507
523
  position: roundTo3(tStart),
508
524
  duration: roundTo3(tDur),
509
525
  keyframes,
@@ -466,3 +466,47 @@ describe("useEnableKeyframes — flat tween transaction", () => {
466
466
  });
467
467
  });
468
468
  });
469
+
470
+ describe("useEnableKeyframes — new tween on a class-only element", () => {
471
+ it("targets the selected sibling alone, not every element sharing its class", async () => {
472
+ window.location.hash = "#/project/test-project";
473
+ usePlayerStore.setState({ currentTime: 1 });
474
+ vi.stubGlobal(
475
+ "fetch",
476
+ vi.fn(async () => ({ ok: true, json: async () => ({ animations: [] }) })),
477
+ );
478
+ const scene = document.body.appendChild(document.createElement("div"));
479
+ scene.id = "scene";
480
+ scene.innerHTML = '<i class="group"></i>'.repeat(5);
481
+ const groups = Array.from(scene.querySelectorAll<HTMLElement>(".group"));
482
+ const commitMutation = vi.fn(async () => undefined);
483
+ const enable = renderEnableKeyframes({
484
+ // A bare `.group` here writes `tl.to(".group", …)`, which animates all five
485
+ // siblings and reads back as all five — one add wiped the timeline.
486
+ domEditSelection: {
487
+ selector: ".group",
488
+ selectorIndex: 3,
489
+ sourceFile: "index.html",
490
+ element: groups[3],
491
+ dataAttributes: { start: "0", duration: "2" },
492
+ } as unknown as DomEditSelection,
493
+ selectedGsapAnimations: [],
494
+ previewIframeRef: {
495
+ current: {
496
+ contentWindow: { gsap: { getProperty: () => 7 } },
497
+ } as unknown as HTMLIFrameElement,
498
+ },
499
+ handleGsapAddAnimation: vi.fn(),
500
+ handleGsapConvertToKeyframes: vi.fn(),
501
+ handleGsapRemoveKeyframe: vi.fn(),
502
+ commitMutation,
503
+ });
504
+
505
+ await act(async () => enable());
506
+
507
+ const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
508
+ expect(mutation?.targetSelector).toBeTruthy();
509
+ expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
510
+ expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
511
+ });
512
+ });
@@ -13,11 +13,12 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
15
  import {
16
- selectorFromSelection,
16
+ existingTweenTargetSelector,
17
17
  computeElementPercentage,
18
18
  KEYFRAME_PCT_MATCH,
19
19
  isInstantHold,
20
20
  resolveEditableTweenDuration,
21
+ writeTargetSelector,
21
22
  } from "./gsapShared";
22
23
  import {
23
24
  absoluteToPercentage,
@@ -250,7 +251,10 @@ async function extendKeyframedTweenToPlayhead(
250
251
  iframe: HTMLIFrameElement | null,
251
252
  commitOverrides?: Partial<CommitMutationOptions>,
252
253
  ): Promise<void> {
253
- const selector = selectorFromSelection(sel);
254
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
255
+ // than re-deriving one from the selection, which would widen a tween already
256
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
257
+ const selector = existingTweenTargetSelector(anim, sel);
254
258
  const position = readElementPosition(iframe, sel, anim);
255
259
  if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
256
260
  const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
@@ -335,7 +339,10 @@ export async function promoteSetToKeyframes(
335
339
  t: number,
336
340
  iframe: HTMLIFrameElement | null,
337
341
  ): Promise<void> {
338
- const selector = selectorFromSelection(sel);
342
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
343
+ // than re-deriving one from the selection, which would widen a tween already
344
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
345
+ const selector = existingTweenTargetSelector(setAnim, sel);
339
346
  const setStart = resolveTweenStart(setAnim) ?? 0;
340
347
  if (!selector || !session.commitMutation) return;
341
348
  // Playhead at or before the set → there's no forward range to promote into.
@@ -389,7 +396,10 @@ export async function applyArcKeyframeAtPlayhead(
389
396
  iframe: HTMLIFrameElement | null,
390
397
  ): Promise<void> {
391
398
  if (!session.commitMutation) return;
392
- const targetSelector = selectorFromSelection(sel);
399
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
400
+ // than re-deriving one from the selection, which would widen a tween already
401
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
402
+ const targetSelector = existingTweenTargetSelector(arcAnim, sel);
393
403
  if (!targetSelector) return;
394
404
  const start = resolveTweenStart(arcAnim) ?? 0;
395
405
  const duration = resolveEditableTweenDuration(arcAnim, sel);
@@ -523,7 +533,10 @@ export function useEnableKeyframes(
523
533
  sel.dataAttributes?.duration,
524
534
  t,
525
535
  );
526
- const selector = selectorFromSelection(sel);
536
+ // A brand-new tween: author it against the one element the user selected.
537
+ // The bare class selectorFromSelection hands back for an id-less element
538
+ // animates every sibling sharing the class (see writeTargetSelector).
539
+ const selector = writeTargetSelector(sel);
527
540
 
528
541
  if (!selector) {
529
542
  session.handleGsapAddAnimation("to");
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
13
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
14
14
  import { roundTo3 } from "../utils/rounding";
15
15
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
16
- import { isInstantHold, idSelector } from "./gsapShared";
16
+ import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
17
17
 
18
18
  type RecordedKeyframe = {
19
19
  percentage: number;
@@ -168,11 +168,24 @@ export function useGestureCommit({
168
168
  if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
169
169
  }
170
170
 
171
+ // Two different jobs, two different selectors. `selector` is the string an
172
+ // ALREADY-AUTHORED tween is matched against (and retargeted with, so a
173
+ // tween aimed at a whole group stays aimed at it). `writeSelector` is what
174
+ // a NEW tween is authored with: the bare class the id-less case yields here
175
+ // would record the gesture onto every sibling sharing it.
171
176
  const selector = sel.id ? idSelector(sel.id) : sel.selector;
172
177
  if (!selector) {
173
178
  showToast("Cannot save — element has no selector", "error");
174
179
  return;
175
180
  }
181
+ // A recorded gesture becomes a NEW tween, so its target must address one
182
+ // element; the selection's own selector would record the motion onto
183
+ // every sibling sharing its class (see writeTargetSelector).
184
+ const writeSelector = writeTargetSelector(sel);
185
+ if (!writeSelector) {
186
+ showToast("Cannot save: element has no unique selector", "error");
187
+ return;
188
+ }
176
189
  if (liveSession.commitMutation) {
177
190
  const recStart = recordingStartTimeRef.current;
178
191
  const rawKeyframes = sortedPcts.map((pct) => ({
@@ -186,7 +199,11 @@ export function useGestureCommit({
186
199
  );
187
200
  const allAnims = liveSession.selectedGsapAnimations ?? [];
188
201
  const existingPositionTween = hasPositionProps
189
- ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
202
+ ? allAnims.find(
203
+ (a) =>
204
+ a.propertyGroup === "position" &&
205
+ tweenTargetsElement(a.targetSelector, selector, sel.element),
206
+ )
190
207
  : undefined;
191
208
  if (existingPositionTween) {
192
209
  if (isInstantHold(existingPositionTween)) {
@@ -261,7 +278,7 @@ export function useGestureCommit({
261
278
  await liveSession.commitMutation(
262
279
  {
263
280
  type: "add-with-keyframes",
264
- targetSelector: selector,
281
+ targetSelector: writeSelector,
265
282
  position: roundTo3(recStart),
266
283
  duration: roundTo3(duration),
267
284
  keyframes: groupKfs,
@@ -287,7 +304,7 @@ export function useGestureCommit({
287
304
  await liveSession.commitMutation(
288
305
  {
289
306
  type: "add-with-keyframes",
290
- targetSelector: selector,
307
+ targetSelector: writeSelector,
291
308
  position: roundTo3(recStart),
292
309
  duration: roundTo3(duration),
293
310
  keyframes: groupKfs,
@@ -186,6 +186,9 @@ function syncCommittedGsapMutation({
186
186
  targetPath,
187
187
  selection.id ?? undefined,
188
188
  mutation,
189
+ // The live preview document is what resolves a class / descendant tween to
190
+ // the elements it really animates; without it only whole-id selectors do.
191
+ iframe?.contentDocument,
189
192
  );
190
193
  }
191
194
  refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
@@ -86,16 +86,24 @@ describe("resolveSelectorElementIds", () => {
86
86
  expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
87
87
  });
88
88
 
89
- it("falls back to a leading #id when there is no DOM", () => {
90
- expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
89
+ // A DOM-less LEADING-id fallback attributed `#card .label` to `#card`, the
90
+ // ancestor it merely scopes to. Without a DOM there is nothing to resolve the
91
+ // descendant against, so the honest answer is no element at all.
92
+ it("resolves nothing for a compound selector when there is no DOM", () => {
93
+ expect(resolveSelectorElementIds("#card .label", null)).toEqual([]);
91
94
  expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
92
95
  });
93
96
 
97
+ it("still resolves every whole-id part of a group selector without a DOM", () => {
98
+ expect(resolveSelectorElementIds("#a, #b", null)).toEqual(["a", "b"]);
99
+ });
100
+
94
101
  // The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
95
102
  // dotted). The old local `#id`-only regex read no id at all for those, so they
96
103
  // silently dropped out of both DOM-less paths.
97
104
  it("falls back to a bracketed id when there is no DOM", () => {
98
- expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]);
105
+ expect(resolveSelectorElementIds('[id="01-hook"]', null)).toEqual(["01-hook"]);
106
+ expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual([]);
99
107
  });
100
108
 
101
109
  it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
@@ -7,24 +7,17 @@ import {
7
7
  pruneKeyframeCacheToFiles,
8
8
  writeGsapAnimationsForElement,
9
9
  } from "./gsapKeyframeCacheHelpers";
10
- import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
10
+ import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
11
11
  import {
12
12
  deduplicateKeyframes,
13
13
  isStaticPositionHold,
14
14
  synthesizeFlatTweenKeyframes,
15
15
  } from "./gsapTweenSynth";
16
- import {
17
- fetchParsedAnimations,
18
- populateKeyframeCacheFromAst,
19
- resolveClipTimingBasis,
20
- } from "./keyframeCacheAstLoad";
16
+ import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
21
17
 
22
18
  // Re-exported so callers keep importing the GSAP cache surface from one module.
23
- export {
24
- fetchParsedAnimations,
25
- resolveClipTimingBasis,
26
- resolveSelectorElementIds,
27
- } from "./keyframeCacheAstLoad";
19
+ export { resolveClipTimingBasis } from "./gsapShared";
20
+ export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
28
21
 
29
22
  /** The selected element's identity for matching tweens to it. */
30
23
  export interface GsapElementTarget {
@@ -22,7 +22,10 @@ export function LayerDisclosureRow({
22
22
  /** Same adaptive width the lane rows use: a narrowed header column must not
23
23
  * leave this row hanging over the clips it labels. */
24
24
  columnWidth: number;
25
- /** Id of the element holding the lanes this row's caret expands. */
25
+ /** Id of the CANVAS-side element holding the diamond lanes this row's caret
26
+ * expands (see TimelinePropertyLanes). The caret also reveals the per-lane
27
+ * control rows in this column, but the diamonds are what following the
28
+ * reference should land on. */
26
29
  lanesId: string;
27
30
  onToggleClipExpanded: () => void;
28
31
  /** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
@@ -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