@hyperframes/studio 0.8.3 → 0.8.5

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 (54) hide show
  1. package/dist/assets/{hyperframes-player-C-CeZlk1.js → hyperframes-player-DcM3fVcp.js} +1 -1
  2. package/dist/assets/{index-B4X3myFN.js → index-Cx_UrhoW.js} +202 -202
  3. package/dist/assets/{index-BSDQyOWB.js → index-Del6XWZj.js} +1 -1
  4. package/dist/assets/{index-BU-aDh5r.js → index-DgJrvYGf.js} +1 -1
  5. package/dist/{chunk-AZYHQC6V.js → chunk-ZALQ3CW7.js} +2 -2
  6. package/dist/chunk-ZALQ3CW7.js.map +1 -0
  7. package/dist/{domEditingLayers-7AMZ7GFI.js → domEditingLayers-XRVXRTNX.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +240 -163
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +9 -11
  13. package/src/components/editor/LayersPanel.tsx +10 -5
  14. package/src/components/editor/SnapToolbar.test.tsx +1 -1
  15. package/src/components/editor/domEditingLayers.test.ts +24 -0
  16. package/src/components/editor/domEditingLayers.ts +5 -1
  17. package/src/components/editor/marqueeCommit.ts +36 -23
  18. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +6 -0
  19. package/src/components/editor/offCanvasIndicatorRefresh.throttle.test.ts +18 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.ts +22 -1
  21. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +14 -9
  22. package/src/components/editor/propertyPanelFlatMediaSection.tsx +18 -7
  23. package/src/components/editor/propertyPanelMediaSection.tsx +15 -7
  24. package/src/components/nle/NLEPreview.tsx +0 -22
  25. package/src/components/storyboard/FramePoster.test.tsx +61 -0
  26. package/src/components/storyboard/FramePoster.tsx +14 -5
  27. package/src/components/storyboard/StoryboardFrameFocus.tsx +1 -1
  28. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +93 -0
  29. package/src/hooks/useAppHotkeys.test.ts +85 -4
  30. package/src/hooks/useAppHotkeys.textEditing.test.tsx +8 -6
  31. package/src/hooks/useAppHotkeys.ts +50 -52
  32. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +9 -6
  33. package/src/hooks/useDomEditCommits.ts +16 -14
  34. package/src/hooks/useDomEditPreviewSync.ts +5 -5
  35. package/src/hooks/useDomEditSession.membersForDelete.test.ts +23 -0
  36. package/src/hooks/useDomEditSession.test.tsx +1 -1
  37. package/src/hooks/useDomEditSession.ts +38 -4
  38. package/src/hooks/useDomEditWiring.ts +3 -3
  39. package/src/hooks/useDomSelection.ts +13 -10
  40. package/src/hooks/useDomSelectionSelectionGuards.test.ts +49 -0
  41. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +100 -0
  42. package/src/hooks/useElementLifecycleOps.ts +74 -15
  43. package/src/hooks/useStudioUrlState.hydration.test.ts +39 -0
  44. package/src/hooks/useStudioUrlState.ts +9 -3
  45. package/src/hooks/useTimelineEditing.ts +55 -28
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +32 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +31 -4
  48. package/src/player/components/CompositionThumbnail.test.ts +15 -0
  49. package/src/player/components/CompositionThumbnail.tsx +9 -0
  50. package/src/player/components/TimelineAutomationLane.test.tsx +31 -2
  51. package/src/player/components/automationClipboard.test.ts +10 -7
  52. package/src/player/components/automationLaneGeometry.test.ts +10 -4
  53. package/dist/chunk-AZYHQC6V.js.map +0 -1
  54. /package/dist/{domEditingLayers-7AMZ7GFI.js.map → domEditingLayers-XRVXRTNX.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.3",
51
- "@hyperframes/parsers": "0.8.3",
52
- "@hyperframes/player": "0.8.3",
53
- "@hyperframes/sdk": "0.8.3",
54
- "@hyperframes/studio-server": "0.8.3"
50
+ "@hyperframes/core": "0.8.5",
51
+ "@hyperframes/parsers": "0.8.5",
52
+ "@hyperframes/player": "0.8.5",
53
+ "@hyperframes/sdk": "0.8.5",
54
+ "@hyperframes/studio-server": "0.8.5"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.3"
71
+ "@hyperframes/producer": "0.8.5"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
package/src/App.tsx CHANGED
@@ -218,10 +218,9 @@ export function StudioApp() {
218
218
  });
219
219
  const clearDomSelectionRef = useRef<() => void>(() => {});
220
220
  const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
221
- const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
222
- async () => {},
223
- );
224
- const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
221
+ type DomEditDelete = (s: DomEditSelection, o?: { expandGroup?: boolean }) => Promise<void>;
222
+ const handleDomEditElementDeleteRef = useRef<DomEditDelete>(async () => {});
223
+ const domEditDeleteBridge: DomEditDelete = (s, o) => handleDomEditElementDeleteRef.current(s, o);
225
224
  const resetKeyframesRef = useRef<() => boolean>(() => false);
