@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
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
506
506
  return scrollHeight - clientHeight <= 1;
507
507
  }
508
508
 
509
- export function shouldHandleTimelineDeleteKey(input: {
510
- key: string;
511
- metaKey?: boolean;
512
- ctrlKey?: boolean;
513
- altKey?: boolean;
514
- target?: EventTarget | null;
515
- }): boolean {
516
- if (input.key !== "Delete" && input.key !== "Backspace") return false;
517
- if (input.metaKey || input.ctrlKey || input.altKey) return false;
518
- const target =
519
- input.target && typeof input.target === "object"
520
- ? (input.target as {
521
- tagName?: string;
522
- isContentEditable?: boolean;
523
- closest?: (selector: string) => Element | null;
524
- })
525
- : null;
526
- if (target) {
527
- const tag = target.tagName?.toLowerCase() ?? "";
528
- if (target.isContentEditable) return false;
529
- if (["input", "textarea", "select"].includes(tag)) return false;
530
- if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
531
- return false;
532
- }
533
- }
534
- return true;
535
- }
536
-
537
509
  /* ── Asset drop ───────────────────────────────────────────────────── */
538
510
  export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
539
511
  if (trackOrder.length === 0) return 0;
@@ -22,7 +22,7 @@ function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
22
22
  // Header actions stay native Tab stops because they have no row-level shortcut.
23
23
  // The nearest interactive ancestor wins so their events never masquerade as row events.
24
24
  const target = event.target.closest<HTMLElement>(
25
- "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
25
+ "button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
26
26
  );
27
27
  return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
28
28
  }
@@ -118,3 +118,30 @@ export function attachIframeAdapter(
118
118
  attachIframeWindow(api, win);
119
119
  return adapter;
120
120
  }
121
+
122
+ export function makePreview(body: string, query = ""): HTMLIFrameElement {
123
+ const iframe = makeFakeIframe(makeAdapterWindow().win);
124
+ iframe.src = `http://localhost/api/projects/demo/preview${query}`;
125
+ iframe.contentDocument!.body.innerHTML = body;
126
+ return iframe;
127
+ }
128
+
129
+ export function mountPlayerWithPreview(live: HTMLIFrameElement) {
130
+ const harness = renderTimelinePlayerHarness();
131
+ act(() => {
132
+ harness.getApi().iframeRef.current = live;
133
+ harness.getApi().onIframeLoad();
134
+ });
135
+ return harness;
136
+ }
137
+
138
+ export async function paintShadow(getApi: () => TimelinePlayerApi, shadow: HTMLIFrameElement) {
139
+ const slot = getApi().previewSlots.find((s) => s.role === "shadow");
140
+ if (!slot) throw new Error("no shadow reload is pending");
141
+ act(() => getApi().setShadowIframeNode(shadow));
142
+ await act(async () => {
143
+ getApi().onShadowIframeLoad(slot.gen);
144
+ getApi().onShadowReadyChange(slot.gen, true);
145
+ });
146
+ return slot.gen;
147
+ }
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
261
261
  expect(usePlayerStore.getState().loopEnabled).toBe(false);
262
262
  });
263
263
  });
264
+
265
+ describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
266
+ const KEYS = [
267
+ { code: "ArrowLeft", key: "ArrowLeft" },
268
+ { code: "ArrowRight", key: "ArrowRight" },
269
+ { code: "Space", key: " " },
270
+ { code: "KeyJ", key: "j" },
271
+ { code: "KeyK", key: "k" },
272
+ { code: "KeyL", key: "l" },
273
+ ];
274
+
275
+ it.each(["video", "audio"])(
276
+ "leaves the timeline alone while a <%s controls> has focus",
277
+ (tag) => {
278
+ const { dispatch, spies } = setupHook();
279
+ const player = document.createElement(tag);
280
+ player.setAttribute("controls", "");
281
+ document.body.append(player);
282
+ const before = usePlayerStore.getState().currentTime;
283
+ for (const init of KEYS) {
284
+ const event = keydown(init);
285
+ Object.defineProperty(event, "target", { value: player });
286
+ dispatch(event);
287
+ expect(event.defaultPrevented, init.code).toBe(false);
288
+ }
289
+ expect(spies.seek).not.toHaveBeenCalled();
290
+ expect(spies.play).not.toHaveBeenCalled();
291
+ expect(spies.playBackward).not.toHaveBeenCalled();
292
+ expect(spies.pause).not.toHaveBeenCalled();
293
+ expect(usePlayerStore.getState().currentTime).toBe(before);
294
+ },
295
+ );
296
+
297
+ it("still steps the timeline from a player without controls", () => {
298
+ const { dispatch } = setupHook();
299
+ const video = document.createElement("video");
300
+ document.body.append(video);
301
+ const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
302
+ Object.defineProperty(event, "target", { value: video });
303
+ dispatch(event);
304
+ expect(event.defaultPrevented).toBe(true);
305
+ });
306
+ });
@@ -3,6 +3,11 @@
3
3
 
