@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -29,6 +29,7 @@ import type {
29
29
  } from "./timelineClipDragTypes";
30
30
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
31
31
  import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
32
+ import { exceedsHandEditLimit } from "./timelineLinkSelection";
32
33
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
33
34
  import { useTimelineClipCapabilities } from "./timelineReadOnly";
34
35
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
@@ -158,11 +159,8 @@ export function useTimelineClipDrag({
158
159
  const elementsRef = useRef(elements);
159
160
  elementsRef.current = elements;
160
161
 
161
- // Perf (frozen-per-gesture): the snap-target set and the audio-track set are
162
- // fixed for the duration of one drag/resize (the store is not re-authored mid
163
- // gesture), so build each ONCE and reuse it across every pointermove and every
164
- // auto-scroll frame. Both caches are cleared at gesture teardown
165
- // (stopClipDragAutoScroll), so the next gesture rebuilds against fresh state.
162
+ // The snap-target and audio-track sets are fixed for one drag/resize (the store is not
163
+ // re-authored mid gesture): built once, reused per pointermove, cleared at teardown.
166
164
  const snapTargetsCacheRef = useRef<Map<string, TimelineSnapTarget[]>>(new Map());
167
165
  const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
168
166
 
@@ -272,8 +270,7 @@ export function useTimelineClipDrag({
272
270
  const blockedClipRef = useRef<BlockedClipState | null>(null);
273
271
  const suppressClickRef = useRef(false);
274
272
 
275
- // Active multi-select group-resize session, created lazily on first movement.
276
- // It owns a projection only; canonical store timing changes at commit.
273
+ // Group-resize session, created on first movement; a projection only, committed at the end.
277
274
  const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
278
275
 
279
276
  const onMoveElementRef = useRef(onMoveElement);
@@ -541,6 +538,8 @@ export function useTimelineClipDrag({
541
538
  if (!element || !getClipCapabilities(element).canMove) return;
542
539
  event.preventDefault();
543
540
  event.stopImmediatePropagation();
541
+ if (exceedsHandEditLimit(element, false, null))
542
+ return void onBlockedEditAttemptRef.current?.(element, "edit-many");
544
543
  setShowPopover(false);
545
544
  setRangeSelectionRef.current?.(null);
546
545
  const row = keyboardPickupInsertRow(
@@ -31,4 +31,10 @@ describe("timelineElementsChanged", () => {
31
31
  expect(timelineElementsChanged([grouped], [clip])).toBe(true);
32
32
  expect(timelineElementsChanged([grouped], [{ ...grouped }])).toBe(false);
33
33
  });
34
+
35
+ it("sees a change to a text row's words, but not an identical re-read", () => {
36
+ const row = { id: "t", tag: "h1", start: 0, duration: 2, track: 0, text: { value: "Old" } };
37
+ expect(timelineElementsChanged([row], [{ ...row, text: { value: "Old" } }])).toBe(false);
38
+ expect(timelineElementsChanged([row], [{ ...row, text: { value: "New" } }])).toBe(true);
39
+ });
34
40
  });
@@ -1,4 +1,5 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
+ import { sameTimelineText } from "../lib/timelineText";
2
3
 
3
4
  const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
4
5
  "id",
@@ -28,6 +29,10 @@ export function timelineElementsChanged(
28
29
  if (next.length !== previous.length) return true;
29
30
  return next.some((element, index) => {
30
31
  const prior = previous[index];
31
- return !prior || RENDERED_FIELDS.some((key) => element[key] !== prior[key]);
32
+ return (
33
+ !prior ||
34
+ RENDERED_FIELDS.some((key) => element[key] !== prior[key]) ||
35
+ !sameTimelineText(element.text, prior.text)
36
+ );
32
37
  });
33
38
  }
@@ -1,5 +1,13 @@
1
+ // @vitest-environment happy-dom
1
2
  import { describe, expect, it } from "vitest";
2
- import { isVideoAudible } from "./timelineElementHelpers";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+ import {
5
+ applyMediaMetadataFromElement,
6
+ getTimelineElementSelector,
7
+ isVideoAudible,
8
+ resolveMediaElement,
9
+ } from "./timelineElementHelpers";
10
+ import { readTimelineText, sameTimelineText } from "./timelineText";
3
11
 
4
12
  describe("isVideoAudible — the compiler's data-has-audio rule", () => {
5
13
  it("explicit data-has-audio wins", () => {
@@ -14,3 +22,81 @@ describe("isVideoAudible — the compiler's data-has-audio rule", () => {
14
22
  expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
15
23
  });
16
24
  });
25
+
26
+ describe("preview nodes built in another realm", () => {
27
+ // The Studio preview's body carries the editor window's prototypes, so its
28
+ // nodes fail `instanceof` against their own window's HTMLElement.
29
+ function foreignNode(html: string): Element {
30
+ const holder = document.createElement("div");
31
+ holder.innerHTML = html;
32
+ const node = holder.firstElementChild!;
33
+ document.body.appendChild(node);
34
+ Object.setPrototypeOf(node, Object.create(Element.prototype));
35
+ expect(node instanceof HTMLElement).toBe(false);
36
+ return node;
37
+ }
38
+
39
+ it("gives a row with an id its id selector", () => {
40
+ const el = foreignNode(`<div id="layer-00" class="clip layer"></div>`);
41
+ expect(getTimelineElementSelector(el)).toBe("#layer-00");
42
+ });
43
+
44
+ it("reads media metadata off a foreign <video>", () => {
45
+ const el = foreignNode(`<video src="a.mp4" data-source-duration="12"></video>`);
46
+ expect(resolveMediaElement(el)).toBe(el);
47
+ const entry = { id: "v", tag: "div", start: 0, duration: 4, track: 0 } as TimelineElement;
48
+ applyMediaMetadataFromElement(entry, el);
49
+ expect(entry.sourceDuration).toBe(12);
50
+ });
51
+ });
52
+
53
+ describe("readTimelineText", () => {
54
+ function layer(html: string): Element {
55
+ document.body.innerHTML = html;
56
+ return document.body.firstElementChild!;
57
+ }
58
+
59
+ it("reads a text layer's words, collapsed to one line, with its font and colour", () => {
60
+ const text = readTimelineText(
61
+ layer(`<h1 style="font-family: Georgia; font-weight: 700; color: rgb(255, 0, 0)">
62
+ Ship <span>it</span>
63
+ </h1>`),
64
+ );
65
+ expect(text).toMatchObject({ value: "Ship it", fontWeight: "700", color: "rgb(255, 0, 0)" });
66
+ expect(text?.fontFamily).toContain("Georgia");
67
+ });
68
+
69
+ it("leaves a layer that holds anything but text, or no words, to its picture", () => {
70
+ expect(
71
+ readTimelineText(layer(`<div><div>Title</div><div><i></i></div></div>`)),
72
+ ).toBeUndefined();
73
+ expect(readTimelineText(layer(`<div class="glyph"></div>`))).toBeUndefined();
74
+ expect(readTimelineText(layer(`<section>Words</section>`))).toBeUndefined();
75
+ });
76
+
77
+ it("keeps a layer that paints an image as a picture, and carries a flat background colour", () => {
78
+ expect(
79
+ readTimelineText(
80
+ layer(`<div style="background-image: linear-gradient(red, blue)">L01</div>`),
81
+ ),
82
+ ).toBeUndefined();
83
+ expect(
84
+ readTimelineText(layer(`<div style="background-color: rgb(230, 57, 70)">Go</div>`)),
85
+ ).toMatchObject({
86
+ value: "Go",
87
+ background: "rgb(230, 57, 70)",
88
+ });
89
+ expect(readTimelineText(layer(`<div>Go</div>`))?.background).toBeUndefined();
90
+ expect(
91
+ readTimelineText(layer(`<div style="background-color: rgba(255, 255, 255, 0.01)">Go</div>`))
92
+ ?.background,
93
+ ).toBeUndefined();
94
+ });
95
+
96
+ it("tells a text change apart from an identical re-read", () => {
97
+ const text = { value: "Old", color: "rgb(0, 0, 0)" };
98
+ expect(sameTimelineText(text, { ...text })).toBe(true);
99
+ expect(sameTimelineText(text, { ...text, value: "New" })).toBe(false);
100
+ expect(sameTimelineText(text, undefined)).toBe(false);
101
+ });
102
+ });
@@ -8,11 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { TimelineElement } from "../store/playerStore";
11
+ import { readTimelineText } from "./timelineText";
11
12
  import type { ClipManifestClip } from "./playbackTypes";
12
13
  import { isFinitePositive } from "./playbackAdapter";
13
14
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
15
  import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
15
16
  import { readElementFades } from "@hyperframes/core/audio-fade";
17
+ import { isHtmlElement, isImageElement, isMediaElement } from "@hyperframes/core/runtime/dom-realm";
16
18
  import { elementVolume } from "./storedVolume";
17
19
  import {
18
20
  type AttrReader,
@@ -143,11 +145,6 @@ export function furthestClipEndFromSource(source: string): number {
143
145
  // DOM element type guards
144
146
  // ---------------------------------------------------------------------------
145
147
 
146
- function isHtmlElement(el: Element): el is HTMLElement {
147
- const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
148
- return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
149
- }
150
-
151
148
  function isCompositionHost(el: Element): boolean {
152
149
  return (
153
150
  el.hasAttribute("data-composition-id") ||
@@ -157,16 +154,11 @@ function isCompositionHost(el: Element): boolean {
157
154
  }
158
155
 
159
156
  export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
160
- const win = el.ownerDocument.defaultView ?? window;
161
- const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
162
- const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
163
- if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
157
+ if (isMediaElement(el) || isImageElement(el)) return el;
164
158
  // A composition's media belongs to its own timeline, not the clip's: its length would cap a trim.
165
159
  if (isCompositionHost(el)) return null;
166
160
  const candidate = el.querySelector("video, audio, img");
167
- return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
168
- ? candidate
169
- : null;
161
+ return isMediaElement(candidate) || isImageElement(candidate) ? candidate : null;
170
162
  }
171
163
 
172
164
  /** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
@@ -255,6 +247,7 @@ function applyFadeMetadataFromElement(entry: TimelineElement, el: Element): void
255
247
  }
256
248
 
257
249
  export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
250
+ setOptional(entry, "text", readTimelineText(el));
258
251
  applyPlaybackMetadataFromElement(entry, el);
259
252
  applyAudioMetadataFromElement(entry, el);
260
253
  applyFadeMetadataFromElement(entry, el);
@@ -266,9 +259,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
266
259
  const src = mediaEl.getAttribute("src");
267
260
  if (src) entry.src = src;
268
261
 
269
- const win = mediaEl.ownerDocument.defaultView ?? window;
270
- const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
271
- if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
262
+ if (!isMediaElement(mediaEl)) return;
272
263
 
273
264
  const sourceDurationAttr =
274
265
  el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -0,0 +1,40 @@
1
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
2
+ import { parseCssColor } from "../../components/editor/colorValue";
3
+ import type { TimelineText } from "../store/timelineElement";
4
+ import {
5
+ getCuratedComputedStyles,
6
+ isEditableTextLeaf,
7
+ isTextBearingTag,
8
+ } from "../../components/editor/domEditingDom";
9
+
10
+ /** A layer that paints only text and a flat colour (a text tag whose children are all text leaves,
11
+ * no background image): its words and look. Anything painted beyond that keeps its picture. */
12
+ export function readTimelineText(el: Element): TimelineText | undefined {
13
+ if (!isHtmlElement(el) || !isTextBearingTag(el.localName)) return undefined;
14
+ const children = Array.from(el.children);
15
+ if (!children.every((child) => isHtmlElement(child) && isEditableTextLeaf(child)))
16
+ return undefined;
17
+ const value = el.textContent?.replace(/\s+/g, " ").trim();
18
+ if (!value) return undefined;
19
+ const style = getCuratedComputedStyles(el);
20
+ if ((style["background-image"] ?? "none") !== "none") return undefined;
21
+ const background = style["background-color"];
22
+ return {
23
+ value,
24
+ fontFamily: style["font-family"],
25
+ fontWeight: style["font-weight"],
26
+ color: style.color,
27
+ background: background && parseCssColor(background)?.alpha === 1 ? background : undefined,
28
+ };
29
+ }
30
+
31
+ export function sameTimelineText(a?: TimelineText, b?: TimelineText): boolean {
32
+ if (!a || !b) return a === b;
33
+ return (
34
+ a.value === b.value &&
35
+ a.fontFamily === b.fontFamily &&
36
+ a.fontWeight === b.fontWeight &&
37
+ a.color === b.color &&
38
+ a.background === b.background
39
+ );
40
+ }
@@ -95,6 +95,17 @@ export interface TimelineElement {
95
95
  authoredStartIsMasterTime?: boolean;
96
96
  /** Legacy marker for an inline sub-composition child; current rows never set it. */
97
97
  expandedHostKey?: string;
98
+ /** A text layer's words and look, which its row draws live instead of a captured picture. */
99
+ text?: TimelineText;
100
+ }
101
+
102
+ export interface TimelineText {
103
+ value: string;
104
+ fontFamily?: string;
105
+ fontWeight?: string;
106
+ color?: string;
107
+ /** The layer's own opaque background colour, when it paints one. */
108
+ background?: string;
98
109
  }
99
110
 
100
111
  type RowClock = Pick<
@@ -156,6 +167,7 @@ export type TimelineElementPatch = Partial<
156
167
  | "audioGroupHidden"
157
168
  | "audioGroupFxChain"
158
169
  | "audioGroupAutomation"
170
+ | "text"
159
171
  >
160
172
  >;
161
173
 
@@ -146,6 +146,9 @@
146
146
  transparent 100%
147
147
  );
148
148
  --timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
149
+ /* A text clip's strip behind its live words: dark for light text, light for dark text. */
150
+ --timeline-text-clip-dark-bg: #1c2028;
151
+ --timeline-text-clip-light-bg: #e9e6df;
149
152
  --timeline-waveform-bar-rgb: 245, 237, 255;
150
153
  --timeline-waveform-muted-rgb: 236, 238, 242;
151
154
  --timeline-waveform-baseline-rgb: 226, 216, 238;
@@ -5,6 +5,7 @@ export interface FrameCaptureRequest {
5
5
  compositionPath: string | null;
6
6
  currentTime: number;
7
7
  origin?: string;
8
+ version?: number;
8
9
  }
9
10
 
10
11
  function normalizeCompositionPath(compositionPath: string | null): string {
@@ -16,6 +17,7 @@ export function buildFrameCaptureUrl({
16
17
  compositionPath,
17
18
  currentTime,
18
19
  origin = window.location.origin,
20
+ version = Date.now(),
19
21
  }: FrameCaptureRequest): string {
20
22
  const compPath = normalizeCompositionPath(compositionPath);
21
23
  const url = new URL(
@@ -25,7 +27,7 @@ export function buildFrameCaptureUrl({
25
27
  url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
26
28
  url.searchParams.set("format", "png");
27
29
  url.searchParams.set("output", "source");
28
- url.searchParams.set("v", String(Date.now()));
30
+ url.searchParams.set("v", String(version));
29
31
  return url.toString();
30
32
  }
31
33
 
@@ -6,12 +6,14 @@ import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import puppeteer, { type Browser } from "puppeteer-core";
8
8
  import { build } from "vite";
9
- import { afterAll, beforeAll, expect, it } from "vitest";
9
+ import { afterAll, beforeAll, expect, it, vi } from "vitest";
10
10
  import { findSystemChrome } from "../../vite.browser";
11
11
  import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
12
12
 
13
13
  const require = createRequire(import.meta.url);
14
14
  const CROP = "inset(0px 40px 0px 0px)";
15
+ // Real Chrome on a loaded Windows runner exceeds Vitest's 5s test and 10s hook defaults.
16
+ vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
15
17
  let browser: Browser;
16
18
  let undoBundle: string;
17
19
 
@@ -37,7 +39,7 @@ beforeAll(async () => {
37
39
  });
38
40
  undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
39
41
  .code;
40
- }, 60_000);
42
+ });
41
43
 
42
44
  afterAll(() => browser?.close());
43
45
 
@@ -0,0 +1,79 @@
1
+ import type { CommitMutationOptions, MutationResult } from "../hooks/gsapScriptCommitTypes";
2
+ import { trackStudioEvent } from "./studioTelemetry";
3
+
4
+ export type KeyframeUsageAction = "add" | "convert" | "remove_all" | "reset";
5
+
6
+ export function keyframeUsageActions(
7
+ mutations: ReadonlyArray<Record<string, unknown>>,
8
+ semanticActions?: ReadonlyArray<KeyframeUsageAction | undefined>,
9
+ ): Set<KeyframeUsageAction> {
10
+ const actions = new Set<KeyframeUsageAction>();
11
+ for (const [index, mutation] of mutations.entries()) {
12
+ const semanticAction = semanticActions?.[index];
13
+ if (semanticAction) {
14
+ actions.add(semanticAction);
15
+ continue;
16
+ }
17
+ switch (mutation.type) {
18
+ case "add-keyframe":
19
+ case "add-with-keyframes":
20
+ actions.add("add");
21
+ break;
22
+ case "convert-to-keyframes":
23
+ actions.add("convert");
24
+ break;
25
+ case "remove-all-keyframes":
26
+ actions.add("remove_all");
27
+ break;
28
+ }
29
+ }
30
+ return actions;
31
+ }
32
+
33
+ export function trackKeyframeUsage(action: KeyframeUsageAction, property?: string): void {
34
+ trackStudioEvent("keyframe", property === undefined ? { action } : { action, property });
35
+ }
36
+
37
+ export function primaryKeyframeAction(
38
+ actions: ReadonlySet<KeyframeUsageAction>,
39
+ ): KeyframeUsageAction | undefined {
40
+ return (["add", "convert", "remove_all", "reset"] as const).find((action) => actions.has(action));
41
+ }
42
+
43
+ export function changedMutationIndices(
44
+ result: MutationResult,
45
+ count: number,
46
+ options?: ReadonlyArray<Pick<CommitMutationOptions, "keyframeTelemetry">>,
47
+ ): number[] {
48
+ if (!result.ok || result.changed !== true) return [];
49
+ const changes = count === 1 ? [true] : result.mutationChanges;
50
+ if (
51
+ !Array.isArray(changes) ||
52
+ changes.length !== count ||
53
+ changes.some((value) => typeof value !== "boolean")
54
+ )
55
+ return [];
56
+ return changes.flatMap((changed, index) =>
57
+ changed && options?.[index]?.keyframeTelemetry !== false ? [index] : [],
58
+ );
59
+ }
60
+
61
+ export function trackKeyframeCommit(
62
+ mutations: ReadonlyArray<Record<string, unknown>>,
63
+ result: MutationResult,
64
+ options: CommitMutationOptions,
65
+ memberOptions?: ReadonlyArray<CommitMutationOptions>,
66
+ ): void {
67
+ if (!result.ok || result.changed !== true || options.keyframeTelemetry === false) return;
68
+ const indices = changedMutationIndices(result, mutations.length, memberOptions);
69
+ const actions = keyframeUsageActions(
70
+ indices.map((index) => mutations[index]!),
71
+ indices.map(
72
+ (index) =>
73
+ memberOptions?.[index]?.keyframeAction ??
74
+ (mutations.length === 1 ? options.keyframeAction : undefined),
75
+ ),
76
+ );
77
+ const action = primaryKeyframeAction(actions);
78
+ if (action) trackKeyframeUsage(action, action === "add" ? options.keyframeProperty : undefined);
79
+ }
@@ -0,0 +1,32 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { trackPreviewEditResult } from "./previewFeatureUsage";
3
+ import { trackStudioEvent } from "./studioTelemetry";
4
+ vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
5
+ beforeEach(() => vi.clearAllMocks());
6
+ describe("preview write usage", () => {
7
+ it.each([
8
+ { ok: true, changed: true },
9
+ {
10
+ ok: true,
11
+ persistence: { changed: true, sourceFile: "private.html", content: "private text" },
12
+ },
13
+ ])("sends only gesture metadata after a changed result", (result) => {
14
+ trackPreviewEditResult("resize", "drag", result);
15
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
16
+ feature: "resize",
17
+ surface: "preview",
18
+ method: "drag",
19
+ });
20
+ });
21
+ it.each([
22
+ undefined,
23
+ null,
24
+ {},
25
+ { ok: false, changed: true },
26
+ { changed: false },
27
+ { persistence: { changed: false } },
28
+ ])("does not count unavailable, failed or unchanged results (%j)", (result) => {
29
+ trackPreviewEditResult("move", "drag", result);
30
+ expect(trackStudioEvent).not.toHaveBeenCalled();
31
+ });
32
+ });
@@ -0,0 +1,46 @@
1
+ import { trackStudioEvent } from "./studioTelemetry";
2
+
3
+ export type PreviewFeature =
4
+ | "move"
5
+ | "resize"
6
+ | "rotate"
7
+ | "crop"
8
+ | "nudge"
9
+ | "multi_select"
10
+ | "z_order"
11
+ | "text_edit"
12
+ | "motion_path"
13
+ | "gesture_recording"
14
+ | "snapping"
15
+ | "grid"
16
+ | "grid_spacing"
17
+ | "snap_to_grid"
18
+ | "ruler"
19
+ | "safe_margins";
20
+
21
+ export type PreviewMethod = "drag" | "button" | "keyboard" | "field";
22
+
23
+ export interface GeometryCommitResult {
24
+ ok: true;
25
+ changed: boolean;
26
+ }
27
+
28
+ export function trackPreviewFeatureUsed(feature: PreviewFeature, method: PreviewMethod): void {
29
+ trackStudioEvent("feature_used", { feature, surface: "preview", method });
30
+ }
31
+
32
+ export function trackPreviewEditResult(
33
+ feature: PreviewFeature,
34
+ method: PreviewMethod,
35
+ result: unknown,
36
+ ): void {
37
+ if (typeof result !== "object" || result === null) return;
38
+ if (Reflect.get(result, "ok") === false) return;
39
+ const persistence = Reflect.get(result, "persistence");
40
+ const changed =
41
+ Reflect.get(result, "changed") === true ||
42
+ (typeof persistence === "object" &&
43
+ persistence !== null &&
44
+ Reflect.get(persistence, "changed") === true);
45
+ if (changed) trackPreviewFeatureUsed(feature, method);
46
+ }
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { buildProjectHash } from "./projectRouting";
3
4
 
4
5
  // The `studio:*` path predates telemetry/config.ts and shipped its own
5
6
  // opt-out key and its own send loop, so it sat outside both contracts the
@@ -25,6 +26,8 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
25
26
  beforeEach(async () => {
26
27
  policyState.allowed = true;
27
28
  localStorage.clear();
29
+ sessionStorage.clear();
30
+ window.location.hash = "";
28
31
  vi.resetModules();
29
32
  vi.useFakeTimers();
30
33
  fetchMock = vi.fn(() => Promise.resolve({ ok: true } as Response));
@@ -33,8 +36,10 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
33
36
  });
34
37
 
35
38
  afterEach(() => {
39
+ window.location.hash = "";
36
40
  vi.useRealTimers();
37
41
  vi.unstubAllGlobals();
42
+ vi.restoreAllMocks();
38
43
  });
39
44
 
40
45
  /** Drain the queue and return the events the batch would have sent. */
@@ -87,4 +92,106 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
87
92
  "$feature/canary-test-one": "false",
88
93
  });
89
94
  });
95
+
96
+ it.each(["fetch", "beacon"] as const)(
97
+ "excludes project names from the complete serialized %s batch",
98
+ async (transport) => {
99
+ const projectName = "Launch #1? v2";
100
+ window.location.hash = buildProjectHash(projectName, { selSelector: "#private-element" });
101
+ const beacon = vi.fn((_url: string, _body: string) => true);
102
+ vi.stubGlobal("navigator", { userAgent: "test", sendBeacon: beacon });
103
+ for (const event of ["feature_used", "keyframe", "design_input", "toolbar_action"]) {
104
+ trackStudioEvent(event);
105
+ }
106
+ if (transport === "beacon") {
107
+ const { flushViaBeacon } = await import("./studioTelemetry");
108
+ flushViaBeacon();
109
+ } else {
110
+ await vi.runOnlyPendingTimersAsync();
111
+ }
112
+ const serialized =
113
+ transport === "beacon"
114
+ ? beacon.mock.calls[0]?.[1]
115
+ : (fetchMock.mock.calls[0]?.[1] as RequestInit)?.body;
116
+ expect(typeof serialized).toBe("string");
117
+ const body = serialized as string;
118
+ expect(body).not.toContain(projectName);
119
+ expect(body).not.toContain(encodeURIComponent(projectName));
120
+ expect(body).not.toContain("private-element");
121
+ expect(body).not.toContain("7aef12cc");
122
+ const payload = JSON.parse(body);
123
+ expect(payload.batch).toHaveLength(4);
124
+ for (const event of payload.batch) {
125
+ expect(event.properties.url_hash).toBe("project");
126
+ expect(event.properties.url_route_id).toMatch(/^[0-9a-f]{8}$/);
127
+ }
128
+ },
129
+ );
130
+
131
+ it.each([
132
+ ["", "home"],
133
+ ["#", "home"],
134
+ ["#unknown/Launch%20%231%3F%20v2?selection=secret", "other"],
135
+ ])("sends a finite route kind for %s", async (hash, routeKind) => {
136
+ window.location.hash = hash;
137
+ trackStudioEvent("thing_happened");
138
+ const events = await sentEvents();
139
+ expect(events[0]?.["properties"]).toMatchObject({ url_hash: routeKind });
140
+ });
141
+
142
+ it("keeps route identity stable across query changes and distinct across projects", async () => {
143
+ window.location.hash = buildProjectHash("Launch #1? v2");
144
+ trackStudioEvent("session_start");
145
+ window.location.hash = buildProjectHash("Launch #1? v2", { selSelector: "#another-element" });
146
+ trackStudioEvent("feature_used");
147
+ window.location.hash = buildProjectHash("Another launch");
148
+ trackStudioEvent("feature_used");
149
+ const events = await sentEvents();
150
+ const ids = events.map(
151
+ (event) => (event["properties"] as Record<string, unknown>)["url_route_id"],
152
+ );
153
+ expect(ids[0]).toMatch(/^[0-9a-f]{8}$/);
154
+ expect(ids[1]).toBe(ids[0]);
155
+ expect(ids[2]).not.toBe(ids[0]);
156
+ expect(ids[2]).toMatch(/^[0-9a-f]{8}$/);
157
+ });
158
+ it("reuses a route ID after reloading the same tab", async () => {
159
+ window.location.hash = buildProjectHash("Launch #1? v2");
160
+ trackStudioEvent("session_start");
161
+ const first = (await sentEvents())[0]?.properties as Record<string, unknown>;
162
+ vi.clearAllTimers();
163
+ vi.resetModules();
164
+ fetchMock.mockClear();
165
+ ({ trackStudioEvent } = await import("./studioTelemetry"));
166
+ trackStudioEvent("session_start");
167
+ const second = (await sentEvents())[0]?.properties as Record<string, unknown>;
168
+ expect(second.url_route_id).toBe(first.url_route_id);
169
+ });
170
+
171
+ it("assigns different random IDs to the same route in two tabs", async () => {
172
+ const random = vi.spyOn(crypto, "getRandomValues");
173
+ random.mockImplementationOnce((bytes) => {
174
+ (bytes as Uint8Array).set([1, 2, 3, 4]);
175
+ return bytes;
176
+ });
177
+ window.location.hash = buildProjectHash("Launch #1? v2");
178
+ trackStudioEvent("session_start");
179
+ const first = (await sentEvents())[0]?.properties as Record<string, unknown>;
180
+ // A second independent tab has its own sessionStorage and module instance.
181
+ sessionStorage.clear();
182
+ vi.clearAllTimers();
183
+ vi.resetModules();
184
+ fetchMock.mockClear();
185
+ random.mockImplementationOnce((bytes) => {
186
+ (bytes as Uint8Array).set([5, 6, 7, 8]);
187
+ return bytes;
188
+ });
189
+ ({ trackStudioEvent } = await import("./studioTelemetry"));
190
+ trackStudioEvent("session_start");
191
+ const second = (await sentEvents())[0]?.properties as Record<string, unknown>;
192
+ expect(first.url_route_id).toBe("01020304");
193
+ expect(second.url_route_id).toBe("05060708");
194
+ expect(second.url_route_id).not.toBe(first.url_route_id);
195
+ expect(random).toHaveBeenCalledTimes(2);
196
+ });
90
197
  });