@hyperframes/studio 0.7.106 → 0.7.108

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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Keeping the player store's automation attributes true.
3
+ *
4
+ * The store is what a lane draws from, and it is populated by element discovery — a
5
+ * message from the preview runtime, which only arrives on load. Anything that edits
6
+ * an envelope afterwards writes to the preview document and the source file, and the
7
+ * store would go on holding the value it was born with until a reload.
8
+ *
9
+ * One reader, called from the two places a change lands: the resync every dom-edit
10
+ * attribute commit already runs, and the soft restore an undo or redo applies. It
11
+ * reads the preview rather than being told, because those callers know a file
12
+ * changed, not which attribute — and because three separate writers shipped without
13
+ * remembering to sync, which is what a single sink prevents.
14
+ */
15
+
16
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
17
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
18
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
19
+
20
+ /** The preview node an element stands for, by dom id and then by `data-hf-id`. */
21
+ function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
22
+ const domId = element.domId ?? element.id;
23
+ const byId = domId ? doc.getElementById(domId) : null;
24
+ if (byId) return byId;
25
+ return element.hfId ? doc.querySelector(`[data-hf-id="${element.hfId}"]`) : null;
26
+ }
27
+
28
+ /**
29
+ * Re-read every element's automation and FX-chain attributes from the preview
30
+ * document, for a change that reached the DOM without going through this store.
31
+ *
32
+ * That is undo and redo. A soft restore patches the reverted attributes onto the
33
+ * live preview and re-runs the timeline — deliberately, so the frame does not blank
34
+ * — but the store it does not touch is the one the lanes read, so an undone delete
35
+ * stayed invisible until a reload. A full restore already clears the store and waits
36
+ * for discovery, so it needs nothing from here.
37
+ *
38
+ * Reads rather than being told: an undo restores whole files, so the attribute it
39
+ * reverted is only known by looking.
40
+ */
41
+ export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
42
+ if (!doc) return;
43
+ usePlayerStore.setState((state) => {
44
+ let changed = false;
45
+ const elements = state.elements.map((element) => {
46
+ const node = previewNodeFor(doc, element);
47
+ if (!node) return element;
48
+ const automation = node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined;
49
+ const fxChain = node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined;
50
+ if (automation === element.automation && fxChain === element.fxChain) return element;
51
+ changed = true;
52
+ return { ...element, automation, fxChain };
53
+ });
54
+ return changed ? { elements } : {};
55
+ });
56
+ }
@@ -6,15 +6,13 @@
6
6
  * is active and the user is navigating caption segments).
7
7
  */
8
8
 
9
+ import { isTypingTarget } from "../../utils/typingTarget";
10
+
9
11
  const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
10
12
 
11
13
  const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
12
- "input",
13
- "textarea",
14
- "select",
15
14
  "button",
16
15
  "a[href]",
17
- "[contenteditable='true']",
18
16
  "[role='button']",
19
17
  "[role='checkbox']",
20
18
  "[role='combobox']",
@@ -27,6 +25,9 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
27
25
  ].join(",");
28
26
 
29
27
  export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
28
+ // Anything the user is typing into owns its keys outright, editable elements
29
+ // included: a letter claimed here never reaches the text.
30
+ if (isTypingTarget(target)) return true;
30
31
  if (!target || typeof target !== "object") return false;
31
32
  const candidate = target as { closest?: unknown };
32
33
  if (typeof candidate.closest !== "function") return false;
@@ -296,3 +296,36 @@ describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs trans
296
296
  ).toEqual(["a", "c"]);
297
297
  });
298
298
  });