226
225
  const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
227
226
  const { handleCopy, handlePaste, handleCut } = useClipboard({
@@ -238,7 +237,7 @@ export function StudioApp() {
238
237
  previewIframeRef,
239
238
  });
240
239
  const appHotkeys = useAppHotkeys({
241
- handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
240
+ handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
242
241
  handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
243
242
  handleDomEditElementDelete: domEditDeleteBridge,
244
243
  domEditSelectionRef: domEditSelectionBridgeRef,
@@ -282,6 +281,7 @@ export function StudioApp() {
282
281
  setRightCollapsed: panelLayout.setRightCollapsed,
283
282
  setRightPanelTab: panelLayout.setRightPanelTab,
284
283
  showToast,
284
+ isRecordingRef: isGestureRecordingRef,
285
285
  refreshPreviewDocumentVersion,
286
286
  queueDomEditSave: previewPersistence.queueDomEditSave,
287
287
  readProjectFile: fileManager.readProjectFile,
@@ -298,7 +298,7 @@ export function StudioApp() {
298
298
  previewDocumentVersion,
299
299
  rightPanelTab: panelLayout.rightPanelTab,
300
300
  applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
301
- syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
301
+ syncPreviewHotkeys: appHotkeys.syncPreviewHotkeys,
302
302
  reloadPreview,
303
303
  setRefreshKey,
304
304
  openSourceForSelection: fileManager.openSourceForSelection,
@@ -365,7 +365,6 @@ export function StudioApp() {
365
365
  isGestureRecordingRef,
366
366
  });
367
367
  handleToggleRecordingRef.current = handleToggleRecording;
368
- const recordingToggle = handleToggleRecording;
369
368
  const canvasRectRef = useRef<DOMRect | null>(null);
370
369
  useLayoutEffect(() => {
371
370
  if (gestureState !== "recording" || !previewIframe) {
@@ -378,8 +377,7 @@ export function StudioApp() {
378
377
  (iframe: HTMLIFrameElement | null) => {
379
378
  previewIframeRef.current = iframe;
380
379
  setPreviewIframe(iframe);
381
- appHotkeys.syncPreviewTimelineHotkey(iframe);
382
- appHotkeys.syncPreviewHistoryHotkey(iframe);
380
+ appHotkeys.syncPreviewHotkeys(iframe);
383
381
  resetConsoleErrors();
384
382
  refreshPreviewDocumentVersion();
385
383
  },
@@ -526,7 +524,7 @@ export function StudioApp() {
526
524
  }}
527
525
  recordingState={gestureState}
528
526
  recordingDuration={gestureRecording.recordingDuration}
529
- onToggleRecording={recordingToggle}
527
+ onToggleRecording={handleToggleRecording}
530
528
  sdkSession={sdkHandle.session}
531
529
  publishSdkSession={sdkHandle.publish}
532
530
  forceReloadSdkSession={sdkHandle.forceReload}
@@ -561,7 +559,7 @@ export function StudioApp() {
561
559
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
562
560
  isGestureRecording={gestureState === "recording"}
563
561
  recordingState={gestureState}
564
- onToggleRecording={recordingToggle}
562
+ onToggleRecording={handleToggleRecording}
565
563
  blockPreview={blockPreview}
566
564
  gestureOverlay={
567
565
  gestureState === "recording" && previewIframe ? (
@@ -23,6 +23,9 @@ import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMir
23
23
  import { runZLaneGesture } from "../nle/zLaneGesture";
24
24
  import { useLayerRevealOverride } from "./useLayerRevealOverride";
25
25
 
26
+ // Rows this panel renders before it stops. A display budget, not a document limit.
27
+ const LAYERS_PANEL_MAX_ROWS = 80;
28
+
26
29
  const TAG_ICONS: Record<string, string> = {
27
30
  video: "Vi",
28
31
  audio: "Au",
@@ -137,11 +140,13 @@ export const LayersPanel = memo(function LayersPanel() {
137
140
  // A preview reload detaches the drilled-into wrapper; exit drill-in if so.
138
141
  if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null);
139
142
 
140
- const items = collectDomEditLayerItems(root, {
141
- activeCompositionPath: activeCompPath,
142
- isMasterView,
143
- activeGroupElement,
144
- });
143
+ const items = collectDomEditLayerItems(
144
+ root,
145
+ { activeCompositionPath: activeCompPath, isMasterView, activeGroupElement },
146
+ // How many rows this panel is willing to render, nothing more. Hit-testing
147
+ // callers deliberately take the whole document instead.
148
+ LAYERS_PANEL_MAX_ROWS,
149
+ );
145
150
  setLayers(sortLayersByZIndex(items));
146
151
  }, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]);
147
152
 
@@ -34,7 +34,7 @@ function AppHotkeyHarness() {
34
34
  const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
35
 
36
36
  useAppHotkeys({
37
- handleTimelineElementDelete: vi.fn(),
37
+ handleTimelineElementsDelete: vi.fn(async () => {}),
38
38
  handleTimelineElementSplit: vi.fn(),
39
39
  handleDomEditElementDelete: vi.fn(),
40
40
  domEditSelectionRef,
@@ -224,3 +224,27 @@ describe("buildTextFieldChildLocator", () => {
224
224
  expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
225
225
  });
226
226
  });
227
+
228
+ describe("collectDomEditLayerItems item budget", () => {
229
+ function documentWith(count: number): HTMLElement {
230
+ const root = document.createElement("div");
231
+ root.setAttribute("data-composition-id", "index.html");
232
+ for (let i = 0; i < count; i++) {
233
+ const child = document.createElement("div");
234
+ child.id = `el-${i}`;
235
+ root.append(child);
236
+ }
237
+ return root;
238
+ }
239
+
240
+ it("returns the whole document by default", () => {
241
+ // A default cap here silently truncated the marquee's candidate list: a drag
242
+ // over the whole canvas only ever saw the first 80 elements, so everything
243
+ // past them was unselectable and survived a Delete.
244
+ expect(collectDomEditLayerItems(documentWith(200), opts)).toHaveLength(200);
245
+ });
246
+
247
+ it("truncates only when a caller asks for a rendering budget", () => {
248
+ expect(collectDomEditLayerItems(documentWith(200), opts, 80)).toHaveLength(80);
249
+ });
250
+ });
@@ -458,10 +458,14 @@ export function countDomEditChildLayers(
458
458
  return count;
459
459
  }
460
460
 
461
+ // Every editable element under `root`, in document order. `maxItems` is a
462
+ // caller's rendering budget, not a property of the document: hit-testing
463
+ // callers (marquee, off-canvas indicators) must see all of it, and sharing a
464
+ // truncated list left everything past the cut unselectable however far you drag.
461
465
  export function collectDomEditLayerItems(
462
466
  root: HTMLElement | null | undefined,
463
467
  options: DomEditContextOptions,
464
- maxItems = 80,
468
+ maxItems = Number.POSITIVE_INFINITY,
465
469
  ): DomEditLayerItem[] {
466
470
  if (!root) return [];
467
471
 
@@ -24,16 +24,18 @@ interface MarqueeHit {
24
24
  }
25
25
 
26
26
  /**
27
- * Synchronous core of the marquee: the elements whose overlay-space rect
28
- * intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
29
- * single-selection / group-selection boxes, so what the marquee highlights
30
- * and selects is exactly the box the user sees when they click an element.
31
- * Shared by the live candidate highlight (per pointer-move) and the mouse-up
32
- * commit. No async source probe that only happens once, on commit.
27
+ * Every element the marquee could hit, with the overlay-space rect it would be
28
+ * tested against. Uses the SAME `toOverlayRect` basis as the single-selection /
29
+ * group-selection boxes, so what the marquee highlights and selects is exactly
30
+ * the box the user sees when they click an element.
31
+ *
32
+ * Measured once per drag rather than per pointer-move: this reads layout for
33
+ * every element in the document, and a captured page has enough of them that
34
+ * doing it 60 times a second stalls the tab. The iframe DOM does not mutate
35
+ * mid-drag, so the rects it returns stay true for the whole gesture.
33
36
  */
34
37
  // fallow-ignore-next-line complexity
35
- function collectMarqueeHits(
36
- rect: Rect,
38
+ function collectMarqueeCandidates(
37
39
  iframe: HTMLIFrameElement,
38
40
  overlayEl: HTMLDivElement,
39
41
  activeCompositionPath: string,
@@ -53,35 +55,39 @@ function collectMarqueeHits(
53
55
  height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
54
56
  };
55
57
 
56
- const hits: MarqueeHit[] = [];
58
+ const candidates: MarqueeHit[] = [];
57
59
  for (const item of items) {
58
60
  const el = item.element;
59
61
  if (!isElementComputedVisible(el)) continue;
60
62
  if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
61
63
  const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
62
64
  if (!overlayRect) continue;
63
- const r: Rect = {
64
- left: overlayRect.left,
65
- top: overlayRect.top,
66
- width: overlayRect.width,
67
- height: overlayRect.height,
68
- };
69
- if (!rectsOverlap(rect, r)) continue;
70
- hits.push({ element: el, rect: r });
65
+ candidates.push({
66
+ element: el,
67
+ rect: {
68
+ left: overlayRect.left,
69
+ top: overlayRect.top,
70
+ width: overlayRect.width,
71
+ height: overlayRect.height,
72
+ },
73
+ });
71
74
  }
72
75
 
73
- return hits;
76
+ return candidates;
77
+ }
78
+
79
+ function hitsWithin(rect: Rect, candidates: MarqueeHit[]): MarqueeHit[] {
80
+ return candidates.filter((candidate) => rectsOverlap(rect, candidate.rect));
74
81
  }
75
82
 
76
83
  async function runMarqueeIntersection(
77
84
  rect: Rect,
78
- iframe: HTMLIFrameElement,
79
- overlayEl: HTMLDivElement,
85
+ candidates: MarqueeHit[],
80
86
  activeCompositionPath: string,
81
87
  ): Promise<DomEditSelection[]> {
82
88
  const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
83
89
  const hits: DomEditSelection[] = [];
84
- for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
90
+ for (const { element } of hitsWithin(rect, candidates)) {
85
91
  const sel = await resolveDomEditSelection(element, {
86
92
  activeCompositionPath,
87
93
  isMasterView,
@@ -116,6 +122,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
116
122
  // iframe DOM doesn't mutate during a drag, so a sync intersection per move
117
123
  // is cheap (clean layout → no thrash).
118
124
  const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
125
+ // Measured once when the drag passes the threshold and reused until it ends.
126
+ const candidatesRef = useRef<MarqueeHit[] | null>(null);
119
127
 
120
128
  const commitMarquee = useCallback(
121
129
  async (
@@ -126,7 +134,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
126
134
  const overlay = deps.overlayRef.current;
127
135
  if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
128
136
  const acp = deps.activeCompositionPathRef.current ?? "index.html";
129
- const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
137
+ const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
138
+ const hits = await runMarqueeIntersection(rect, candidates, acp);
130
139
  deps.onMarqueeSelectRef.current(hits, additive);
131
140
  },
132
141
  [deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
@@ -145,6 +154,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
145
154
  const dy = m.currentY - m.startY;
146
155
  if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
147
156
  m.pastThreshold = true;
157
+ candidatesRef.current = null;
148
158
  }
149
159
  const rect: Rect = {
150
160
  left: Math.min(m.startX, m.currentX),
@@ -157,7 +167,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
157
167
  const overlay = deps.overlayRef.current;
158
168
  if (iframe && overlay) {
159
169
  const acp = deps.activeCompositionPathRef.current ?? "index.html";
160
- setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
170
+ candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
171
+ setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
161
172
  }
162
173
  return;
163
174
  }
@@ -191,6 +202,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
191
202
  }
192
203
  setMarqueeRect(null);
193
204
  setCandidateRects([]);
205
+ candidatesRef.current = null;
194
206
  return;
195
207
  }
196
208
  deps.gestures.onPointerUp(event);
@@ -203,6 +215,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
203
215
  marqueeRef.current = null;
204
216
  setMarqueeRect(null);
205
217
  setCandidateRects([]);
218
+ candidatesRef.current = null;
206
219
  return;
207
220
  }
208
221
  deps.gestures.clearPointerState(deps.selectionRef);
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
6
  import { DomEditOverlay } from "./DomEditOverlay";
7
+ import { RECOMPUTE_INTERVAL_MS } from "./offCanvasIndicatorRefresh";
7
8
 
8
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
10
 
@@ -47,7 +48,12 @@ function domRect(left: number, top: number, width: number, height: number): DOMR
47
48
  };
48
49
  }
49
50
 
51
+ // The refresh rebuilds at most every RECOMPUTE_INTERVAL_MS — it walks the whole
52
+ // preview and reads layout per element, which is too much to do per frame while
53
+ // animation is writing inline styles. Waiting past that window is what makes
54
+ // consecutive frames here represent consecutive rebuilds.
50
55
  async function flushAnimationFrames(): Promise<void> {
56
+ await new Promise<void>((resolve) => setTimeout(resolve, RECOMPUTE_INTERVAL_MS + 5));
51
57
  await new Promise<void>((resolve) => {
52
58
  requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
53
59
  });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RECOMPUTE_INTERVAL_MS, rebuildDue } from "./offCanvasIndicatorRefresh";
3
+
4
+ describe("rebuildDue", () => {
5
+ it("collapses mutations arriving inside one window into a single rebuild", () => {
6
+ // A rebuild walks the whole preview and reads layout per element, and what
7
+ // marks it dirty is a MutationObserver on inline style — which is how
8
+ // animation writes. Without this, playback pays that on nearly every frame.
9
+ const first = 1_000;
10
+ expect(rebuildDue(true, Number.NEGATIVE_INFINITY, first)).toBe(true);
11
+ expect(rebuildDue(true, first, first + RECOMPUTE_INTERVAL_MS - 1)).toBe(false);
12
+ expect(rebuildDue(true, first, first + RECOMPUTE_INTERVAL_MS)).toBe(true);
13
+ });
14
+
15
+ it("never rebuilds when nothing changed", () => {
16
+ expect(rebuildDue(false, Number.NEGATIVE_INFINITY, 1_000)).toBe(false);
17
+ });
18
+ });
@@ -43,11 +43,29 @@ function observeDoc(doc: Document, markDirty: () => void): MutationObserver | nu
43
43
  return observer;
44
44
  }
45
45
 
46
+ /**
47
+ * How often the indicator geometry may be rebuilt.
48
+ *
49
+ * A rebuild walks every element in the preview and reads layout for each —
50
+ * 6.5ms on an 825-element captured page, against a 16.7ms frame. What marks it
51
+ * dirty is a MutationObserver on inline style, which is exactly how animation
52
+ * writes, so playback would pay that on close to every frame. The indicators
53
+ * are a passive affordance: refreshing them a few times a second is
54
+ * indistinguishable on screen and keeps the cost off the frame budget.
55
+ */
56
+ export const RECOMPUTE_INTERVAL_MS = 100;
57
+
58
+ /** Dirty, and far enough past the last rebuild to be worth paying for another. */
59
+ export function rebuildDue(dirty: boolean, lastAt: number, now: number): boolean {
60
+ return dirty && now - lastAt >= RECOMPUTE_INTERVAL_MS;
61
+ }
62
+
46
63
  export function startOffCanvasIndicatorRefresh(
47
64
  options: OffCanvasIndicatorRefreshOptions,
48
65
  ): () => void {
49
66
  let frame = 0;
50
67
  let lastCompSig = "";
68
+ let lastRecomputeAt = Number.NEGATIVE_INFINITY;
51
69
  const markDirty = () => {
52
70
  options.dirtyRef.current = true;
53
71
  };
@@ -76,7 +94,10 @@ export function startOffCanvasIndicatorRefresh(
76
94
  if (options.dirtyRef.current) clearIndicators(options);
77
95
  return;
78
96
  }
79
- if (!options.dirtyRef.current) return;
97
+ // Staying dirty while throttled is what makes the next eligible frame rebuild.
98
+ const now = performance.now();
99
+ if (!rebuildDue(options.dirtyRef.current, lastRecomputeAt, now)) return;
100
+ lastRecomputeAt = now;
80
101
  options.dirtyRef.current = false;
81
102
  recomputeOffCanvasIndicators(
82
103
  iframe,
@@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => {
131
131
  });
132
132
 
133
133
  describe("FlatMediaSection — volume/rate/media-start", () => {
134
- it("renders volume at its stored percentage and commits a new value on drag", () => {
134
+ it("renders unity volume as neutral 0 dB at the slider midpoint", () => {
135
135
  const onSetAttribute = vi.fn();
136
- const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
136
+ const element = makeVideoElement({ dataAttributes: { volume: "1" } });
137
137
  const host = document.createElement("div");
138
138
  document.body.append(host);
139
139
  const root = createRoot(host);
@@ -149,13 +149,16 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
149
149
  />,
150
150
  );
151
151
  });
152
- expect(host.textContent).toContain("50%");
152
+ expect(host.textContent).toContain("0.0 dB");
153
+ expect(
154
+ host.querySelector('[data-flat-slider-track="true"]')?.getAttribute("aria-valuenow"),
155
+ ).toBe("0");
153
156
  act(() => root.unmount());
154
157
  });
155
158
 
156
- it("commits a new volume value on slider track pointerdown", () => {
159
+ it("commits +12 dB of boost from the upper half of the volume fader", () => {
157
160
  const onSetAttribute = vi.fn();
158
- const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
161
+ const element = makeVideoElement({ dataAttributes: { volume: "1" } });
159
162
  const host = document.createElement("div");
160
163
  document.body.append(host);
161
164
  const root = createRoot(host);
@@ -176,11 +179,13 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
176
179
  value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
177
180
  });
178
181
  act(() => {
179
- volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
180
- volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
182
+ volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
183
+ volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
181
184
  });
182
- // starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
183
- expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
185
+ // Six decimals, not two: at two the bottom of the dB fader collapses onto
186
+ // "0" (a hard mute) and every stop below unity writes a value the knob then
187
+ // jumps away from.
188
+ expect(onSetAttribute).toHaveBeenCalledWith("volume", "3.981072");
184
189
  act(() => root.unmount());
185
190
  });
186
191
 
@@ -13,6 +13,14 @@ import {
13
13
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
14
14
  import { FlatToggle } from "./propertyPanelFlatToggle";
15
15
  import { AutomationToggle } from "./propertyPanelFxControls";
16
+ import {
17
+ AUDIO_GAIN_FADER_MAX,
18
+ AUDIO_GAIN_FADER_MIN,
19
+ audioFaderPositionToGain,
20
+ formatAudioGain,
21
+ audioGainToFaderPosition,
22
+ audioGainToText,
23
+ } from "@hyperframes/core/audio-gain";
16
24
 
17
25
  // fallow-ignore-next-line complexity
18
26
  export function FlatMediaSection({
@@ -54,7 +62,7 @@ export function FlatMediaSection({
54
62
  const el = element.element;
55
63
 
56
64
  const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
57
- const volumePercent = Math.round(volume * 100);
65
+ const volumeFaderPosition = audioGainToFaderPosition(volume);
58
66
  const mediaStart =
59
67
  Number.parseFloat(
60
68
  element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
@@ -215,13 +223,16 @@ export function FlatMediaSection({
215
223
  <div className="min-w-0 flex-1">
216
224
  <FlatSlider
217
225
  label="Volume"
218
- value={volumePercent}
219
- min={0}
220
- max={100}
221
- tier={volumePercent === 100 ? "default" : "explicitCustom"}
222
- displayValue={`${volumePercent}%`}
226
+ value={volumeFaderPosition}
227
+ min={AUDIO_GAIN_FADER_MIN}
228
+ max={AUDIO_GAIN_FADER_MAX}
229
+ tier={volume === 1 ? "default" : "explicitCustom"}
230
+ displayValue={audioGainToText(volume)}
223
231
  disabled={volumeAutomated}
224
- onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
232
+ centerTick
233
+ onCommit={(next) =>
234
+ void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
235
+ }
225
236
  />
226
237
  </div>
227
238
  <AutomationToggle
@@ -13,6 +13,14 @@ import {
13
13
  } from "./propertyPanelHelpers";
14
14
  import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
15
15
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
16
+ import {
17
+ AUDIO_GAIN_FADER_MAX,
18
+ AUDIO_GAIN_FADER_MIN,
19
+ audioFaderPositionToGain,
20
+ formatAudioGain,
21
+ audioGainToFaderPosition,
22
+ audioGainToText,
23
+ } from "@hyperframes/core/audio-gain";
16
24
 
17
25
  // fallow-ignore-next-line complexity
18
26
  export function MediaSection({
@@ -47,7 +55,7 @@ export function MediaSection({
47
55
  const el = element.element;
48
56
 
49
57
  const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
50
- const volumePercent = Math.round(volume * 100);
58
+ const volumeFaderPosition = audioGainToFaderPosition(volume);
51
59
 
52
60
  const mediaStart =
53
61
  Number.parseFloat(
@@ -250,14 +258,14 @@ export function MediaSection({
250
258
  <span className={LABEL}>Volume</span>
251
259
  <SliderControl
252
260
  trackName="Volume"
253
- value={volumePercent}
254
- min={0}
255
- max={100}
261
+ value={volumeFaderPosition}
262
+ min={AUDIO_GAIN_FADER_MIN}
263
+ max={AUDIO_GAIN_FADER_MAX}
256
264
  step={1}
257
- displayValue={`${volumePercent}%`}
258
- formatDisplayValue={(next) => `${Math.round(next)}%`}
265
+ displayValue={audioGainToText(volume)}
266
+ formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))}
259
267
  onCommit={(next) => {
260
- void onSetAttribute("volume", formatNumericValue(next / 100));
268
+ void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)));
261
269
  }}
262
270
  />
263
271
  </div>
@@ -354,28 +354,6 @@ export const NLEPreview = memo(function NLEPreview({
354
354
  return () => document.removeEventListener("wheel", handleWheel, { capture: true });
355
355
  }, [applyZoom, applyPan]);
356
356
 
357
- useEffect(() => {
358
- const viewport = viewportRef.current;
359
- if (!viewport) return;
360
-
361
- const handleDblClick = (event: MouseEvent) => {
362
- if (isPreviewAtFit(zoomRef.current)) return;
363
- const rect = viewport.getBoundingClientRect();
364
- if (
365
- event.clientX < rect.left ||
366
- event.clientX > rect.right ||
367
- event.clientY < rect.top ||
368
- event.clientY > rect.bottom
369
- ) {
370
- return;
371
- }
372
- applyZoom(DEFAULT_PREVIEW_ZOOM);
373
- };
374
-
375
- document.addEventListener("dblclick", handleDblClick, { capture: true });
376
- return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
377
- }, [applyZoom]);
378
-
379
357
  useEffect(() => {
380
358
  const isInsideViewport = (clientX: number, clientY: number): DOMRect | null => {
381
359
  const viewport = viewportRef.current;
@@ -0,0 +1,61 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { FramePoster } from "./FramePoster";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const roots: Root[] = [];
11
+
12
+ afterEach(() => {
13
+ act(() => roots.splice(0).forEach((root) => root.unmount()));
14
+ document.body.replaceChildren();
15
+ });
16
+
17
+ // A fresh host per render: several cases compare two surfaces side by side.
18
+ function renderPoster(surface?: "tile" | "hero"): HTMLImageElement {
19
+ const host = document.createElement("div");
20
+ document.body.appendChild(host);
21
+ const root = createRoot(host);
22
+ roots.push(root);
23
+ act(() => {
24
+ root.render(
25
+ <FramePoster
26
+ projectId="demo"
27
+ src="frames/01-hero.html"
28
+ seconds={3}
29
+ title="hero"
30
+ surface={surface}
31
+ />,
32
+ );
33
+ });
34
+ const img = host.querySelector("img");
35
+ if (!img) throw new Error("poster did not render an <img>");
36
+ return img;
37
+ }
38
+
39
+ describe("FramePoster", () => {
40
+ // Regression: the contact sheet stretched a 240x135 capture across a wide,
41
+ // high-density card, making body copy, thin lines, and sprite details blurry.
42
+ it.each([
43
+ [undefined, "storyboard"],
44
+ ["tile", "storyboard"],
45
+ ["hero", "source"],
46
+ ] as const)("captures the %s surface at %s density", (surface, output) => {
47
+ const url = new URL(renderPoster(surface).src);
48
+
49
+ expect(url.searchParams.get("output")).toBe(output);
50
+ expect(url.pathname).toBe("/api/projects/demo/thumbnail/frames/01-hero.html");
51
+ });
52
+
53
+ it("defaults to the tile surface", () => {
54
+ expect(renderPoster().className).toBe(renderPoster("tile").className);
55
+ });
56
+
57
+ it("letterboxes only the hero, so a tile still fills its cell", () => {
58
+ expect(renderPoster("hero").className).toContain("object-contain");
59
+ expect(renderPoster("tile").className).toContain("object-cover");
60
+ });
61
+ });