@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import type { TimelineElement } from "../player/store/timelineElement";
7
+ import { mountReactHarness } from "./domSelectionTestHarness";
8
+ import { usePreviewPersistence } from "./usePreviewPersistence";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ describe("undo that reloads the preview", () => {
18
+ it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
19
+ const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
20
+ usePlayerStore.getState().setElements(clips);
21
+ usePlayerStore.getState().setTimelineReady(true);
22
+ const reloadPreview = vi.fn();
23
+ let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
24
+ null;
25
+ function Harness() {
26
+ sync = usePreviewPersistence({
27
+ showToast: () => {},
28
+ readOptionalProjectFile: async () => "",
29
+ writeProjectFile: async () => {},
30
+ recordEdit: async () => {},
31
+ previewIframeRef: { current: null },
32
+ activeCompPathRef: { current: "index.html" },
33
+ reloadPreview,
34
+ }).syncHistoryPreviewAfterApply;
35
+ return null;
36
+ }
37
+ mountReactHarness(<Harness />);
38
+
39
+ // A nested composition's undo is not the active file's, so it reloads the preview.
40
+ const files = { "compositions/sub.html": { previous: "<p>1</p>", restored: "<p>2</p>" } };
41
+ await act(async () => sync!({ paths: Object.keys(files), files }));
42
+
43
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
44
+ const player = usePlayerStore.getState();
45
+ expect([player.elements, player.timelineReady]).toEqual([clips, true]);
46
+ });
47
+ });
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
159
159
  if (result.status !== "clean") throw result.error;
160
160
  }, [drainPendingDomEditSaves]);
161
161
 
