@hyperframes/studio 0.8.15 → 0.8.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.15",
3
+ "version": "0.8.16",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.15",
51
- "@hyperframes/player": "0.8.15",
52
- "@hyperframes/parsers": "0.8.15",
53
- "@hyperframes/studio-server": "0.8.15",
54
- "@hyperframes/sdk": "0.8.15"
50
+ "@hyperframes/core": "0.8.16",
51
+ "@hyperframes/parsers": "0.8.16",
52
+ "@hyperframes/player": "0.8.16",
53
+ "@hyperframes/studio-server": "0.8.16",
54
+ "@hyperframes/sdk": "0.8.16"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.15"
71
+ "@hyperframes/producer": "0.8.16"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { classifyWebAudioMediaRoute } from "@hyperframes/core/runtime/web-audio-route";
2
3
  import { ContextMenu } from "./AssetContextMenu";
3
4
  import { basename, getAudioSubtype, type CopyFeedback } from "./assetHelpers";
4
5
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
@@ -167,14 +168,24 @@ export function AudioRow({
167
168
  setPlaying(false);
168
169
  cancelAnimationFrame(animRef.current);
169
170
  };
171
+ // `src` must be set BEFORE classifying: the check reads `currentSrc`/
172
+ // `src`, and a same-origin `serveUrl` mustn't be judged from a blank
173
+ // element.
174
+ el.src = serveUrl;
170
175
  audioRef.current = el;
171
176
  const analyser = analyserRef.current;
172
- if (analyser) {
177
+ // Same hazard `webAudioRoute.ts` documents for the timeline runtime
178
+ // (#3458): `createMediaElementSource` on a cross-origin element without
179
+ // a `crossorigin` opt-in permanently reroutes it to a node that outputs
180
+ // SILENCE per the Web Audio spec, without throwing. Classify first so
181
+ // this preview player can't reintroduce that bug — skipping the Web
182
+ // Audio graph here only costs the frequency-bar visualizer; native
183
+ // `<audio>` playback below stays audible either way.
184
+ if (analyser && classifyWebAudioMediaRoute(el).kind === "web-audio") {
173
185
  sourceRef.current = actxRef.current.createMediaElementSource(el);
174
186
  sourceRef.current.connect(analyser);
175
187
  analyser.connect(actxRef.current.destination);
176
188
  }
177
- el.src = serveUrl;
178
189
  }
179
190
 
180
191
  if (actxRef.current.state === "suspended") await actxRef.current.resume();
@@ -208,6 +208,54 @@ describe("useDomSelection additive", () => {
208
208
  });
209
209
 
210
210
  describe("useDomSelection", () => {
211
+ it("ignores a repeated non-additive selection of the same target", () => {
212
+ const element = document.createElement("div");
213
+ element.id = "headline";
214
+ const first = makeSelection("Headline", element);
215
+ const repeated = makeSelection("Headline", element);
216
+ const harness = renderHarness({
217
+ activeCompPath: "intro.html",
218
+ projectId: "project-1",
219
+ refreshKey: 0,
220
+ });
221
+
222
+ act(() => harness.current().applyDomSelection(first));
223
+ harness.timeline.setSelectedTimelineElementId.mockClear();
224
+ harness.timeline.setTimelineSelectionSet.mockClear();
225
+
226
+ act(() => harness.current().applyDomSelection(repeated));
227
+
228
+ expect(harness.current().domEditSelection).toBe(first);
229
+ expect(harness.current().domEditGroupSelections).toEqual([first]);
230
+ expect(harness.timeline.setSelectedTimelineElementId).not.toHaveBeenCalled();
231
+ expect(harness.timeline.setTimelineSelectionSet).not.toHaveBeenCalled();
232
+ harness.cleanup();
233
+ });
234
+
235
+ it("still refreshes selection data for the same target when preserving the group", () => {
236
+ const element = document.createElement("div");
237
+ element.id = "headline";
238
+ const first = makeSelection("Headline", element);
239
+ const refreshed = makeSelection("Updated headline", element);
240
+ const harness = renderHarness({
241
+ activeCompPath: "intro.html",
242
+ projectId: "project-1",
243
+ refreshKey: 0,
244
+ });
245
+
246
+ act(() => harness.current().applyDomSelection(first));
247
+ act(() =>
248
+ harness.current().applyDomSelection(refreshed, {
249
+ preserveGroup: true,
250
+ revealPanel: false,
251
+ }),
252
+ );
253
+
254
+ expect(harness.current().domEditSelection).toBe(refreshed);
255
+ expect(harness.current().domEditGroupSelections).toEqual([refreshed]);
256
+ harness.cleanup();
257
+ });
258
+
211
259
  it("clears a committed selection when the active composition path changes", () => {
212
260
  const { selection, harness } = setupSelectedHarness();
213
261
  expect(harness.current().domEditSelection).toBe(selection);
@@ -1,10 +1,9 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
- import type { SelectElementOptions, TimelineElement } from "../player";
2
+ import type { TimelineElement } from "../player";
3
3
  import {
4
4
  getAllPreviewTargetsFromPointer,
5
5
  getPreviewTargetFromPointer,
6
6
  } from "../utils/studioPreviewHelpers";
7
- import { type RightPanelTab } from "../utils/studioHelpers";
8
7
  import {
9
8
  domEditSelectionsTargetSame,
10
9
  domEditSelectionInGroup,
@@ -22,86 +21,18 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
22
21
  import { useStudioTestHooks } from "./useStudioTestHooks";
23
22
  import { logSelect } from "../utils/selectDebug";
24
23
  import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror";
25
-
26
- // ── Types ──
27
-
28
- export interface ApplyDomSelectionOptions {
29
- revealPanel?: boolean;
30
- additive?: boolean;
31
- preserveGroup?: boolean;
32
- // A clear that came FROM the timeline must not be echoed back, or picking a
33
- // clip with no canvas node would deselect the clip you just picked.
34
- announce?: boolean;
35
- }
36
-
37
- export interface ResolveDomSelectionOptions {
38
- preferClipAncestor?: boolean;
39
- skipSourceProbe?: boolean;
40
- activeGroupElement?: HTMLElement | null;
41
- }
42
-
43
- export interface UseDomSelectionParams {
44
- projectId: string | null;
45
- activeCompPath: string | null;
46
- isMasterView: boolean;
47
- compIdToSrc: Map<string, string>;
48
- captionEditMode: boolean;
49
- previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
50
- timelineElements: TimelineElement[];
51
- getTimelineSelectionSet: () => ReadonlySet<string>;
52
- setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
53
- /** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
54
- setTimelineSelectionSet: (ids: Set<string>) => void;
55
- setRightCollapsed: (collapsed: boolean) => void;
56
- setRightPanelTab: (tab: RightPanelTab) => void;
57
- previewIframe: HTMLIFrameElement | null;
58
- refreshKey: number;
59
- rightPanelTab: RightPanelTab;
60
- }
61
-
62
- export interface UseDomSelectionReturn {
63
- // State
64
- domEditSelection: DomEditSelection | null;
65
- domEditGroupSelections: DomEditSelection[];
66
- domEditHoverSelection: DomEditSelection | null;
67
- activeGroupElement: HTMLElement | null;
68
- // Refs
69
- domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
70
- domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
71
- domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
72
- activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
73
- // State setters (needed by useDomEditSession for agent-prompt reset flows)
74
- setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
75
- setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
76
- setActiveGroupElement: (el: HTMLElement | null) => void;
77
- // Callbacks
78
- applyDomSelection: (
79
- selection: DomEditSelection | null,
80
- options?: ApplyDomSelectionOptions,
81
- ) => void;
82
- clearDomSelection: () => void;
83
- buildDomSelectionFromTarget: (
84
- target: HTMLElement,
85
- options?: ResolveDomSelectionOptions,
86
- ) => Promise<DomEditSelection | null>;
87
- resolveDomSelectionFromPreviewPoint: (
88
- clientX: number,
89
- clientY: number,
90
- options?: ResolveDomSelectionOptions,
91
- ) => Promise<DomEditSelection | null>;
92
- resolveAllDomSelectionsFromPreviewPoint: (
93
- clientX: number,
94
- clientY: number,
95
- ) => Promise<DomEditSelection[]>;
96
- updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
97
- buildDomSelectionForTimelineElement: (
98
- element: TimelineElement,
99
- ) => Promise<DomEditSelection | null>;
100
- handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
101
- refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
102
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
103
- applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
104
- }
24
+ import type {
25
+ ApplyDomSelectionOptions,
26
+ UseDomSelectionParams,
27
+ UseDomSelectionReturn,
28
+ } from "./useDomSelectionTypes";
29
+
30
+ export type {
31
+ ApplyDomSelectionOptions,
32
+ ResolveDomSelectionOptions,
33
+ UseDomSelectionParams,
34
+ UseDomSelectionReturn,
35
+ } from "./useDomSelectionTypes";
105
36
 
106
37
  // ── Hook ──
107
38
 
@@ -187,6 +118,19 @@ export function useDomSelection({
187
118
  const isAdditiveSelection = Boolean(options?.additive);
188
119
  const currentSelection = domEditSelectionRef.current;
189
120
  const previousGroup = domEditGroupSelectionsRef.current;
121
+ const isRepeatedSingleSelection =
122
+ !isAdditiveSelection &&
123
+ !options?.preserveGroup &&
124
+ previousGroup.length === 1 &&
125
+ domEditSelectionsTargetSame(currentSelection, selection) &&
126
+ domEditSelectionsTargetSame(previousGroup[0], selection);
127
+ if (isRepeatedSingleSelection) {
128
+ if (options?.revealPanel !== false) {
129
+ setRightCollapsed(false);
130
+ if (rightPanelTabRef.current !== "variables") setRightPanelTab("design");
131
+ }
132
+ return;
133
+ }
190
134
  const currentGroup = isAdditiveSelection
191
135
  ? seedDomEditGroupWithSelection(previousGroup, currentSelection)
192
136
  : previousGroup;
@@ -0,0 +1,82 @@
1
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import type { SelectElementOptions, TimelineElement } from "../player";
4
+ import type { RightPanelTab } from "../utils/studioHelpers";
5
+
6
+ export interface ApplyDomSelectionOptions {
7
+ revealPanel?: boolean;
8
+ additive?: boolean;
9
+ preserveGroup?: boolean;
10
+ // A clear that came FROM the timeline must not be echoed back, or picking a
11
+ // clip with no canvas node would deselect the clip you just picked.
12
+ announce?: boolean;
13
+ }
14
+
15
+ export interface ResolveDomSelectionOptions {
16
+ preferClipAncestor?: boolean;
17
+ skipSourceProbe?: boolean;
18
+ activeGroupElement?: HTMLElement | null;
19
+ }
20
+
21
+ export interface UseDomSelectionParams {
22
+ projectId: string | null;
23
+ activeCompPath: string | null;
24
+ isMasterView: boolean;
25
+ compIdToSrc: Map<string, string>;
26
+ captionEditMode: boolean;
27
+ previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
28
+ timelineElements: TimelineElement[];
29
+ getTimelineSelectionSet: () => ReadonlySet<string>;
30
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
31
+ /** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
32
+ setTimelineSelectionSet: (ids: Set<string>) => void;
33
+ setRightCollapsed: (collapsed: boolean) => void;
34
+ setRightPanelTab: (tab: RightPanelTab) => void;
35
+ previewIframe: HTMLIFrameElement | null;
36
+ refreshKey: number;
37
+ rightPanelTab: RightPanelTab;
38
+ }
39
+
40
+ export interface UseDomSelectionReturn {
41
+ // State
42
+ domEditSelection: DomEditSelection | null;
43
+ domEditGroupSelections: DomEditSelection[];
44
+ domEditHoverSelection: DomEditSelection | null;
45
+ activeGroupElement: HTMLElement | null;
46
+ // Refs
47
+ domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
48
+ domEditGroupSelectionsRef: MutableRefObject<DomEditSelection[]>;
49
+ domEditHoverSelectionRef: MutableRefObject<DomEditSelection | null>;
50
+ activeGroupElementRef: MutableRefObject<HTMLElement | null>;
51
+ // State setters (needed by useDomEditSession for agent-prompt reset flows)
52
+ setDomEditSelection: Dispatch<SetStateAction<DomEditSelection | null>>;
53
+ setDomEditGroupSelections: Dispatch<SetStateAction<DomEditSelection[]>>;
54
+ setActiveGroupElement: (el: HTMLElement | null) => void;
55
+ // Callbacks
56
+ applyDomSelection: (
57
+ selection: DomEditSelection | null,
58
+ options?: ApplyDomSelectionOptions,
59
+ ) => void;
60
+ clearDomSelection: () => void;
61
+ buildDomSelectionFromTarget: (
62
+ target: HTMLElement,
63
+ options?: ResolveDomSelectionOptions,
64
+ ) => Promise<DomEditSelection | null>;
65
+ resolveDomSelectionFromPreviewPoint: (
66
+ clientX: number,
67
+ clientY: number,
68
+ options?: ResolveDomSelectionOptions,
69
+ ) => Promise<DomEditSelection | null>;
70
+ resolveAllDomSelectionsFromPreviewPoint: (
71
+ clientX: number,
72
+ clientY: number,
73
+ ) => Promise<DomEditSelection[]>;
74
+ updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
75
+ buildDomSelectionForTimelineElement: (
76
+ element: TimelineElement,
77
+ ) => Promise<DomEditSelection | null>;
78
+ handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
79
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
80
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
81
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
82
+ }
@@ -4,11 +4,15 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
7
+ import { trackStudioEvent } from "../utils/studioTelemetry";
7
8
  import { usePanelLayout } from "./usePanelLayout";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
13
+
11
14
  beforeEach(() => {
15
+ vi.mocked(trackStudioEvent).mockClear();
12
16
  const entries = new Map<string, string>();
13
17
  Object.defineProperty(window, "localStorage", {
14
18
  configurable: true,
@@ -165,6 +169,29 @@ describe("usePanelLayout — right inspector panes", () => {
165
169
  harness.unmount();
166
170
  });
167
171
 
172
+ it("tracks only actual right-panel tab changes, including rapid repeated calls", () => {
173
+ const harness = renderPanelLayout();
174
+
175
+ act(() => {
176
+ harness.getState().setRightPanelTab("design");
177
+ harness.getState().setRightPanelTab("design");
178
+ });
179
+ expect(trackStudioEvent).not.toHaveBeenCalled();
180
+
181
+ act(() => {
182
+ harness.getState().setRightPanelTab("layers");
183
+ harness.getState().setRightPanelTab("layers");
184
+ });
185
+ expect(trackStudioEvent).toHaveBeenCalledOnce();
186
+ expect(trackStudioEvent).toHaveBeenCalledWith("tab_switch", {
187
+ panel: "right_panel",
188
+ tab: "layers",
189
+ });
190
+ expect(harness.getState().rightPanelTab).toBe("layers");
191
+
192
+ harness.unmount();
193
+ });
194
+
168
195
  it("caps a panel relative to the window instead of at a flat 600px", () => {
169
196
  resizeWindowTo(700);
170
197
  const harness = renderPanelLayout();
@@ -289,6 +316,17 @@ describe("usePanelLayout — right inspector panes", () => {
289
316
  const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
290
317
  expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
291
318
 
319
+ // The flat inspector can change its visible pane without changing the
320
+ // umbrella rightPanelTab value. A later element selection must still bring
321
+ // Design back even though rightPanelTab already says "design".
322
+ act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
323
+ expect(harness.getState()).toMatchObject({
324
+ rightPanelTab: "design",
325
+ rightInspectorPanes: { layers: true, design: false },
326
+ });
327
+ act(() => harness.getState().setRightPanelTab("design"));
328
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
329
+
292
330
  // Element-select / block-params-close / header Inspector-button callers
293
331
  // all reach setRightPanelTab directly, not through the in-panel tab
294
332
  // click's own setExclusiveRightInspectorPane call — this must still
@@ -56,9 +56,13 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
56
56
  const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
57
57
  initialState?.rightPanelTab ?? "design",
58
58
  );
59
+ const rightPanelTabRef = useRef(rightPanelTab);
60
+ rightPanelTabRef.current = rightPanelTab;
59
61
  const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
60
62
  getInitialRightInspectorPanes(initialState?.rightPanelTab),
61
63
  );
64
+ const rightInspectorPanesRef = useRef(rightInspectorPanes);
65
+ rightInspectorPanesRef.current = rightInspectorPanes;
62
66
  // Set when the user explicitly reopens a panel the window had auto-collapsed,
63
67
  // so the rail cannot immediately swallow it again. Cleared once the window is
64
68
  // wide enough that auto-collapse is no longer in play.
@@ -161,7 +165,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
161
165
 
162
166
  const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
163
167
  setRightCollapsed(collapsed);
164
- if (!collapsed) setAutoCollapseOverride((prev) => ({ ...prev, right: true }));
168
+ if (!collapsed) {
169
+ setAutoCollapseOverride((prev) => (prev.right ? prev : { ...prev, right: true }));
170
+ }
165
171
  }, []);
166
172
 
167
173
  const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
@@ -192,6 +198,15 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
192
198
 
193
199
  const trackedSetRightPanelTab = useCallback(
194
200
  (tab: RightPanelTab) => {
201
+ const paneAlreadySelected =
202
+ tab !== "design" && tab !== "layers"
203
+ ? true
204
+ : STUDIO_FLAT_INSPECTOR_ENABLED
205
+ ? rightInspectorPanesRef.current[tab] &&
206
+ !rightInspectorPanesRef.current[tab === "design" ? "layers" : "design"]
207
+ : rightInspectorPanesRef.current[tab];
208
+ if (rightPanelTabRef.current === tab && paneAlreadySelected) return;
209
+ rightPanelTabRef.current = tab;
195
210
  if (tab === "design" || tab === "layers") {
196
211
  // Flat inspector: Layers always renders full-height by itself (see
197
212
  // StudioRightPanel's render gate), so this MUST land on the same
@@ -202,11 +217,11 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
202
217
  // inspector tab) would otherwise additively leave both panes `true`
203
218
  // and reproduce the "both tabs highlight, only one renders" bug this
204
219
  // still-additive branch used to cause under the flat flag.
205
- setRightInspectorPanes(
206
- STUDIO_FLAT_INSPECTOR_ENABLED
207
- ? { design: tab === "design", layers: tab === "layers" }
208
- : (panes) => ({ ...panes, [tab]: true }),
209
- );
220
+ const nextPanes = STUDIO_FLAT_INSPECTOR_ENABLED
221
+ ? { design: tab === "design", layers: tab === "layers" }
222
+ : { ...rightInspectorPanesRef.current, [tab]: true };
223
+ rightInspectorPanesRef.current = nextPanes;
224
+ setRightInspectorPanes(nextPanes);
210
225
  }
211
226
  setRightPanelTab(tab);
212
227
  trackStudioEvent("tab_switch", { panel: "right_panel", tab });
@@ -4,6 +4,8 @@
4
4
  * from here without creating circular dependencies.
5
5
  */
6
6
 
7
+ import type { RuntimeTimelineClipIdentity } from "@hyperframes/core";
8
+
7
9
  export interface PlaybackAdapter {
8
10
  play: () => void;
9
11
  pause: () => void;
@@ -32,23 +34,12 @@ export interface TimelineLike {
32
34
  isActive: () => boolean;
33
35
  }
34
36
 
35
- export interface ClipManifestClip {
36
- id: string | null;
37
- label: string;
38
- start: number;
39
- duration: number;
40
- track: number;
37
+ export interface ClipManifestClip extends RuntimeTimelineClipIdentity {
41
38
  zIndex?: number;
42
39
  stackingContextId?: string | null;
43
- kind: "video" | "audio" | "image" | "element" | "composition";
44
- tagName: string | null;
45
- compositionId: string | null;
46
40
  compositionAncestors?: string[];
47
- parentCompositionId: string | null;
48
- compositionSrc: string | null;
49
41
  playbackStart?: number;
50
42
  playbackRate?: number;
51
- assetUrl: string | null;
52
43
  }
53
44
 
54
45
  export interface ClipManifest {
@@ -19,6 +19,7 @@ describe("Studio runtime protocol", () => {
19
19
  "rational-fps",
20
20
  "seek-keep-playing",
21
21
  "composition-manifest-v1",
22
+ "runtime-data",
22
23
  ],
23
24
  fps: { numerator: 60, denominator: 1 },
24
25
  timeSeconds: 1.25,