Skip to content

Commit 438ea9d

Browse files
perf(studio): manual edits stay under 8 ms per frame (#4898)
* ci(studio): measure any edit cases on quiet runners, five runs in parallel * perf(studio): an undo or save stops re-rendering every panel * test(studio): the edit bench types one key per frame, as a person does * fix(studio): the edit bench measures frame work when a task outside the drag has no thread time * refactor(studio): hooks hand back stable handlers themselves, keeping App.tsx under 600 lines * test(studio): the edit bench says why a case's frame work is unknown * fix(studio): the edit bench counts a task without thread time at its wall time * perf(studio): draw toasts on an opaque surface without backdrop blur * fix(studio): keep stable handlers bound to the project they were made in * fix(studio): every stable handler names the project it belongs to * style(studio): format the stable handler call sites * refactor(studio): name the timeline editing handler objects before wrapping them
1 parent 00b61b8 commit 438ea9d

26 files changed

Lines changed: 548 additions & 180 deletions

‎.github/workflows/studio-drag-frames.yml‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,29 @@ on:
1414
ref:
1515
description: Commit, branch or tag to measure
1616
default: main
17+
filter:
18+
description: Case ids to run (a regular expression)
19+
default: "^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$"
20+
grid:
21+
description: The grid the ids come from (pr or full)
22+
default: pr
1723
pull_request:
1824
paths:
1925
- ".github/workflows/studio-drag-frames.yml"
2026

2127
jobs:
2228
drag-frames:
23-
name: "Studio: drag frames"
29+
name: "Studio: drag frames (run ${{ matrix.run }})"
2430
runs-on: ubuntu-latest
2531
timeout-minutes: 60
32+
strategy:
33+
fail-fast: false
34+
matrix:
35+
run: [1, 2, 3, 4, 5]
2636
env:
2737
BENCH_REF: ${{ inputs.ref || 'main' }}
38+
BENCH_FILTER: ${{ inputs.filter || '^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$' }}
39+
BENCH_GRID: ${{ inputs.grid || 'pr' }}
2840
steps:
2941
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
3042
with:
@@ -40,17 +52,14 @@ jobs:
4052
- run: bash scripts/ci/install-workspace-dependencies.sh
4153
- run: bun run build
4254
- run: node packages/cli/dist/cli.js browser ensure
43-
- name: Run the drag cases five times
55+
- name: Run the drag cases
4456
working-directory: packages/studio
4557
run: |
4658
set -euo pipefail
4759
echo "Measuring $(git rev-parse HEAD)"
48-
for n in 1 2 3 4 5; do
49-
bun tests/e2e/edit-accuracy/run.mjs --grid pr \
50-
--filter '^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$' \
51-
--jobs 1 --port 5961 --cli "$GITHUB_WORKSPACE/packages/cli/dist/cli.js" \
52-
--out "$RUNNER_TEMP/drag-frames/run-$n"
53-
done
60+
bun tests/e2e/edit-accuracy/run.mjs --grid "$BENCH_GRID" --filter "$BENCH_FILTER" \
61+
--jobs 1 --port 5961 --cli "$GITHUB_WORKSPACE/packages/cli/dist/cli.js" \
62+
--out "$RUNNER_TEMP/drag-frames/run-${{ matrix.run }}"
5463
- name: Require a quiet box (the blank page dropped no frame)
5564
run: |
5665
# shellcheck disable=SC2016 # the backticks are JavaScript template strings
@@ -70,7 +79,7 @@ jobs:
7079
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
7180
if: always()
7281
with:
73-
name: studio-drag-frames
82+
name: studio-drag-frames-${{ matrix.run }}
7483
path: ${{ runner.temp }}/drag-frames
7584
if-no-files-found: error
7685
retention-days: 30

‎packages/studio/src/App.tsx‎

Lines changed: 82 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
22
import { buildProjectApiPath } from "./utils/projectRouting";
3-
import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
3+
import { useState, useCallback, useRef, useMemo } from "react";
4+
import { useStableHandlers } from "./hooks/useStableHandlers";
5+
import { useHistoryFlags, useToolbarSession } from "./hooks/useShellSlices";
46
import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
57
import { useRenderQueue } from "./components/renders/useRenderQueue";
68
import { usePlayerStore } from "./player";
@@ -46,10 +48,9 @@ import {
4648
import type { DomEditSelection } from "./components/editor/domEditing";
4749
import { StudioHeader } from "./components/StudioHeader";
4850
import { useGestureCommit } from "./hooks/useGestureCommit";
49-
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
50-
import { StudioLeftPanels } from "./components/StudioLeftPanels";
51+
import { useGestureTrailOverlay } from "./hooks/useGestureTrailOverlay";
5152
import { EditorShell } from "./components/EditorShell";
52-
import { StudioRightPanels } from "./components/StudioRightPanels";
53+
import { useStudioSidePanels } from "./hooks/useStudioSidePanels";
5354
import { TimelineToolbar } from "./components/TimelineToolbar";
5455
import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
5556
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
@@ -99,17 +100,18 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
99100
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
100101
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
101102
});
102-
const editHistory = usePersistentEditHistory({ projectId });
103+
const editHistory = useStableHandlers(usePersistentEditHistory({ projectId }), projectId);
103104
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
104105
const pendingTimelineEditPathRef = useRef(new Set<string>());
105106
const isGestureRecordingRef = useRef(false);
106107
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
107-
const fileManager = useFileManager({
108+
const fileManagerResult = useFileManager({
108109
projectId,
109110
showToast,
110111
recordEdit: editHistory.recordEdit,
111112
setRefreshKey,
112113
});
114+
const fileManager = useStableHandlers(fileManagerResult, projectId);
113115
const masterCompPath = useMemo(
114116
() => resolveMasterCompositionPath(fileManager.compositions),
115117
[fileManager.compositions],
@@ -134,8 +136,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
134136
);
135137
const activeCompPathRef = useRef(activeCompPath);
136138
activeCompPathRef.current = activeCompPath;
137-
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
138-
const previewPersistence = usePreviewPersistence({
139+
const renderQueue = useStableHandlers(useRenderQueue(projectId, activeCompPathRef), projectId);
140+
const previewPersistenceResult = usePreviewPersistence({
139141
showToast,
140142
readOptionalProjectFile: fileManager.readOptionalProjectFile,
141143
writeProjectFile: fileManager.writeProjectFile,
@@ -144,6 +146,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
144146
activeCompPathRef,
145147
reloadPreview: () => setRefreshKey((k) => k + 1),
146148
});
149+
const previewPersistence = useStableHandlers(previewPersistenceResult, projectId);
147150
const externalFileChanges = useStudioExternalFileChanges({
148151
projectId,
149152
activeCompPath,
@@ -184,15 +187,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
184187
} = useTimelineAddAtPlayhead(
185188
timelineEditing.handleTimelineAssetDrop,
186189
timelineEditing.handleTimelineCompositionDrop,
190+
projectId,
187191
);
188-
const {
189-
activeBlockParams,
190-
setActiveBlockParams,
191-
handleAddBlock,
192-
handleTimelineBlockDrop,
193-
handleAddMediaOverlay,
194-
handlePreviewBlockDrop,
195-
} = useBlockHandlers({
192+
const blockHandlersResult = useBlockHandlers({
196193
projectId,
197194
blockCtxDeps: {
198195
activeCompPath,
@@ -209,6 +206,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
209206
setRightCollapsed: panelLayout.setRightCollapsed,
210207
setRightPanelTab: panelLayout.setRightPanelTab,
211208
});
209+
const {
210+
activeBlockParams,
211+
setActiveBlockParams,
212+
handleAddBlock,
213+
handleTimelineBlockDrop,
214+
handleAddMediaOverlay,
215+
handlePreviewBlockDrop,
216+
} = useStableHandlers(blockHandlersResult, projectId);
212217
const dismissBlockParams = useCallback(() => setActiveBlockParams(null), [setActiveBlockParams]);
213218
const setRightPanelTab = useDismissingTabSetter(panelLayout.setRightPanelTab, dismissBlockParams);
214219
const layout = useMemo(
@@ -237,6 +242,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
237242
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
238243
});
239244
const appHotkeys = useAppHotkeys({
245+
projectId,
240246
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
241247
handleLinkEdit: timelineEditing.handleLinkEdit,
242248
handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly,
@@ -269,7 +275,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
269275
onToggleRecording: () => handleToggleRecordingRef.current(),
270276
readOnlyPreview,
271277
});
272-
const domEditSession = useDomEditSession({
278+
const domEditSessionResult = useDomEditSession({
273279
projectId,
274280
activeCompPath,
275281
compIdToSrc,
@@ -309,6 +315,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
309315
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
310316
readOnlyPreview,
311317
});
318+
const domEditSession = useStableHandlers(domEditSessionResult, projectId);
312319
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
313320
const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
314321
handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
@@ -342,13 +349,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
342349
effectiveTimelineDuration,
343350
});
344351
const compositionDimensions = useCompositionDimensions(previewIframeRef);
345-
const lint = useLintModal(projectId, refreshKey);
346-
const frameCapture = useFrameCapture({
352+
const lint = useStableHandlers(useLintModal(projectId, refreshKey), projectId);
353+
const frameCaptureResult = useFrameCapture({
347354
projectId,
348355
activeCompPath,
349356
showToast,
350357
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
351358
});
359+
const frameCapture = useStableHandlers(frameCaptureResult, projectId);
352360
const {
353361
consoleErrors,
354362
setConsoleErrors,
@@ -359,21 +367,19 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
359367
const domEditSessionRef = useRef(domEditSession);
360368
domEditSessionRef.current = domEditSession;
361369
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
370+
projectId,
362371
domEditSessionRef,
363372
previewIframeRef,
364373
showToast,
365374
isGestureRecordingRef,
366375
readOnlyPreview,
367376
});
368377
handleToggleRecordingRef.current = handleToggleRecording;
369-
const canvasRectRef = useRef<DOMRect | null>(null);
370-
useLayoutEffect(() => {
371-
if (gestureState !== "recording" || !previewIframe) {
372-
canvasRectRef.current = null;
373-
return;
374-
}
375-
canvasRectRef.current = previewIframe.getBoundingClientRect();
376-
}, [gestureState, previewIframe]);
378+
const gestureOverlay = useGestureTrailOverlay(
379+
{ gestureState, gestureRecording },
380+
previewIframe,
381+
compositionDimensions,
382+
);
377383
const handlePreviewIframeRef = useCallback(
378384
(iframe: HTMLIFrameElement | null) => {
379385
previewIframeRef.current = iframe;
@@ -415,6 +421,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
415421
setRightPanelTab,
416422
initialState: initialUrlStateRef.current,
417423
});
424+
const historyFlags = useHistoryFlags(editHistory);
425+
const toolbarSession = useToolbarSession(domEditSession);
418426
const studioCtxValue = buildStudioContextValue({
419427
projectId: projectId!,
420428
activeCompPath,
@@ -427,7 +435,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
427435
setRefreshKey,
428436
timelineElements,
429437
isPlaying,
430-
editHistory,
438+
editHistory: historyFlags,
431439
handleUndo: appHotkeys.handleUndo,
432440
handleRedo: appHotkeys.handleRedo,
433441
renderQueue,
@@ -441,11 +449,51 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
441449
const timelineToolbar = useMemo(
442450
() => (
443451
<TimelineToolbar
444-
domEditSession={domEditSession}
452+
domEditSession={toolbarSession}
445453
onSplitElement={timelineEditing.handleTimelineElementSplit}
446454
/>
447455
),
448-
[domEditSession, timelineEditing.handleTimelineElementSplit],
456+
[toolbarSession, timelineEditing.handleTimelineElementSplit],
457+
);
458+
const handleExport = useCallback(() => {
459+
void previewPersistence
460+
.waitForPendingDomEditSaves()
461+
.then(() => renderQueue.startRender(undefined));
462+
}, [previewPersistence, renderQueue]);
463+
const clearConsoleErrors = useCallback(() => setConsoleErrors(null), [setConsoleErrors]);
464+
const panels = useStudioSidePanels(
465+
{
466+
onSelectComposition: handleSelectComposition,
467+
onAddBlock: handleAddBlock,
468+
onPreviewBlock: setBlockPreview,
469+
onLint: lint.handleLint,
470+
linting: lint.linting,
471+
lintFindingCount: lint.lintFindingCount,
472+
lintFindingsByFile: lint.findingsByFile,
473+
lintHasError: lint.hasLintError,
474+
onAddAssetToTimeline: handleAddAssetAtPlayhead,
475+
onAddCompositionToTimeline: handleAddCompositionAtPlayhead,
476+
},
477+
{
478+
activeBlockParams,
479+
onDismissBlockParams: dismissBlockParams,
480+
onCloseBlockParams: () => {
481+
setActiveBlockParams(null);
482+
panelLayout.setRightPanelTab("design");
483+
},
484+
recordingState: gestureState,
485+
recordingDuration: gestureRecording.recordingDuration,
486+
onToggleRecording: handleToggleRecording,
487+
sdkSession: sdkHandle.session,
488+
publishSdkSession: sdkHandle.publish,
489+
forceReloadSdkSession: sdkHandle.forceReload,
490+
reloadPreview,
491+
recordEdit: editHistory.recordEdit,
492+
onToggleElementHidden: timelineEditing.handleToggleElementHidden,
493+
onAutoGroupCarveSources: timelineEditing.handleAutoGroupCarveSources,
494+
onAddMediaOverlay: handleAddMediaOverlay,
495+
},
496+
projectId,
449497
);
450498
if (resolving || waitingForServer || !projectId)
451499
return <StudioSplash waiting={waitingForServer} />;
@@ -468,12 +516,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
468516
capturing={frameCapture.capturing}
469517
inspectorButtonActive={inspectorButtonActive}
470518
inspectorPanelActive={inspectorPanelActive}
471-
onExport={() => {
472-
void (async () => {
473-
await previewPersistence.waitForPendingDomEditSaves();
474-
await renderQueue.startRender(undefined);
475-
})();
476-
}}
519+
onExport={handleExport}
477520
/>
478521
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
479522
<SaveQueuePausedBanner
@@ -491,41 +534,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
491534
<EditorShell
492535
readOnlyPreview={readOnlyPreview}
493536
readOnlyPreviewReason={readOnlyPreviewReason}
494-
panels={
495-
<>
496-
<StudioLeftPanels
497-
onSelectComposition={handleSelectComposition}
498-
onAddBlock={handleAddBlock}
499-
onPreviewBlock={setBlockPreview}
500-
onLint={lint.handleLint}
501-
linting={lint.linting}
502-
lintFindingCount={lint.lintFindingCount}
503-
lintFindingsByFile={lint.findingsByFile}
504-
lintHasError={lint.hasLintError}
505-
onAddAssetToTimeline={handleAddAssetAtPlayhead}
506-
onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
507-
/>
508-
<StudioRightPanels
509-
activeBlockParams={activeBlockParams}
510-
onDismissBlockParams={dismissBlockParams}
511-
onCloseBlockParams={() => {
512-
setActiveBlockParams(null);
513-
panelLayout.setRightPanelTab("design");
514-
}}
515-
recordingState={gestureState}
516-
recordingDuration={gestureRecording.recordingDuration}
517-
onToggleRecording={handleToggleRecording}
518-
sdkSession={sdkHandle.session}
519-
publishSdkSession={sdkHandle.publish}
520-
forceReloadSdkSession={sdkHandle.forceReload}
521-
reloadPreview={reloadPreview}
522-
recordEdit={editHistory.recordEdit}
523-
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
524-
onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
525-
onAddMediaOverlay={handleAddMediaOverlay}
526-
/>
527-
</>
528-
}
537+
panels={panels}
529538
timelineToolbar={timelineToolbar}
530539
renderClipContent={renderClipContent}
531540
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
@@ -561,26 +570,15 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
561570
recordingState={gestureState}
562571
onToggleRecording={handleToggleRecording}
563572
blockPreview={blockPreview}
564-
gestureOverlay={
565-
gestureState === "recording" && previewIframe ? (
566-
<GestureTrailOverlay
567-
samples={gestureRecording.samplesRef.current}
568-
sampleCount={gestureRecording.samplesRef.current.length}
569-
trail={gestureRecording.trailRef.current}
570-
canvasRect={canvasRectRef.current!}
571-
compositionSize={compositionDimensions ?? undefined}
572-
mode="recording"
573-
/>
574-
) : undefined
575-
}
573+
gestureOverlay={gestureOverlay}
576574
/>
577575
<StudioOverlays
578576
projectId={projectId}
579577
projectDir={fileManager.projectDir}
580578
lintModal={lint.lintModal}
581579
closeLintModal={lint.closeLintModal}
582580
consoleErrors={consoleErrors}
583-
clearConsoleErrors={() => setConsoleErrors(null)}
581+
clearConsoleErrors={clearConsoleErrors}
584582
domEditSession={domEditSession}
585583
activeCompPath={activeCompPath}
586584
toasts={toasts}

0 commit comments

Comments
 (0)