4
4
  import { useCallback, useRef, useState } from "react";
5
5
  import { useMountEffect } from "../../hooks/useMountEffect";
6
+ import {
7
+ afterStudioManualEditGestures,
8
+ isStudioManualEditGestureLiveIn,
9
+ studioManualEditSavesIn,
10
+ } from "../../components/editor/manualEditsDom";
6
11
  import { logReload } from "../../utils/reloadDebug";
7
12
  import {
8
13
  useTimelineSyncCallbacks,
@@ -14,6 +19,8 @@ import {
14
19
  } from "./useTimelineSyncCallbacks";
15
20
  import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
16
21
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
22
+ import { announcePreviewPromoted } from "../sceneSwap";
23
+ import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
17
24
  import { usePlayerStore } from "../store/playerStore";
18
25
 
19
26
  // One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
@@ -78,12 +85,18 @@ export function useShadowPreviewReload({
78
85
  // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
79
86
  const budgetGenRef = useRef<number | null>(null);
80
87
  const cancelPendingLoadRef = useRef<() => void>(() => {});
88
+ const stopHoldRef = useRef<() => void>(() => {});
89
+ const shadowUrlRef = useRef("");
90
+ const savesAtRequestRef = useRef(0);
91
+ const beginShadowReloadRef = useRef<(url: string) => void>(() => {});
92
+ const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {});
81
93
  const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
82
94
 
83
95
  const stopPendingShadow = useCallback(() => {
84
96
  clearTimeout(readyTimerRef.current);
85
97
  budgetGenRef.current = null;
86
98
  cancelPendingLoadRef.current();
99
+ stopHoldRef.current();
87
100
  pendingCommitRef.current = null;
88
101
  visuallyReadyGenRef.current = null;
89
102
  }, []);
@@ -106,12 +119,58 @@ export function useShadowPreviewReload({
106
119
  [stopPendingShadow, isRefreshingRef, pendingSeekRef],
107
120
  );
108
121
 
122
+ const gestureSavesOnScreen = useCallback(() => {
123
+ const liveDoc = iframeRef.current?.contentDocument;
124
+ return liveDoc ? studioManualEditSavesIn(liveDoc) : 0;
125
+ }, [iframeRef]);
126
+
127
+ const waitThenRetry = useCallback(
128
+ (gen: number, why: string, wait: (retry: () => void) => () => void) => {
129
+ clearTimeout(readyTimerRef.current);
130
+ budgetGenRef.current = null;
131
+ stopHoldRef.current();
132
+ logReload("shadow-held", { gen, why });
133
+ stopHoldRef.current = wait(() => {
134
+ logReload("shadow-hold-ended", { gen, why });
135
+ promoteWhenReadyRef.current(gen);
136
+ });
137
+ return true;
138
+ },
139
+ [],
140
+ );
141
+
142
+ // Nothing replaces the preview under a gesture, and a shadow requested before a gesture's save
143
+ // shows the file before it: once that save has landed, a fresh one loads instead.
144
+ const heldOrStale = useCallback(
145
+ (gen: number) => {
146
+ const liveDoc = iframeRef.current?.contentDocument;
147
+ if (liveDoc && isStudioManualEditGestureLiveIn(liveDoc))
148
+ return waitThenRetry(gen, "gesture", (retry) =>
149
+ afterStudioManualEditGestures(liveDoc, retry),
150
+ );
151
+ if (gestureSavesOnScreen() === savesAtRequestRef.current) return false;
152
+ if (isStudioEditSaving()) return waitThenRetry(gen, "save", afterStudioPendingEdits);
153
+ logReload("shadow-reloaded-after-gesture-save", { gen });
154
+ const url = new URL(shadowUrlRef.current, window.location.origin);
155
+ url.searchParams.set("_t", String(Date.now()));
156
+ beginShadowReloadRef.current(url.toString());
157
+ return true;
158
+ },
159
+ [iframeRef, gestureSavesOnScreen, waitThenRetry],
160
+ );
161
+
162
+ const shadowReadyToPromote = useCallback((gen: number) => {
163
+ const shadow = shadowIframeRef.current;
164
+ const pending = pendingCommitRef.current;
165
+ const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
166
+ return shadow && pending && ready && gen === shadowGenRef.current ? { shadow, pending } : null;
167
+ }, []);
168
+
109
169
  const promoteWhenReady = useCallback(
110
170
  (gen: number) => {
111
- const shadow = shadowIframeRef.current;
112
- const pending = pendingCommitRef.current;
113
- const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
114
- if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
171
+ const promotable = shadowReadyToPromote(gen);
172
+ if (!promotable || heldOrStale(gen)) return;
173
+ const { shadow, pending } = promotable;
115
174
  stopPendingShadow();
116
175
  // The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
117
176
  const live = getAdapter();
@@ -119,6 +178,7 @@ export function useShadowPreviewReload({
119
178
  const playing = usePlayerStore.getState().isPlaying;
120
179
  live?.pause();
121
180
  // The store takes the new document's timeline only now that it is the one on screen, and reads it there.
181
+ const retired = iframeRef.current;
122
182
  iframeRef.current = shadow;
123
183
  pending.commit();
124
184
  shadowIframeRef.current = null;
@@ -126,17 +186,21 @@ export function useShadowPreviewReload({
126
186
  applyPreviewAudioState();
127
187
  setPreviewSlots((prev) => planShadowPromotion(prev, gen));
128
188
  if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
189
+ announcePreviewPromoted({ retired, live: shadow });
129
190
  onPromotedRef.current?.();
130
191
  thumbnailScheduler.setPreviewReloading(false);
131
192
  },
132
193
  [
133
194
  stopPendingShadow,
195
+ shadowReadyToPromote,
196
+ heldOrStale,
134
197
  getAdapter,
135
198
  iframeRef,
136
199
  attachIframeShortcutListeners,
137
200
  applyPreviewAudioState,
138
201
  ],
139
202
  );
203
+ promoteWhenReadyRef.current = promoteWhenReady;
140
204
 
141
205
  const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
142
206
  const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
@@ -216,13 +280,16 @@ export function useShadowPreviewReload({
216
280
  stopPendingShadow();
217
281
  budgetGenRef.current = gen;
218
282
  budgetsSpentRef.current = 0;
283
+ shadowUrlRef.current = url;
284
+ savesAtRequestRef.current = gestureSavesOnScreen();
219
285
  armReadyTimer(gen);
220
286
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
221
287
  // Thumbnails of the edit wait for the new preview instead of competing with it.
222
288
  thumbnailScheduler.setPreviewReloading(true);
223
289
  },
224
- [stopPendingShadow, armReadyTimer],
290
+ [stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
225
291
  );
292
+ beginShadowReloadRef.current = beginShadowReload;
226
293
 
227
294
  // Composition switch (not an edit reload): drop any in-flight shadow.
228
295
  const resetPreviewSlots = useCallback(() => {
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
- import { syncStoredAutomationFromPreview } from "./automationStoreSync";
3
+ import { syncStoredAutomationFromPreview, syncStoredElementAttribute } from "./automationStoreSync";
4
4
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
5
 
6
6
  const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
@@ -32,7 +32,24 @@ beforeEach(() => {
32
32
  usePlayerStore.getState().reset();
33
33
  });
34
34
 
35
+ describe("syncStoredElementAttribute", () => {
36
+ it("records a saved fade on the stored clip, so the timeline draws what the file holds", () => {
37
+ usePlayerStore.setState({ elements: [el({ fadeIn: 1 })] });
38
+ syncStoredElementAttribute(el(), "data-fade-in", "2.5");
39
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBe(2.5);
40
+ syncStoredElementAttribute(el(), "data-fade-in", null);
41
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBeUndefined();
42
+ });
43
+ });
44
+
35
45
  describe("syncStoredAutomationFromPreview", () => {
46
+ it("reads back a fade an undo restored on the preview", () => {
47
+ usePlayerStore.setState({ elements: [el({ fadeOut: 2 })] });
48
+ syncStoredAutomationFromPreview(previewWith({ "data-fade-in": "0.6" }));
49
+ const stored = usePlayerStore.getState().elements[0];
50
+ expect([stored?.fadeIn, stored?.fadeOut]).toEqual([0.6, undefined]);
51
+ });
52
+
36
53
  it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
37
54
  usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
38
55
  const doc = document.implementation.createHTMLDocument("preview");
@@ -16,12 +16,18 @@
16
16
 
17
17
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
18
18
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
19
+ import {
20
+ HF_AUDIO_FADE_IN_ATTR,
21
+ HF_AUDIO_FADE_OUT_ATTR,
22
+ readElementFades,
23
+ readFadeSeconds,
24
+ } from "@hyperframes/core/audio-fade";
19
25
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
20
26
  import { groupInfoFor } from "./timelineGroupInfo";
21
27
  import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
22
28
 
23
29
  /**
24
- * Re-read every element's automation and FX-chain attributes from the preview
30
+ * Re-read every element's automation, FX-chain and fade attributes from the preview
25
31
  * document, for a change that reached the DOM without going through this store.
26
32
  *
27
33
  * That is undo and redo. A soft restore patches the reverted attributes onto the
@@ -34,21 +40,21 @@ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElem
34
40
  * reverted is only known by looking.
35
41
  */
36
42
  /**
37
- * What an element's four synced fields SHOULD read, given the preview.
43
+ * What an element's six synced fields SHOULD read, given the preview.
38
44
  *
39
- * Its own two come off its node; the other two are its copy of what its group
45
+ * Its own four come off its node; the other two are its copy of what its group
40
46
  * carries. The timeline derives a group's lanes and chain from these mirrors,
41
- * never from the group element — and the FX rack is not group-aware: selecting
42
- * a group and automating one of its parameters writes `data-automation` on the
43
- * group node through the ordinary element path, which used to refresh an
44
- * element's own two fields and nothing else. So the group's row went on reading
45
- * the value it was born with, and its `∿` never appeared.
47
+ * never from the group element, and automating a group writes `data-automation`
48
+ * on the group node through the ordinary element path, so both halves are re-read.
46
49
  */
47
50
  function syncedFields(doc: Document, element: TimelineElement, node: Element) {
48
51
  const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
52
+ const fades = readElementFades(node);
49
53
  return {
50
54
  automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
51
55
  fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
56
+ fadeIn: storedFade(fades.fadeIn),
57
+ fadeOut: storedFade(fades.fadeOut),
52
58
  audioGroupAutomation: group?.automation,
53
59
  audioGroupFxChain: group?.fxChain,
54
60
  };
@@ -74,27 +80,33 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
74
80
  });
75
81
  }
76
82
 
77
- const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
78
- [HF_AUDIO_AUTOMATION_ATTR]: "automation",
79
- [HF_AUDIO_FX_ATTR]: "fxChain",
83
+ /** Discovery's rule (applyFadeMetadataFromElement): a fade of 0 is no fade. */
84
+ const storedFade = (seconds: number) => (seconds > 0 ? seconds : undefined);
85
+
86
+ const STORED_FIELD: Record<string, (value: string | null) => Partial<TimelineElement>> = {
87
+ [HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ automation: value ?? undefined }),
88
+ [HF_AUDIO_FX_ATTR]: (value) => ({ fxChain: value ?? undefined }),
89
+ [HF_AUDIO_FADE_IN_ATTR]: (value) => ({ fadeIn: storedFade(readFadeSeconds(value)) }),
90
+ [HF_AUDIO_FADE_OUT_ATTR]: (value) => ({ fadeOut: storedFade(readFadeSeconds(value)) }),
80
91
  };
81
92
 
82
- /** Record a saved automation or FX-chain value on one element's stored copy. */
93
+ /** Record a saved automation, FX-chain or fade value on one element's stored copy. */
83
94
  export function syncStoredElementAttribute(
84
95
  target: TimelineElement,
85
96
  attr: string,
86
97
  value: string | null,
87
98
  ): void {
88
- const field = STORED_FIELD[attr];
89
- if (!field) return;
99
+ const fields = STORED_FIELD[attr]?.(value);
100
+ if (!fields) return;
90
101
  const key = getTimelineElementIdentity(target);
91
- const next = value ?? undefined;
102
+ const same = (element: TimelineElement) =>
103
+ (Object.keys(fields) as (keyof TimelineElement)[]).every((k) => element[k] === fields[k]);
92
104
  usePlayerStore.setState((state) => {
93
105
  let changed = false;
94
106
  const elements = state.elements.map((element) => {
95
- if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
107
+ if (getTimelineElementIdentity(element) !== key || same(element)) return element;
96
108
  changed = true;
97
- return { ...element, [field]: next };
109
+ return { ...element, ...fields };
98
110
  });
99
111
  return changed ? { elements } : {};
100
112
  });
@@ -6,7 +6,7 @@
6
6
  * is active and the user is navigating caption segments).
7
7
  */
8
8
 
9
- import { isTypingTarget } from "../../utils/typingTarget";
9
+ import { ownsPlainKeys } from "../../utils/typingTarget";
10
10
 
11
11
  const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
12
12
 
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
15
15
  "a[href]",
16
16
  "[role='button']",
17
17
  "[role='checkbox']",
18
- "[role='combobox']",
19
18
  "[role='menuitem']",
20
19
  // Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
21
20
  "[role='menuitemradio']",
22
21
  "[role='radio']",
23
- "[role='slider']",
24
22
  "[role='spinbutton']",
25
- "[role='switch']",
26
- "[role='textbox']",
27
23
  ].join(",");
28
24
 
29
25
  export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
30
26
  // Anything the user is typing into owns its keys outright, editable elements
31
- // included: a letter claimed here never reaches the text.
32
- if (isTypingTarget(target)) return true;
27
+ // included: a letter claimed here never reaches the text. So does a native player.
28
+ if (ownsPlainKeys(target)) return true;
33
29
  if (!target || typeof target !== "object") return false;
34
30
  const candidate = target as { closest?: unknown };
35
31
  if (typeof candidate.closest !== "function") return false;
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isVideoAudible } from "./timelineElementHelpers";
3
+
4
+ describe("isVideoAudible — the compiler's data-has-audio rule", () => {
5
+ it("explicit data-has-audio wins", () => {
6
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "true", muted: false })).toBe(true);
7
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "false", muted: false })).toBe(false);
8
+ });
9
+ it("no attribute: an unmuted video is audible, a muted one is not", () => {
10
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: null, muted: false })).toBe(true);
11
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: undefined, muted: true })).toBe(false);
12
+ });
13
+ it("never true for non-video without the attribute", () => {
14
+ expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
15
+ });
16
+ });
@@ -220,12 +220,26 @@ function readVolume(el: Element, media: Element): number | undefined {
220
220
  return Number.isFinite(volume) ? volume : undefined;
221
221
  }