299
+
300
+ describe("audio FX attributes on parsed elements", () => {
301
+ const CHAIN = '{"version":1,"nodes":[{"type":"lowpass","id":"n1","params":{}}]}';
302
+ const LANE = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
303
+
304
+ it("carries data-fx-chain and data-automation off the element", () => {
305
+ // The timeline row is what reserves automation height and draws the lanes;
306
+ // parsed straight from the DOM it used to arrive without either attribute,
307
+ // so the panel showed a chain the timeline could not.
308
+ const doc = new DOMParser().parseFromString(
309
+ `<div data-composition-id="main" data-start="0" data-duration="10">
310
+ <audio id="bgm" data-start="0" data-duration="10" data-fx-chain='${CHAIN}'
311
+ data-automation='${LANE}'></audio>
312
+ </div>`,
313
+ "text/html",
314
+ );
315
+ const [bgm] = parseTimelineFromDOM(doc, 10).filter((e) => e.domId === "bgm");
316
+ expect(bgm?.fxChain).toBe(CHAIN);
317
+ expect(bgm?.automation).toBe(LANE);
318
+ });
319
+
320
+ it("leaves them unset on a track that carries neither", () => {
321
+ const doc = new DOMParser().parseFromString(
322
+ `<div data-composition-id="main" data-start="0" data-duration="10">
323
+ <audio id="bgm" data-start="0" data-duration="10"></audio>
324
+ </div>`,
325
+ "text/html",
326
+ );
327
+ const [bgm] = parseTimelineFromDOM(doc, 10).filter((e) => e.domId === "bgm");
328
+ expect(bgm?.fxChain).toBeUndefined();
329
+ expect(bgm?.automation).toBeUndefined();
330
+ });
331
+ });
@@ -138,6 +138,10 @@ export function createTimelineElementFromManifestClip(params: {
138
138
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
139
139
  const timelineRole = hostEl.getAttribute("data-timeline-role");
140
140
  if (timelineRole) entry.timelineRole = timelineRole;
141
+ const fxChain = hostEl.getAttribute("data-fx-chain");
142
+ if (fxChain) entry.fxChain = fxChain;
143
+ const automation = hostEl.getAttribute("data-automation");
144
+ if (automation) entry.automation = automation;
141
145
  entry.zIndex = readTimelineElementZIndex(hostEl);
142
146
  }
143
147
  if (clip.assetUrl) entry.src = clip.assetUrl;
@@ -334,6 +338,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
334
338
  if (resolvedSrc) entry.src = resolvedSrc;
335
339
  }
336
340
 
