@hyperframes/studio 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BTo132Bc.js → hyperframes-player-70BfjZRz.js} +1 -1
- package/dist/assets/{index-CEkoC1xI.js → index-DEnjB744.js} +1 -1
- package/dist/assets/{index-BsU5eQo4.js → index-KH4Y5JCd.js} +202 -202
- package/dist/assets/{index-A4oM8HYD.js → index-RQ7S6nvA.js} +1 -1
- package/dist/{chunk-AZYHQC6V.js → chunk-ZALQ3CW7.js} +2 -2
- package/dist/chunk-ZALQ3CW7.js.map +1 -0
- package/dist/{domEditingLayers-7AMZ7GFI.js → domEditingLayers-XRVXRTNX.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +228 -157
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -11
- package/src/components/editor/LayersPanel.tsx +10 -5
- package/src/components/editor/SnapToolbar.test.tsx +1 -1
- package/src/components/editor/domEditingLayers.test.ts +24 -0
- package/src/components/editor/domEditingLayers.ts +5 -1
- package/src/components/editor/marqueeCommit.ts +36 -23
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +6 -0
- package/src/components/editor/offCanvasIndicatorRefresh.throttle.test.ts +18 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +22 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +14 -9
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +18 -7
- package/src/components/editor/propertyPanelMediaSection.tsx +15 -7
- package/src/components/nle/NLEPreview.tsx +0 -22
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +93 -0
- package/src/hooks/useAppHotkeys.test.ts +85 -4
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +8 -6
- package/src/hooks/useAppHotkeys.ts +50 -52
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +9 -6
- package/src/hooks/useDomEditCommits.ts +16 -14
- package/src/hooks/useDomEditPreviewSync.ts +5 -5
- package/src/hooks/useDomEditSession.membersForDelete.test.ts +23 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +38 -4
- package/src/hooks/useDomEditWiring.ts +3 -3
- package/src/hooks/useDomSelection.ts +13 -10
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +49 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +100 -0
- package/src/hooks/useElementLifecycleOps.ts +74 -15
- package/src/hooks/useStudioUrlState.hydration.test.ts +39 -0
- package/src/hooks/useStudioUrlState.ts +9 -3
- package/src/hooks/useTimelineEditing.ts +55 -28
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +32 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +31 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +31 -2
- package/src/player/components/automationClipboard.test.ts +10 -7
- package/src/player/components/automationLaneGeometry.test.ts +10 -4
- package/dist/chunk-AZYHQC6V.js.map +0 -1
- /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
|
+
"version": "0.8.4",
|
|
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.
|
|
51
|
-
"@hyperframes/player": "0.8.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/sdk": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
50
|
+
"@hyperframes/core": "0.8.4",
|
|
51
|
+
"@hyperframes/player": "0.8.4",
|
|
52
|
+
"@hyperframes/parsers": "0.8.4",
|
|
53
|
+
"@hyperframes/sdk": "0.8.4",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.4"
|
|
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.
|
|
71
|
+
"@hyperframes/producer": "0.8.4"
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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={
|
|
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={
|
|
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(
|
|
141
|
-
|
|
142
|
-
isMasterView,
|
|
143
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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("
|
|
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
|
|
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: "
|
|
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:
|
|
180
|
-
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX:
|
|
182
|
+
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
|
|
183
|
+
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
|
|
181
184
|
});
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
|
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={
|
|
219
|
-
min={
|
|
220
|
-
max={
|
|
221
|
-
tier={
|
|
222
|
-
displayValue={
|
|
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
|
-
|
|
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
|
|
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={
|
|
254
|
-
min={
|
|
255
|
-
max={
|
|
261
|
+
value={volumeFaderPosition}
|
|
262
|
+
min={AUDIO_GAIN_FADER_MIN}
|
|
263
|
+
max={AUDIO_GAIN_FADER_MAX}
|
|
256
264
|
step={1}
|
|
257
|
-
displayValue={
|
|
258
|
-
formatDisplayValue={(next) =>
|
|
265
|
+
displayValue={audioGainToText(volume)}
|
|
266
|
+
formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))}
|
|
259
267
|
onCommit={(next) => {
|
|
260
|
-
void onSetAttribute("volume",
|
|
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;
|