222
222
 
223
+ /** The compiler's rule (timingCompiler): explicit data-has-audio wins; otherwise an unmuted <video> is audible. */
224
+ export function isVideoAudible(opts: {
225
+ tag: string;
226
+ hasAudioAttr: string | null | undefined;
227
+ muted: boolean;
228
+ }): boolean {
229
+ if (opts.hasAudioAttr === "true") return true;
230
+ if (opts.hasAudioAttr === "false" || opts.hasAudioAttr === "") return false;
231
+ return opts.tag.toLowerCase() === "video" && !opts.muted;
232
+ }
233
+
223
234
  /** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
224
235
  function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
225
236
  const media = resolveMediaElement(el) ?? el;
226
237
  const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
227
- const hasAudio = el.getAttribute("data-has-audio");
228
- const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
238
+ const sound = isVideoAudible({
239
+ tag: el.tagName,
240
+ hasAudioAttr: el.getAttribute("data-has-audio"),
241
+ muted,
242
+ });
229
243
  setOptional(entry, "hasAudio", sound ? true : undefined);
230
244
  setOptional(entry, "muted", muted ? true : undefined);
231
245
  setOptional(entry, "volume", readVolume(el, media));
@@ -19,6 +19,8 @@ describe("timeline viewport budgets", () => {
19
19
  interactionP95Ms: 50,
20
20
  constrainedInteractionP95Ms: 75,
21
21
  constrainedFrameIntervalP95Ms: 75,
22
+ ciVirtualizedInteractionP95Ms: 58.3,
23
+ ciVirtualizedFrameIntervalP95Ms: 25,
22
24
  longTaskLimitMs: 50,
23
25
  constrainedLongTaskLimitMs: 300,
24
26
  posterCoverageRatio: 0.9,
@@ -28,6 +28,8 @@ export interface TimelineViewportBudgets {
28
28
  frameIntervalP95Ms: number;
29
29
  constrainedInteractionP95Ms: number;
30
30
  constrainedFrameIntervalP95Ms: number;
31
+ ciVirtualizedInteractionP95Ms: number;
32
+ ciVirtualizedFrameIntervalP95Ms: number;
31
33
  longTaskLimitMs: number;
32
34
  constrainedLongTaskLimitMs: number;
33
35
  memoryReturnToleranceRatio: number;
@@ -86,6 +88,8 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
86
88
  frameIntervalP95Ms: 33.3,
87
89
  constrainedInteractionP95Ms: 75,
88
90
  constrainedFrameIntervalP95Ms: 75,
91
+ ciVirtualizedInteractionP95Ms: 58.3,
92
+ ciVirtualizedFrameIntervalP95Ms: 25,
89
93
  longTaskLimitMs: 50,
90
94
  constrainedLongTaskLimitMs: 300,
91
95
  memoryReturnToleranceRatio: 0.15,
@@ -17,6 +17,24 @@ export function onPreviewContentReplaced(
17
17
  };
18
18
  }
19
19
 
20
+ const PREVIEW_PROMOTED = "hf-preview-promoted";
21
+ export type PreviewPromotion = { retired: HTMLIFrameElement | null; live: HTMLIFrameElement };
22
+
23
+ export function announcePreviewPromoted(promotion: PreviewPromotion): void {
24
+ promotion.live.ownerDocument.dispatchEvent(
25
+ new CustomEvent(PREVIEW_PROMOTED, { detail: promotion }),
26
+ );
27
+ }
28
+
29
+ export function onPreviewPromoted(
30
+ doc: Document,
31
+ listener: (promotion: PreviewPromotion) => void,
32
+ ): () => void {
33
+ const handle = (event: Event) => listener((event as CustomEvent<PreviewPromotion>).detail);
34
+ doc.addEventListener(PREVIEW_PROMOTED, handle);
35
+ return () => doc.removeEventListener(PREVIEW_PROMOTED, handle);
36
+ }
37
+
20
38
  export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
21
39
  const doc = iframe?.contentDocument;
22
40
  const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
@@ -253,6 +253,32 @@
253
253
  border-radius: var(--timeline-clip-audio-radius);
254
254
  }
255
255
 
256
+ .timeline-fade-tab {
257
+ position: absolute;
258
+ border-radius: 2px;
259
+ background: var(--timeline-fade-dot);
260
+ box-shadow:
261
+ 0 0 0 1px var(--timeline-fade-dot-ring),
262
+ 0 1px 3px var(--timeline-fade-dot-ring);
263
+ transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
264
+ }
265
+
266
+ .timeline-fade-handle:hover .timeline-fade-tab,
267
+ .timeline-fade-handle[data-dragging] .timeline-fade-tab {
268
+ transform: scaleY(1.2);
269
+ }
270
+
271
+ .timeline-fade-handle:focus-visible .timeline-fade-tab {
272
+ outline: 2px solid var(--timeline-accent);
273
+ outline-offset: 2px;
274
+ }
275
+
276
+ @media (prefers-reduced-motion: reduce) {
277
+ .timeline-fade-tab {
278
+ transition: none;
279
+ }
280
+ }
281
+
256
282
  .timeline-clip.is-audio.is-hovered {
257
283
  background-color: var(--timeline-clip-audio-bg-hover);
258
284
  }
@@ -16,6 +16,12 @@
16
16
  }
17
17
  }
18
18
 
19
+ [data-theme-reveal]::view-transition-old(root),
20
+ [data-theme-reveal]::view-transition-new(root) {
21
+ animation: none;
22
+ mix-blend-mode: normal;
23
+ }
24
+
19
25
  body {
20
26
  margin: 0;
21
27
  padding: 0;
@@ -77,6 +77,16 @@
77
77
  --color-media: light-dark(oklch(0.5 0.1 215), #00e3ff);
78
78
  --color-container: light-dark(oklch(0.55 0.13 70), #f5a623);
79
79
 
80
+ /* Code editor syntax: One Light and One Dark hues, held at 4.5:1 on a line, the active line and a selection on it. */
81
+ --color-code-keyword: light-dark(oklch(0.509 0.211 329), oklch(0.829 0.164 318));
82
+ --color-code-name: light-dark(oklch(0.504 0.179 28), oklch(0.856 0.145 17));
83
+ --color-code-function: light-dark(oklch(0.492 0.193 263), oklch(0.805 0.121 245));
84
+ --color-code-constant: light-dark(oklch(0.495 0.13 50), oklch(0.815 0.11 50));
85
+ --color-code-type: light-dark(oklch(0.48 0.12 92), oklch(0.805 0.1 92));
86
+ --color-code-operator: light-dark(oklch(0.471 0.127 237), oklch(0.793 0.092 206));
87
+ --color-code-comment: light-dark(oklch(0.48 0.009 279), oklch(0.806 0.03 262));
88
+ --color-code-string: light-dark(oklch(0.468 0.142 143), oklch(0.793 0.11 133));
89
+
80
90
  /* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values
81
91
  * rather than roles, so they are not reachable as a background or text colour by mistake. */