341
+ // Read from the element, like the manifest path does: without these an audio
342
+ // clip parsed straight from the DOM reserved no automation height and drew no
343
+ // lanes, while the property panel still showed its chain.
344
+ const domFxChain = el.getAttribute("data-fx-chain");
345
+ if (domFxChain) entry.fxChain = domFxChain;
346
+ const domAutomation = el.getAttribute("data-automation");
347
+ if (domAutomation) entry.automation = domAutomation;
348
+
337
349
  if (el.hasAttribute("data-timeline-locked")) {
338
350
  entry.timelineLocked = true;
339
351
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { usePlayerStore } from "./playerStore";
3
+
4
+ describe("automationSelectionSlice", () => {
5
+ it("stores one ordered selection box and clears it", () => {
6
+ const store = usePlayerStore.getState();
7
+ // Dragged up and to the left: both axes arrive backwards.
8
+ store.setAutomationSelection({
9
+ elementKey: "bgm",
10
+ target: "volume",
11
+ t0: 2,
12
+ t1: 1,
13
+ v0: 0.8,
14
+ v1: 0.3,
15
+ });
16
+ const sel = usePlayerStore.getState().automationSelection;
17
+ // Ordered on write, so every consumer can assume t0 <= t1 and v0 <= v1 —
18
+ // which is what lets a point test be two range checks rather than four.
19
+ expect(sel).toEqual({
20
+ elementKey: "bgm",
21
+ target: "volume",
22
+ t0: 1,
23
+ t1: 2,
24
+ v0: 0.3,
25
+ v1: 0.8,
26
+ });
27
+ usePlayerStore.getState().clearAutomationSelection();
28
+ expect(usePlayerStore.getState().automationSelection).toBeNull();
29
+ });
30
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The active selection box on one automation lane.
3
+ *
4
+ * A store slice, not lane-local state, for the same reason keyframe selection
5
+ * is one: Delete/copy/paste handlers and the shape menu live outside the lane
6
+ * component and need to read it. Ephemeral by construction — nothing
7
+ * serializes store state, and the selection must never survive into a render.
8
+ */
9
+ import type { StoreApi } from "zustand";
10
+
11
+ export interface AutomationSelection {
12
+ /** TimelineElement key (key ?? id) of the clip that owns the lane. */
13
+ elementKey: string;
14
+ /** Lane target: "volume" or "fx.<nodeId>.<param>". */
15
+ target: string;
16
+ /** Clip-local seconds; always t0 <= t1 (ordered on write). */
17
+ t0: number;
18
+ t1: number;
19
+ /**
20
+ * The box's value bounds, in the parameter's own units; always v0 <= v1.
21
+ *
22
+ * What makes the selection a box rather than a time span: a point is caught
23
+ * only if it falls inside both axes. Span operations — copy, paste, shape
24
+ * insert, simplify — still read t0/t1 alone, because they act on the envelope
25
+ * over a stretch of time rather than on a set of breakpoints.
26
+ */
27
+ v0: number;
28
+ v1: number;
29
+ }
30
+
31
+ export interface AutomationSelectionSlice {
32
+ automationSelection: AutomationSelection | null;
33
+ setAutomationSelection: (sel: AutomationSelection) => void;
34
+ clearAutomationSelection: () => void;
35
+ }
36
+
37
+ export function createAutomationSelectionSlice(
38
+ set: StoreApi<AutomationSelectionSlice>["setState"],
39
+ ): AutomationSelectionSlice {
40
+ return {
41
+ automationSelection: null,
42
+ setAutomationSelection: (sel) =>
43
+ set({
44
+ automationSelection: {
45
+ ...sel,
46
+ t0: Math.min(sel.t0, sel.t1),
47
+ t1: Math.max(sel.t0, sel.t1),
48
+ v0: Math.min(sel.v0, sel.v1),
49
+ v1: Math.max(sel.v0, sel.v1),
50
+ },
51
+ }),
52
+ clearAutomationSelection: () => set({ automationSelection: null }),
53
+ };
54
+ }
@@ -557,6 +557,21 @@ describe("usePlayerStore", () => {
557
557
  expectResettableDefaults(usePlayerStore.getState());
558
558
  });
559
559
 
560
+ it("drops an automation time selection on reset and on a project switch", () => {
561
+ const sel = { elementKey: "bgm", target: "volume", t0: 1, t1: 2 };
562
+
563
+ usePlayerStore.getState().setAutomationSelection(sel);
564
+ usePlayerStore.getState().reset();
565
+ expect(usePlayerStore.getState().automationSelection).toBeNull();
566
+
567
+ // The switch matters more than reset(): a stale elementKey can match a
568
+ // same-keyed clip in the new project and redirect a paste to its old t0.
569
+ usePlayerStore.getState().beginTimelineSession("project-a");
570
+ usePlayerStore.getState().setAutomationSelection(sel);
571
+ usePlayerStore.getState().beginTimelineSession("project-b");
572
+ expect(usePlayerStore.getState().automationSelection).toBeNull();
573
+ });
574
+
560
575
  it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
561
576
  const store = usePlayerStore.getState();
562
577
  store.setPlaybackRate(2);
@@ -10,76 +10,19 @@ import {
10
10
  } from "../../utils/studioUiPreferences";
11
11
  import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
12
12
  import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
13
+ import {
14
+ createAutomationSelectionSlice,
15
+ type AutomationSelectionSlice,
16
+ } from "./automationSelectionSlice";
13
17
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
14
18
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
15
19
 
16
20
  export type { KeyframeCacheEntry } from "./keyframeSlice";
17
21
  export { liveTime } from "./liveTime";
18
22
 
19
- export interface TimelineElement {
20
- id: string;
21
- label?: string;
22
- key?: string;
23
- kind?: ClipManifestClip["kind"];
24
- tag: string;
25
- start: number;
26
- duration: number;
27
- track: number;
28
- /**
29
- * The data-track-index as written in the source file. Set at the manifest
30
- * translation boundary (createTimelineElementFromManifestClip) from the
31
- * runtime clip's verbatim track, and preserved through display-lane remaps
32
- * (normalizeToZones packs sparse authored tracks onto contiguous display
33
- * lanes; expanded sub-comp children get synthetic display rows). Lane edits
34
- * must persist THIS space — writing a display-lane number into a sparse file
35
- * re-targets the wrong track. For an expanded child the value is in its OWN
36
- * source file's coordinate space, not the host timeline's.
37
- */
38
- authoredTrack?: number;
39
- /** Resolved z-index for stacking-aware timeline ordering. */
40
- zIndex?: number;
41
- /** True when the effective z-index was authored inline or through CSS, not auto. */
42
- hasExplicitZIndex?: boolean;
43
- /** Canonical CSS stacking context this element's z-index participates in. */
44
- stackingContextId?: string | null;
45
- /** Nearest parent composition context, matching RuntimeTimelineClip. */
46
- parentCompositionId?: string | null;
47
- /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
48
- compositionAncestors?: string[];
49
- domId?: string;
50
- /** Stable `data-hf-id` attribute value — used as primary patch target when present */
51
- hfId?: string;
52
- /** Best-effort selector used when patching source HTML back from timeline edits */
53
- selector?: string;
54
- /** Zero-based occurrence index for non-unique selectors */
55
- selectorIndex?: number;
56
- /** Source composition file that owns this element, when known */
57
- sourceFile?: string;
58
- src?: string;
59
- playbackStart?: number;
60
- playbackStartAttr?: "media-start" | "playback-start";
61
- playbackRate?: number;
62
- sourceDuration?: number;
63
- volume?: number;
64
- /** Path from data-composition-src — identifies sub-composition elements */
65
- compositionSrc?: string;
66
- /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
67
- timingSource?: "authored" | "implicit";
68
- /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
69
- timelineLocked?: boolean;
70
- /** Set by data-hidden on the host element — hides the clip in preview and render. */
71
- hidden?: boolean;
72
- /** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
73
- timelineRole?: string;
74
- /**
75
- * Set by useExpandedTimelineElements on an inline-expanded sub-composition
76
- * child: the absolute master-timeline start of the sub-comp host the child
77
- * lives in. Presence marks the element as expanded; edits subtract it to get
78
- * the child's local (sourceFile-relative) time. Works at any nesting depth.
79
- */
80
- expandedParentStart?: number;
81
- expandedHostKey?: string;
82
- }
23
+ import type { TimelineElement } from "./timelineElement";
24
+
25
+ export type { TimelineElement };
83
26
  export type ZoomMode = "fit" | "manual";
84
27
  type TimelineTool = "select" | "razor";
85
28
 
@@ -104,7 +47,7 @@ function resolveElementSelection(
104
47
  };
105
48
  }
106
49
 
107
- interface PlayerState extends KeyframeSlice, ThumbnailSlice {
50
+ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
108
51
  isPlaying: boolean;
109
52
  currentTime: number;
110
53
  duration: number;
@@ -223,6 +166,32 @@ interface PlayerState extends KeyframeSlice, ThumbnailSlice {
223
166
  requestSeek: (time: number) => void;
224
167
  clearSeekRequest: () => void;
225
168
 
169
+ /**
170
+ * Request the transport start or stop from outside the player loop.
171
+ *
172
+ * The FX rack auditions a preset by writing it to the running graph, which is
173
+ * silent while the transport is paused — so hovering one has to start
174
+ * playback, and leaving has to put the playhead back where it was. Hovering is
175
+ * not an edit and must not cost the author their place.
176
+ *
177
+ * A nonce rather than a bare boolean: two hovers in a row both want play, and
178
+ * without it the second request is indistinguishable from the first having
179
+ * already been served.
180
+ */
181
+ playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
182
+ requestPlayback: (playing: boolean, returnTo?: number | null) => void;
183
+ clearPlaybackRequest: () => void;
184
+
185
+ /**
186
+ * Request the timeline to scroll a clip into view (e.g. clicking an
187
+ * already-added asset card in the sidebar). Consumed and cleared by
188
+ * useTimelineRevealClip. The nonce makes repeat requests for the same
189
+ * clip observable so a second click re-reveals after the user scrolls away.
190
+ */
191
+ clipRevealRequest: { elementId: string; nonce: number } | null;
192
+ requestClipReveal: (elementId: string) => void;
193
+ clearClipRevealRequest: () => void;
194
+
226
195
  timelineFocus: TimelineFocusRequest | null;
227
196
  timelineFocusNonce: number;
228
197
  requestTimelineFocus: (id: string) => void;
@@ -294,6 +263,10 @@ export function createTimelineResetState() {
294
263
  motionPathArmed: false,
295
264
  motionPathCreateAvailable: false,
296
265
  selectedKeyframes: new Set<string>(),
266
+ // Ephemeral like every other selection here. A range surviving a project
267
+ // switch can match a same-keyed clip in the new project and redirect a
268
+ // paste through `sel.elementKey === paste.elementKey` to a stale t0.
269
+ automationSelection: null,
297
270
  expandedClipIds: new Set<string>(),
298
271
  focusedEaseSegment: null,
299
272
  selectedElementIds: new Set<string>(),
@@ -343,6 +316,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
343
316
  })),
344
317
  ...createThumbnailSlice(set),
345
318
 
319
+ ...createAutomationSelectionSlice(set),
320
+
346
321
  activeKeyframePct: null,
347
322
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
348
323
  motionPathArmed: false,
@@ -373,6 +348,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
373
348
  requestSeek: (time) => set({ requestedSeekTime: time }),
374
349
  clearSeekRequest: () => set({ requestedSeekTime: null }),
375
350
 
351
+ playbackRequest: null,
352
+ requestPlayback: (playing, returnTo = null) =>
353
+ set((s) => ({
354
+ playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 },
355
+ })),
356
+ clearPlaybackRequest: () => set({ playbackRequest: null }),
357
+
358
+ clipRevealRequest: null,
359
+ requestClipReveal: (elementId) =>
360
+ set((s) => ({
361
+ clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
362
+ })),
363
+ clearClipRevealRequest: () => set({ clipRevealRequest: null }),
364
+
376
365
  timelineFocus: null,
377
366
  timelineFocusNonce: 0,
378
367
  requestTimelineFocus: (id) =>
@@ -0,0 +1,78 @@
1
+ /**
2
+ * One row in the timeline: a clip as Studio needs it, translated from the
3
+ * runtime's manifest at createTimelineElementFromManifestClip.
4
+ *
5
+ * Split out of playerStore, which had reached the 600-line studio ceiling and
6
+ * could not carry another field. Re-exported from there, so every existing
7
+ * importer is unaffected.
8
+ */
9
+
10
+ import type { ClipManifestClip } from "../lib/playbackTypes";
11
+
12
+ export interface TimelineElement {
13
+ id: string;
14
+ label?: string;
15
+ key?: string;
16
+ kind?: ClipManifestClip["kind"];
17
+ tag: string;
18
+ start: number;
19
+ duration: number;
20
+ track: number;
21
+ /**
22
+ * The data-track-index as written in the source file. Set at the manifest
23
+ * translation boundary (createTimelineElementFromManifestClip) from the
24
+ * runtime clip's verbatim track, and preserved through display-lane remaps
25
+ * (normalizeToZones packs sparse authored tracks onto contiguous display
26
+ * lanes; expanded sub-comp children get synthetic display rows). Lane edits
27
+ * must persist THIS space — writing a display-lane number into a sparse file
28
+ * re-targets the wrong track. For an expanded child the value is in its OWN
29
+ * source file's coordinate space, not the host timeline's.
30
+ */
31
+ authoredTrack?: number;
32
+ /** Resolved z-index for stacking-aware timeline ordering. */
33
+ zIndex?: number;
34
+ /** True when the effective z-index was authored inline or through CSS, not auto. */
35
+ hasExplicitZIndex?: boolean;
36
+ /** Canonical CSS stacking context this element's z-index participates in. */
37
+ stackingContextId?: string | null;
38
+ /** Nearest parent composition context, matching RuntimeTimelineClip. */
39
+ parentCompositionId?: string | null;
40
+ /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
41
+ compositionAncestors?: string[];
42
+ domId?: string;
43
+ /** Stable `data-hf-id` attribute value — used as primary patch target when present */
44
+ hfId?: string;
45
+ /** Best-effort selector used when patching source HTML back from timeline edits */
46
+ selector?: string;
47
+ /** Zero-based occurrence index for non-unique selectors */
48
+ selectorIndex?: number;
49
+ /** Source composition file that owns this element, when known */
50
+ sourceFile?: string;
51
+ src?: string;
52
+ playbackStart?: number;
53
+ playbackStartAttr?: "media-start" | "playback-start";
54
+ playbackRate?: number;
55
+ sourceDuration?: number;
56
+ volume?: number;
57
+ /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
58
+ fxChain?: string;
59
+ automation?: string;
60
+ /** Path from data-composition-src — identifies sub-composition elements */
61
+ compositionSrc?: string;
62
+ /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
63
+ timingSource?: "authored" | "implicit";
64
+ /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
65
+ timelineLocked?: boolean;
66
+ /** Set by data-hidden on the host element — hides the clip in preview and render. */
67
+ hidden?: boolean;
68
+ /** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
69
+ timelineRole?: string;
70
+ /**
71
+ * Set by useExpandedTimelineElements on an inline-expanded sub-composition
72
+ * child: the absolute master-timeline start of the sub-comp host the child
73
+ * lives in. Presence marks the element as expanded; edits subtract it to get
74
+ * the child's local (sourceFile-relative) time. Works at any nesting depth.
75
+ */
76
+ expandedParentStart?: number;
77
+ expandedHostKey?: string;
78
+ }
@@ -469,3 +469,92 @@ body {
469
469
  animation: none;
470
470
  }
471
471
  }