162
+ const settlePendingEdits = useCallback(async (): Promise<void> => {
163
+ await flushStudioPendingEdits();
164
+ await domEditSaveQueueRef.current?.waitForIdle();
165
+ }, []);
166
+
162
167
  const resetDomEditSaveQueueBreaker = useCallback(() => {
163
168
  domEditSaveQueueRef.current?.reset();
164
169
  setDomEditSaveQueuePaused(null);
@@ -207,16 +212,13 @@ export function usePreviewPersistence({
207
212
  reloadPreview,
208
213
  );
209
214
  if (strategy === "full") {
210
- const player = usePlayerStore.getState();
211
- player.setElements([]);
212
- player.setSelectedElementId(null);
213
- player.setTimelineReady(false);
215
+ usePlayerStore.getState().setSelectedElementId(null);
214
216
  return;
215
217
  }
216
218
  // A soft restore patched the reverted attributes onto the live preview, but the
217
219
  // player store keeps its own copy and that copy is what the automation lanes
218
220
  // draw — so without this an undone envelope edit stayed invisible until a
219
- // reload. The full path above clears the store and waits for discovery instead.
221
+ // reload. The full path above waits for the reloaded preview to report instead.
220
222
  syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
221
223
  },
222
224
  [previewIframeRef, activeCompPathRef, reloadPreview],
@@ -256,6 +258,7 @@ export function usePreviewPersistence({
256
258
  queueDomEditSave,
257
259
  drainPendingDomEditSaves,
258
260
  waitForPendingDomEditSaves,
261
+ settlePendingEdits,
259
262
  domEditSaveQueuePaused,
260
263
  resetDomEditSaveQueueBreaker,
261
264
  applyCurrentStudioManualEditsToPreview,
@@ -9,7 +9,7 @@ import {
9
9
  import type { TimelineElement } from "../player";
10
10
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
11
11
  import { audioPillFlags } from "../player/components/audioClipLink";
12
- import { AudioWaveform } from "../player/components/AudioWaveform";
12
+ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
13
13
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
14
14
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
15
15
  import { usePlayerStore } from "../player/store/playerStore";
@@ -135,7 +135,7 @@ export function useRenderClipContent({
135
135
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
136
136
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
137
137
  if (effectiveMode === "hidden") {
138
- return el.tag === "audio"
138
+ return rendersWaveform(el)
139
139
  ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
140
140
  : null;
141
141
  }
@@ -173,7 +173,7 @@ export function useRenderClipContent({
173
173
  // Audio clips — waveform visualization. Resolve these before the generic
174
174
  // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
175
175
  // captured frame from the currently drilled composition preview.
176
- if (el.tag === "audio") {
176
+ if (rendersWaveform(el)) {
177
177
  return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
178
178
  }
179
179
 
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
2
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
3
3
 
4
4
  type AsyncFn = (...args: never[]) => Promise<unknown>;
5
5
 
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
11
11
  const key = fn as unknown as AsyncFn;
12
12
  const cached = wrappedRef.current.get(key);
13
13
  if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
- const wrapped = (...args: Args): Promise<R> => {
15
- const result = fn(...args);
16
- trackStudioPendingEdit(result);
17
- return result;
18
- };
14
+ const wrapped = trackedStudioEdit(fn);
19
15
  wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
16
  return wrapped;
21
17
  }, []);
package/src/index.ts CHANGED
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
181
181
  // DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
182
182
  export { useDomEditSession } from "./hooks/useDomEditSession";
183
183
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
184
+ export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
185
+ export type { PreviewPromotion } from "./player/sceneSwap";
184
186
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
185
187
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
186
188
  export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
@@ -238,3 +240,10 @@ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/m
238
240
  export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
239
241
  export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
240
242
  export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
243
+
244
+ export { buildStudioTools } from "./webmcp/useStudioAgentTools";
245
+ export type { StudioAgentToolsDeps } from "./webmcp/useStudioAgentTools";
246
+ export { collectStudioLookScene } from "./webmcp/tools/lookTools";
247
+ export type { StudioLookSnapshot } from "./webmcp/tools/lookTools";
248
+ export type { ModelContextTool } from "./webmcp/types";
249
+ export type { ToolResult } from "./webmcp/toolResult";
@@ -0,0 +1,33 @@
1
+ // @vitest-environment happy-dom
2
+ // Reads the preview on screen the way a host app does: by package name.
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, expectTypeOf, it } from "vitest";
6
+ import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
7
+ import {
8
+ makePreview,
9
+ mountPlayerWithPreview,
10
+ paintShadow,
11
+ } from "./player/hooks/timelinePlayerTestHarness";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
16
+ expectTypeOf<PreviewPromotion>().toHaveProperty("live");
17
+ const host = makePreview("<h1>Title</h1>");
18
+ const player = mountPlayerWithPreview(host);
19
+ let live: HTMLIFrameElement | null = null;
20
+ function Probe() {
21
+ live = useLivePreviewIframe(host);
22
+ return null;
23
+ }
24
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
25
+ act(() => root.render(<Probe />));
26
+ act(() => player.getApi().refreshPlayer());
27
+ const shadow = makePreview("<h1>Title</h1>", "?_t=1");
28
+ await paintShadow(player.getApi, shadow);
29
+ expect(player.getApi().iframeRef.current).toBe(shadow);
30
+ expect(live).toBe(shadow);
31
+ act(() => root.unmount());
32
+ act(() => player.root.unmount());
33
+ });
package/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { IconContext } from "@phosphor-icons/react";
4
4
  import { StudioApp } from "./App";
5
+ import { ShowThemeToggle } from "./components/ThemeToggle";
5
6
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
7
  import { readIconTokens } from "./styles/iconTokens";
7
8
  import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
128
129
  Icons that pass their own size or weight still win. */}
129
130
  <IconContext.Provider value={readIconTokens()}>
130
131
  <StudioErrorBoundary>
131
- <StudioApp />
132
+ <ShowThemeToggle.Provider value>
133
+ <StudioApp />
134
+ </ShowThemeToggle.Provider>
132
135
  </StudioErrorBoundary>
133
136
  </IconContext.Provider>
134
137
  </StrictMode>,
@@ -20,24 +20,30 @@ afterEach(() => {
20
20
  document.body.innerHTML = "";
21
21
  });
22
22
 
23
+ /** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
24
+ function recordingCanvas() {
25
+ const fills: Array<[string, number, number, number, number]> = [];
26
+ const context = {
27
+ scale: vi.fn(),
28
+ clearRect: vi.fn(),
29
+ fillStyle: "",
30
+ fillRect: (x: number, y: number, width: number, height: number) =>
31
+ fills.push([String(context.fillStyle), x, y, width, height]),
32
+ } as unknown as CanvasRenderingContext2D;
33
+ const canvas = document.createElement("canvas");
34
+ Object.defineProperties(canvas, {
35
+ clientWidth: { value: 6 },
36
+ clientHeight: { value: 20 },
37
+ });
38
+ vi.spyOn(canvas, "getContext").mockReturnValue(context);
39
+ return { canvas, fills };
40
+ }
41
+
23
42
  describe("AudioWaveform", () => {
24
43
  it("paints a baseline and peak bar for every mapped waveform bin", () => {
25
- const fillRect = vi.fn();
26
- const context = {
27
- scale: vi.fn(),
28
- clearRect: vi.fn(),
29
- fillRect,
30
- fillStyle: "",
31
- } as unknown as CanvasRenderingContext2D;
32
- const canvas = document.createElement("canvas");
33
- Object.defineProperties(canvas, {
34
- clientWidth: { value: 6 },
35
- clientHeight: { value: 20 },
36
- });
37
- vi.spyOn(canvas, "getContext").mockReturnValue(context);
44
+ const { canvas, fills } = recordingCanvas();
38
45
  drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
39
- expect(fillRect).toHaveBeenCalledTimes(4);
40
- expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
46
+ expect(fills.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
41
47
  [0, 18, 3, 2],
42
48
  [0, 15, 3, 5],
43
49
  [3, 18, 3, 2],
@@ -45,6 +51,21 @@ describe("AudioWaveform", () => {
45
51
  ]);
46
52
  });
47
53
 
54
+ it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
55
+ const { canvas, fills } = recordingCanvas();
56
+ // A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.
57
+ drawWaveformCanvas(canvas, [1, 1], false, 0, 1, { fadeIn: 1, fadeOut: 0, duration: 2 });
58
+ const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
59
+ // The heard half, then the ghost only above it, so the two never stack.
60
+ expect(bars.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
61
+ [0, 10, 3, 10],
62
+ [0, 0, 3, 10],
63
+ [3, 0, 3, 20],
64
+ ]);
65
+ const alpha = (style: string) => Number(style.split(",").at(-1)?.replace(")", ""));
66
+ expect(alpha(bars[1][0])).toBeCloseTo(alpha(bars[0][0]) * 0.27, 2);
67
+ });
68
+
48
69
  it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
49
70
  const host = document.createElement("div");
50
71
  document.body.append(host);
@@ -1,8 +1,10 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef } from "react";
1
+ import { memo, useCallback, useContext, useEffect, useMemo, useRef } from "react";
2
+ import { fadeGain } from "@hyperframes/core/audio-fade";
2
3
  import { useMountEffect } from "../../hooks/useMountEffect";
3
4
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
5
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
6
  import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
7
+ import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
6
8
 
7
9
  export interface AudioWaveformProps {
8
10
  audioUrl: string;
@@ -22,7 +24,10 @@ export interface AudioWaveformProps {
22
24
 
23
25
  const BAR_STEP = 3;
24
26
 
25
- type BarGeometry = { x: number; width: number; height: number };
27
+ export const rendersWaveform = (el: { tag: string }) => el.tag === "audio";
28
+ const FADE_GHOST_OPACITY = 0.27;
29
+
30
+ type BarGeometry = { x: number; width: number; height: number; gain: number };
26
31
 
27
32
  function paintWaveformBars(
28
33
  context: CanvasRenderingContext2D,
@@ -33,11 +38,18 @@ function paintWaveformBars(
33
38
  amplitudes: readonly number[],
34
39
  ) {
35
40
  bars.forEach((bar, index) => {
36
- const amplitude = amplitudes[index] ?? 0;
41
+ const opacity = loudnessToOpacity(amplitudes[index] ?? 0);
37
42
  context.fillStyle = `rgb(${waveformBaselineRgb})`;
38
43
  context.fillRect(bar.x, height - 2, bar.width, 2);
39
- context.fillStyle = `rgba(${waveformBarRgb},${loudnessToOpacity(amplitude).toFixed(2)})`;
40
- context.fillRect(bar.x, height - bar.height, bar.width, bar.height);
44
+ const paint = (alpha: number, top: number, barHeight: number) => {
45
+ context.fillStyle = `rgba(${waveformBarRgb},${alpha.toFixed(2)})`;
46
+ context.fillRect(bar.x, top, bar.width, barHeight);
47
+ };
48
+ const faded = bar.height * bar.gain;
49
+ if (faded > 0) paint(opacity, height - faded, faded);
50
+ if (faded < bar.height) {
51
+ paint(opacity * FADE_GHOST_OPACITY, height - bar.height, bar.height - faded);
52
+ }
41
53
  });
42
54
  }
43
55
 
@@ -47,6 +59,7 @@ export function drawWaveformCanvas(
47
59
  muted: boolean,
48
60
  trimStartFraction: number,
49
61
  trimEndFraction: number,
62
+ fades: ClipFadeShape | null = null,
50
63
  ) {
51
64
  const width = Math.max(1, canvas.clientWidth);
52
65
  const height = Math.max(1, canvas.clientHeight);
@@ -67,6 +80,9 @@ export function drawWaveformCanvas(
67
80
  x: (index * width) / amplitudes.length,
68
81
  width: Math.max(1, width / amplitudes.length),
69
82
  height: Math.max(3, amplitude * height),
83
+ gain: fades
84
+ ? fadeGain(((index + 0.5) / amplitudes.length) * fades.duration, fades.duration, fades)
85
+ : 1,
70
86
  }));
71
87
  const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
72
88
  const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
@@ -179,11 +195,12 @@ export const AudioWaveform = memo(function AudioWaveform({
179
195
  const peaks =
180
196
  snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null;
181
197
 
198
+ const fades = useContext(ClipFadesContext);
182
199
  const draw = useCallback(() => {
183
200
  const canvas = canvasRef.current;
184
201
  if (!canvas || !peaks) return;
185
- drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
186
- }, [muted, peaks, trimEndFraction, trimStartFraction]);
202
+ drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1, fades);
203
+ }, [fades, muted, peaks, trimEndFraction, trimStartFraction]);
187
204
 
188
205
  const setCanvasRef = useCallback(
189
206
  (canvas: HTMLCanvasElement | null) => {
@@ -16,7 +16,6 @@ import {
16
16
  getTimelineScrollLeftForZoomAnchor,
17
17
  getTimelineScrollLeftForZoomTransition,
18
18
  shouldShowTimelineShortcutHint,
19
- shouldHandleTimelineDeleteKey,
20
19
  shouldAutoScrollTimeline,
21
20
  getTimelineVisibleTimeRange,
22
21
  getTimelineScrollTopForGeometryChange,
@@ -1248,26 +1247,6 @@ describe("shouldShowTimelineShortcutHint", () => {
1248
1247
  });
1249
1248
  });
1250
1249
 
1251
- describe("shouldHandleTimelineDeleteKey", () => {
1252
- it("handles Delete and Backspace when focus is not in an editor", () => {
1253
- expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
1254
- expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
1255
- });
1256
-
1257
- it("ignores modifier shortcuts", () => {
1258
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
1259
- expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
1260
- });
1261
-
1262
- it("ignores input and editable targets", () => {
1263
- const input = { tagName: "INPUT", isContentEditable: false };
1264
- const editable = { tagName: "DIV", isContentEditable: true };
1265
-
1266
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
1267
- expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
1268
- });
1269
- });
1270
-
1271
1250
  describe("getDefaultDroppedTrack", () => {
1272
1251
  it("defaults to track 0 when there are no rows yet", () => {
1273
1252
  expect(getDefaultDroppedTrack([])).toBe(0);
@@ -26,7 +26,6 @@ export {
26
26
  getTimelineCanvasHeight,
27
27
  shouldShowTimelineShortcutHint,
28
28
  resolveTimelineAssetDrop,
29
- shouldHandleTimelineDeleteKey,
30
29
  getDefaultDroppedTrack,
31
30
  } from "./timelineLayout";
32
31
  export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { TimelineClip } from "./TimelineClip";
8
+ import { ClipFadesContext } from "./TimelineClipFades";
8
9
  import type { TimelineEditCapabilities } from "./timelineEditing";
9
10
  import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
10
11
 
@@ -67,6 +68,43 @@ function renderClip({
67
68
  }
68
69
 
69
70
  describe("TimelineClip", () => {
71
+ it("hands its content the clip's fades, and none to a clip the mixer does not hear", () => {
72
+ const seen: unknown[] = [];
73
+ function Probe() {
74
+ seen.push(React.useContext(ClipFadesContext));
75
+ return null;
76
+ }
77
+ const render = (element: TimelineElement) => {
78
+ const host = document.createElement("div");
79
+ document.body.append(host);
80
+ const root = createRoot(host);
81
+ act(() => {
82
+ root.render(
83
+ <TimelineClip
84
+ el={element}
85
+ pps={100}
86
+ clipY={0}
87
+ isSelected={false}
88
+ isHovered={false}
89
+ hasCustomContent
90
+ capabilities={capabilities}
91
+ isComposition={false}
92
+ onHoverStart={vi.fn()}
93
+ onHoverEnd={vi.fn()}
94
+ onClick={vi.fn()}
95
+ onDoubleClick={vi.fn()}
96
+ >
97
+ <Probe />
98
+ </TimelineClip>,
99
+ );
100
+ });
101
+ act(() => root.unmount());
102
+ };
103
+ render({ id: "music", tag: "audio", start: 0, duration: 4, track: 0, fadeIn: 1, fadeOut: 0.5 });
104
+ render({ id: "title", tag: "div", start: 0, duration: 4, track: 0, fadeIn: 1 });
105
+ expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
106
+ });
107
+
70
108
  it("renders the clip label above custom content without showing default timecode", () => {
71
109
  const { host, root } = renderClip({
72
110
  element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
@@ -9,7 +9,8 @@ import {
9
9
  import type { TimelineEditCapabilities } from "./timelineEditing";
10
10
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
11
11
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
12
- import { TimelineClipFades } from "./TimelineClipFades";
12
+ import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
13
+ import { rendersWaveform } from "./AudioWaveform";
13
14
 
14
15
  interface TimelineClipProps {
15
16
  el: TimelineElement;
@@ -82,6 +83,8 @@ export const TimelineClip = memo(function TimelineClip({
82
83
  "--clip-handle": theme.handleColor,
83
84
  } as CSSProperties;
84
85
  const isAudioClip = isAudioTimelineElement(el);
86
+ const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
87
+ const fade = useClipFadeDraft(el);
85
88
  const clipClassName = [
86
89
  "timeline-clip",
87
90
  "absolute",
@@ -206,16 +209,21 @@ export const TimelineClip = memo(function TimelineClip({
206
209
  {startLabel}-{endLabel}s
207
210
  </span>
208
211
  )}
209
- {children}
210
- {/* Fade handles + ramps for anything the mixer hears — audio clips and
211
- videos marked data-has-audio. They write data-fade-in/out on the clip
212
- and are the timeline half of the inspector's Fade rows. */}
213
- {(isAudioClip || el.hasAudio) && !isGestureActor && (
212
+ <ClipFadesContext.Provider value={hasFades ? fade.shape : null}>
213
+ {children}
214
+ </ClipFadesContext.Provider>
215
+ {/* Fade handles for anything the mixer hears: audio clips and videos marked
216
+ data-has-audio. They write data-fade-in/out, the timeline half of the
217
+ inspector's Fade rows. */}
218
+ {hasFades && (
214
219
  <TimelineClipFades
215
220
  el={el}
216
221
  pps={pps}
217
222
  widthPx={widthPx}
218
223
  showHandles={(isHovered || isSelected) && !isDragging}
224
+ focusable={isSelected}
225
+ hasWaveform={rendersWaveform(el)}
226
+ fade={fade}
219
227
  />
220
228
  )}
221
229
  </button>