82
92
  --color-brand-gradient-from: #06e3fa;
@@ -17,6 +17,7 @@ export type ClipboardPayload =
17
17
  clips: TimelineClipboardClip[];
18
18
  sourceFile: string;
19
19
  projectId?: string;
20
+ copiedInComposition?: string;
20
21
  }
21
22
  | {
22
23
  kind: "dom-element";
@@ -63,7 +64,15 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
63
64
  });
64
65
  if (clips.length === 0) return null;
65
66
  const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
66
- return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile, projectId };
67
+ const copiedInComposition =
68
+ typeof obj.copiedInComposition === "string" ? obj.copiedInComposition : undefined;
69
+ return {
70
+ kind: "timeline-clip",
71
+ clips,
72
+ sourceFile: obj.sourceFile,
73
+ projectId,
74
+ copiedInComposition,
75
+ };
67
76
  }
68
77
  if (obj.kind === "dom-element") {
69
78
  if (typeof obj.html !== "string") return null;
@@ -195,6 +195,7 @@ export interface SoftReloadOptions {
195
195
  currentTimeOverride?: number;
196
196
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
197
197
  authoredHtml?: string;
198
+ reparse?: Element[];
198
199
  }
199
200
 
200
201
  /**
@@ -251,7 +252,7 @@ export function applySoftReload(
251
252
  scriptText: string,
252
253
  options: SoftReloadOptions = {},
253
254
  ): SoftReloadResult {
254
- const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
255
+ const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
255
256
  if (!iframe || !scriptText) return "cannot-soft-reload";
256
257
 
257
258
  const win = iframe.contentWindow as IframeWindow | null;
@@ -329,7 +330,7 @@ export function applySoftReload(
329
330
  // fallow-ignore-next-line complexity
330
331
  const doReload = () => {
331
332
  const timelines = win.__timelines;
332
- const allTargets: Element[] = [];
333
+ const allTargets: Element[] = [...reparse];
333
334
 
334
335
  // Kill ONLY the target composition's timeline(s) — leaving every other
335
336
  // composition's timeline (and its children on the global timeline) intact.