472
+
473
+ /* Preset shelf: the hovered row, and the wave that says it is being heard.
474
+ *
475
+ * Hovering a preset auditions it on the running audio, which is invisible — the
476
+ * panel looks identical whether the audition is playing or the pointer just
477
+ * happens to be resting there. The bars are that feedback: they run only while a
478
+ * preset is actually sounding, so they are a readout of the audio rather than a
479
+ * hover decoration. */
480
+ .hf-fx-preset-item {
481
+ position: relative;
482
+ }
483
+
484
+ @media (prefers-reduced-motion: no-preference) {
485
+ .hf-fx-preset-item {
486
+ transition:
487
+ background-color 120ms ease,
488
+ color 120ms ease;
489
+ }
490
+ }
491
+
492
+ .hf-fx-preset-item:hover,
493
+ .hf-fx-preset-item:focus-visible {
494
+ /* The accent at low alpha rather than a grey: the row is lit by the thing that
495
+ is playing, and it should read as the same event as the bars. */
496
+ background-color: rgba(60, 230, 172, 0.12);
497
+ }
498
+
499
+ .hf-fx-preset-wave {
500
+ position: absolute;
501
+ top: 50%;
502
+ right: 0.5rem;
503
+ display: flex;
504
+ align-items: center;
505
+ gap: 2px;
506
+ height: 0.75rem;
507
+ transform: translateY(-50%);
508
+ opacity: 0;
509
+ transition: opacity 120ms ease;
510
+ pointer-events: none;
511
+ }
512
+
513
+ .hf-fx-preset-item:hover .hf-fx-preset-wave,
514
+ .hf-fx-preset-item:focus-visible .hf-fx-preset-wave {
515
+ opacity: 1;
516
+ }
517
+
518
+ .hf-fx-preset-wave span {
519
+ width: 2px;
520
+ height: 100%;
521
+ border-radius: 1px;
522
+ background: #3ce6ac;
523
+ transform: scaleY(0.25);
524
+ transform-origin: center;
525
+ }
526
+
527
+ @media (prefers-reduced-motion: no-preference) {
528
+ .hf-fx-preset-wave span {
529
+ animation: hf-fx-preset-wave 900ms ease-in-out infinite;
530
+ }
531
+ /* Staggered so it reads as a waveform travelling rather than four bars
532
+ pumping in unison, which reads as a loading spinner. */
533
+ .hf-fx-preset-wave span:nth-child(2) {
534
+ animation-delay: 150ms;
535
+ }
536
+ .hf-fx-preset-wave span:nth-child(3) {
537
+ animation-delay: 300ms;
538
+ }
539
+ .hf-fx-preset-wave span:nth-child(4) {
540
+ animation-delay: 450ms;
541
+ }
542
+ }
543
+
544
+ /* Still visible without motion — it is telling the author something is
545
+ playing, and that fact does not go away because the bars hold still. */
546
+ @media (prefers-reduced-motion: reduce) {
547
+ .hf-fx-preset-wave span {
548
+ transform: scaleY(0.7);
549
+ }
550
+ }
551
+
552
+ @keyframes hf-fx-preset-wave {
553
+ 0%,
554
+ 100% {
555
+ transform: scaleY(0.25);
556
+ }
557
+ 50% {
558
+ transform: scaleY(1);
559
+ }
560
+ }
@@ -0,0 +1,45 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import {
5
+ agentRuntimeProperty,
6
+ resetAgentRuntimeForTests,
7
+ resolveAgentRuntime,
8
+ } from "./agentRuntime";
9
+
10
+ afterEach(() => {
11
+ resetAgentRuntimeForTests();
12
+ delete window.__HF_CLI_AGENT_RUNTIME;
13
+ });
14
+
15
+ describe("the agent driving this studio", () => {
16
+ it("reads what the CLI published", () => {
17
+ window.__HF_CLI_AGENT_RUNTIME = "claude_code";
18
+ expect(resolveAgentRuntime()).toBe("claude_code");
19
+ expect(agentRuntimeProperty()).toBe("claude_code");
20
+ });
21
+
22
+ it("is null when nothing published one — a studio opened by hand", () => {
23
+ expect(resolveAgentRuntime()).toBeNull();
24
+ });
25
+
26
+ it("treats an empty string as nothing, not as a value", () => {
27
+ // Two encodings of the same fact is what turns a breakdown into a phantom
28
+ // gap; "" and absent must not be separate rows.
29
+ window.__HF_CLI_AGENT_RUNTIME = "";
30
+ expect(resolveAgentRuntime()).toBeNull();
31
+ });
32
+
33
+ it("reports 'none' rather than nothing as a property", () => {
34
+ // Most sessions are people, and that is a finding. An omitted property
35
+ // could not tell it apart from an event that predates the property.
36
+ expect(agentRuntimeProperty()).toBe("none");
37
+ });
38
+
39
+ it("memoizes, so a later overwrite cannot split one session in two", () => {
40
+ window.__HF_CLI_AGENT_RUNTIME = "codex";
41
+ expect(resolveAgentRuntime()).toBe("codex");
42
+ window.__HF_CLI_AGENT_RUNTIME = "cursor";
43
+ expect(resolveAgentRuntime()).toBe("codex");
44
+ });
45
+ });