mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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
This commit is contained in:
@@ -14,17 +14,29 @@ on:
|
||||
ref:
|
||||
description: Commit, branch or tag to measure
|
||||
default: main
|
||||
filter:
|
||||
description: Case ids to run (a regular expression)
|
||||
default: "^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$"
|
||||
grid:
|
||||
description: The grid the ids come from (pr or full)
|
||||
default: pr
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/studio-drag-frames.yml"
|
||||
|
||||
jobs:
|
||||
drag-frames:
|
||||
name: "Studio: drag frames"
|
||||
name: "Studio: drag frames (run ${{ matrix.run }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
run: [1, 2, 3, 4, 5]
|
||||
env:
|
||||
BENCH_REF: ${{ inputs.ref || 'main' }}
|
||||
BENCH_FILTER: ${{ inputs.filter || '^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$' }}
|
||||
BENCH_GRID: ${{ inputs.grid || 'pr' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -40,17 +52,14 @@ jobs:
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- run: bun run build
|
||||
- run: node packages/cli/dist/cli.js browser ensure
|
||||
- name: Run the drag cases five times
|
||||
- name: Run the drag cases
|
||||
working-directory: packages/studio
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Measuring $(git rev-parse HEAD)"
|
||||
for n in 1 2 3 4 5; do
|
||||
bun tests/e2e/edit-accuracy/run.mjs --grid pr \
|
||||
--filter '^(move|resize|rotate|crop)-(none|tween)-px-r0-root-z100$' \
|
||||
--jobs 1 --port 5961 --cli "$GITHUB_WORKSPACE/packages/cli/dist/cli.js" \
|
||||
--out "$RUNNER_TEMP/drag-frames/run-$n"
|
||||
done
|
||||
bun tests/e2e/edit-accuracy/run.mjs --grid "$BENCH_GRID" --filter "$BENCH_FILTER" \
|
||||
--jobs 1 --port 5961 --cli "$GITHUB_WORKSPACE/packages/cli/dist/cli.js" \
|
||||
--out "$RUNNER_TEMP/drag-frames/run-${{ matrix.run }}"
|
||||
- name: Require a quiet box (the blank page dropped no frame)
|
||||
run: |
|
||||
# shellcheck disable=SC2016 # the backticks are JavaScript template strings
|
||||
@@ -70,7 +79,7 @@ jobs:
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: studio-drag-frames
|
||||
name: studio-drag-frames-${{ matrix.run }}
|
||||
path: ${{ runner.temp }}/drag-frames
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
+82
-84
@@ -1,6 +1,8 @@
|
||||
import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
|
||||
import { buildProjectApiPath } from "./utils/projectRouting";
|
||||
import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
|
||||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useStableHandlers } from "./hooks/useStableHandlers";
|
||||
import { useHistoryFlags, useToolbarSession } from "./hooks/useShellSlices";
|
||||
import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
|
||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||
import { usePlayerStore } from "./player";
|
||||
@@ -46,10 +48,9 @@ import {
|
||||
import type { DomEditSelection } from "./components/editor/domEditing";
|
||||
import { StudioHeader } from "./components/StudioHeader";
|
||||
import { useGestureCommit } from "./hooks/useGestureCommit";
|
||||
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
||||
import { StudioLeftPanels } from "./components/StudioLeftPanels";
|
||||
import { useGestureTrailOverlay } from "./hooks/useGestureTrailOverlay";
|
||||
import { EditorShell } from "./components/EditorShell";
|
||||
import { StudioRightPanels } from "./components/StudioRightPanels";
|
||||
import { useStudioSidePanels } from "./hooks/useStudioSidePanels";
|
||||
import { TimelineToolbar } from "./components/TimelineToolbar";
|
||||
import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
|
||||
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
|
||||
@@ -99,17 +100,18 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
|
||||
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
|
||||
});
|
||||
const editHistory = usePersistentEditHistory({ projectId });
|
||||
const editHistory = useStableHandlers(usePersistentEditHistory({ projectId }), projectId);
|
||||
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
|
||||
const pendingTimelineEditPathRef = useRef(new Set<string>());
|
||||
const isGestureRecordingRef = useRef(false);
|
||||
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
const fileManager = useFileManager({
|
||||
const fileManagerResult = useFileManager({
|
||||
projectId,
|
||||
showToast,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
setRefreshKey,
|
||||
});
|
||||
const fileManager = useStableHandlers(fileManagerResult, projectId);
|
||||
const masterCompPath = useMemo(
|
||||
() => resolveMasterCompositionPath(fileManager.compositions),
|
||||
[fileManager.compositions],
|
||||
@@ -134,8 +136,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
|
||||
const previewPersistence = usePreviewPersistence({
|
||||
const renderQueue = useStableHandlers(useRenderQueue(projectId, activeCompPathRef), projectId);
|
||||
const previewPersistenceResult = usePreviewPersistence({
|
||||
showToast,
|
||||
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
@@ -144,6 +146,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
activeCompPathRef,
|
||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||
});
|
||||
const previewPersistence = useStableHandlers(previewPersistenceResult, projectId);
|
||||
const externalFileChanges = useStudioExternalFileChanges({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -184,15 +187,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
} = useTimelineAddAtPlayhead(
|
||||
timelineEditing.handleTimelineAssetDrop,
|
||||
timelineEditing.handleTimelineCompositionDrop,
|
||||
projectId,
|
||||
);
|
||||
const {
|
||||
activeBlockParams,
|
||||
setActiveBlockParams,
|
||||
handleAddBlock,
|
||||
handleTimelineBlockDrop,
|
||||
handleAddMediaOverlay,
|
||||
handlePreviewBlockDrop,
|
||||
} = useBlockHandlers({
|
||||
const blockHandlersResult = useBlockHandlers({
|
||||
projectId,
|
||||
blockCtxDeps: {
|
||||
activeCompPath,
|
||||
@@ -209,6 +206,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
setRightCollapsed: panelLayout.setRightCollapsed,
|
||||
setRightPanelTab: panelLayout.setRightPanelTab,
|
||||
});
|
||||
const {
|
||||
activeBlockParams,
|
||||
setActiveBlockParams,
|
||||
handleAddBlock,
|
||||
handleTimelineBlockDrop,
|
||||
handleAddMediaOverlay,
|
||||
handlePreviewBlockDrop,
|
||||
} = useStableHandlers(blockHandlersResult, projectId);
|
||||
const dismissBlockParams = useCallback(() => setActiveBlockParams(null), [setActiveBlockParams]);
|
||||
const setRightPanelTab = useDismissingTabSetter(panelLayout.setRightPanelTab, dismissBlockParams);
|
||||
const layout = useMemo(
|
||||
@@ -237,6 +242,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
||||
});
|
||||
const appHotkeys = useAppHotkeys({
|
||||
projectId,
|
||||
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
|
||||
handleLinkEdit: timelineEditing.handleLinkEdit,
|
||||
handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly,
|
||||
@@ -269,7 +275,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
onToggleRecording: () => handleToggleRecordingRef.current(),
|
||||
readOnlyPreview,
|
||||
});
|
||||
const domEditSession = useDomEditSession({
|
||||
const domEditSessionResult = useDomEditSession({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
compIdToSrc,
|
||||
@@ -309,6 +315,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
|
||||
readOnlyPreview,
|
||||
});
|
||||
const domEditSession = useStableHandlers(domEditSessionResult, projectId);
|
||||
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
||||
const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
|
||||
handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
|
||||
@@ -342,13 +349,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
effectiveTimelineDuration,
|
||||
});
|
||||
const compositionDimensions = useCompositionDimensions(previewIframeRef);
|
||||
const lint = useLintModal(projectId, refreshKey);
|
||||
const frameCapture = useFrameCapture({
|
||||
const lint = useStableHandlers(useLintModal(projectId, refreshKey), projectId);
|
||||
const frameCaptureResult = useFrameCapture({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
||||
});
|
||||
const frameCapture = useStableHandlers(frameCaptureResult, projectId);
|
||||
const {
|
||||
consoleErrors,
|
||||
setConsoleErrors,
|
||||
@@ -359,6 +367,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
const domEditSessionRef = useRef(domEditSession);
|
||||
domEditSessionRef.current = domEditSession;
|
||||
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
|
||||
projectId,
|
||||
domEditSessionRef,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
@@ -366,14 +375,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
readOnlyPreview,
|
||||
});
|
||||
handleToggleRecordingRef.current = handleToggleRecording;
|
||||
const canvasRectRef = useRef<DOMRect | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (gestureState !== "recording" || !previewIframe) {
|
||||
canvasRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
canvasRectRef.current = previewIframe.getBoundingClientRect();
|
||||
}, [gestureState, previewIframe]);
|
||||
const gestureOverlay = useGestureTrailOverlay(
|
||||
{ gestureState, gestureRecording },
|
||||
previewIframe,
|
||||
compositionDimensions,
|
||||
);
|
||||
const handlePreviewIframeRef = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
previewIframeRef.current = iframe;
|
||||
@@ -415,6 +421,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
setRightPanelTab,
|
||||
initialState: initialUrlStateRef.current,
|
||||
});
|
||||
const historyFlags = useHistoryFlags(editHistory);
|
||||
const toolbarSession = useToolbarSession(domEditSession);
|
||||
const studioCtxValue = buildStudioContextValue({
|
||||
projectId: projectId!,
|
||||
activeCompPath,
|
||||
@@ -427,7 +435,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
setRefreshKey,
|
||||
timelineElements,
|
||||
isPlaying,
|
||||
editHistory,
|
||||
editHistory: historyFlags,
|
||||
handleUndo: appHotkeys.handleUndo,
|
||||
handleRedo: appHotkeys.handleRedo,
|
||||
renderQueue,
|
||||
@@ -441,11 +449,51 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
const timelineToolbar = useMemo(
|
||||
() => (
|
||||
<TimelineToolbar
|
||||
domEditSession={domEditSession}
|
||||
domEditSession={toolbarSession}
|
||||
onSplitElement={timelineEditing.handleTimelineElementSplit}
|
||||
/>
|
||||
),
|
||||
[domEditSession, timelineEditing.handleTimelineElementSplit],
|
||||
[toolbarSession, timelineEditing.handleTimelineElementSplit],
|
||||
);
|
||||
const handleExport = useCallback(() => {
|
||||
void previewPersistence
|
||||
.waitForPendingDomEditSaves()
|
||||
.then(() => renderQueue.startRender(undefined));
|
||||
}, [previewPersistence, renderQueue]);
|
||||
const clearConsoleErrors = useCallback(() => setConsoleErrors(null), [setConsoleErrors]);
|
||||
const panels = useStudioSidePanels(
|
||||
{
|
||||
onSelectComposition: handleSelectComposition,
|
||||
onAddBlock: handleAddBlock,
|
||||
onPreviewBlock: setBlockPreview,
|
||||
onLint: lint.handleLint,
|
||||
linting: lint.linting,
|
||||
lintFindingCount: lint.lintFindingCount,
|
||||
lintFindingsByFile: lint.findingsByFile,
|
||||
lintHasError: lint.hasLintError,
|
||||
onAddAssetToTimeline: handleAddAssetAtPlayhead,
|
||||
onAddCompositionToTimeline: handleAddCompositionAtPlayhead,
|
||||
},
|
||||
{
|
||||
activeBlockParams,
|
||||
onDismissBlockParams: dismissBlockParams,
|
||||
onCloseBlockParams: () => {
|
||||
setActiveBlockParams(null);
|
||||
panelLayout.setRightPanelTab("design");
|
||||
},
|
||||
recordingState: gestureState,
|
||||
recordingDuration: gestureRecording.recordingDuration,
|
||||
onToggleRecording: handleToggleRecording,
|
||||
sdkSession: sdkHandle.session,
|
||||
publishSdkSession: sdkHandle.publish,
|
||||
forceReloadSdkSession: sdkHandle.forceReload,
|
||||
reloadPreview,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
onToggleElementHidden: timelineEditing.handleToggleElementHidden,
|
||||
onAutoGroupCarveSources: timelineEditing.handleAutoGroupCarveSources,
|
||||
onAddMediaOverlay: handleAddMediaOverlay,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
if (resolving || waitingForServer || !projectId)
|
||||
return <StudioSplash waiting={waitingForServer} />;
|
||||
@@ -468,12 +516,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
capturing={frameCapture.capturing}
|
||||
inspectorButtonActive={inspectorButtonActive}
|
||||
inspectorPanelActive={inspectorPanelActive}
|
||||
onExport={() => {
|
||||
void (async () => {
|
||||
await previewPersistence.waitForPendingDomEditSaves();
|
||||
await renderQueue.startRender(undefined);
|
||||
})();
|
||||
}}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
|
||||
<SaveQueuePausedBanner
|
||||
@@ -491,41 +534,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
<EditorShell
|
||||
readOnlyPreview={readOnlyPreview}
|
||||
readOnlyPreviewReason={readOnlyPreviewReason}
|
||||
panels={
|
||||
<>
|
||||
<StudioLeftPanels
|
||||
onSelectComposition={handleSelectComposition}
|
||||
onAddBlock={handleAddBlock}
|
||||
onPreviewBlock={setBlockPreview}
|
||||
onLint={lint.handleLint}
|
||||
linting={lint.linting}
|
||||
lintFindingCount={lint.lintFindingCount}
|
||||
lintFindingsByFile={lint.findingsByFile}
|
||||
lintHasError={lint.hasLintError}
|
||||
onAddAssetToTimeline={handleAddAssetAtPlayhead}
|
||||
onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
|
||||
/>
|
||||
<StudioRightPanels
|
||||
activeBlockParams={activeBlockParams}
|
||||
onDismissBlockParams={dismissBlockParams}
|
||||
onCloseBlockParams={() => {
|
||||
setActiveBlockParams(null);
|
||||
panelLayout.setRightPanelTab("design");
|
||||
}}
|
||||
recordingState={gestureState}
|
||||
recordingDuration={gestureRecording.recordingDuration}
|
||||
onToggleRecording={handleToggleRecording}
|
||||
sdkSession={sdkHandle.session}
|
||||
publishSdkSession={sdkHandle.publish}
|
||||
forceReloadSdkSession={sdkHandle.forceReload}
|
||||
reloadPreview={reloadPreview}
|
||||
recordEdit={editHistory.recordEdit}
|
||||
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
||||
onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
|
||||
onAddMediaOverlay={handleAddMediaOverlay}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
panels={panels}
|
||||
timelineToolbar={timelineToolbar}
|
||||
renderClipContent={renderClipContent}
|
||||
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
|
||||
@@ -561,18 +570,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
recordingState={gestureState}
|
||||
onToggleRecording={handleToggleRecording}
|
||||
blockPreview={blockPreview}
|
||||
gestureOverlay={
|
||||
gestureState === "recording" && previewIframe ? (
|
||||
<GestureTrailOverlay
|
||||
samples={gestureRecording.samplesRef.current}
|
||||
sampleCount={gestureRecording.samplesRef.current.length}
|
||||
trail={gestureRecording.trailRef.current}
|
||||
canvasRect={canvasRectRef.current!}
|
||||
compositionSize={compositionDimensions ?? undefined}
|
||||
mode="recording"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
gestureOverlay={gestureOverlay}
|
||||
/>
|
||||
<StudioOverlays
|
||||
projectId={projectId}
|
||||
@@ -580,7 +578,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
||||
lintModal={lint.lintModal}
|
||||
closeLintModal={lint.closeLintModal}
|
||||
consoleErrors={consoleErrors}
|
||||
clearConsoleErrors={() => setConsoleErrors(null)}
|
||||
clearConsoleErrors={clearConsoleErrors}
|
||||
domEditSession={domEditSession}
|
||||
activeCompPath={activeCompPath}
|
||||
toasts={toasts}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { PreviewPane } from "./nle/PreviewPane";
|
||||
import { TimelinePane } from "./nle/TimelinePane";
|
||||
import { PreviewOverlays } from "./nle/PreviewOverlays";
|
||||
@@ -81,7 +81,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
||||
// The dockable shell: every panel lives in one Dock, arranged by the user's
|
||||
// saved layout. Owns the shared player + composition-stack state via
|
||||
// NLEProvider so every panel shares one player.
|
||||
export function EditorShell({
|
||||
export const EditorShell = memo(function EditorShell({
|
||||
panels,
|
||||
timelineToolbar,
|
||||
renderClipContent,
|
||||
@@ -228,7 +228,7 @@ export function EditorShell({
|
||||
</div>
|
||||
</PreviewReadOnlyProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface EditorShellBodyProps {
|
||||
panels: ReactNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, type MouseEvent } from "react";
|
||||
import { memo, useContext, type MouseEvent } from "react";
|
||||
import { Camera } from "../icons/SystemIcons";
|
||||
import { useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
@@ -9,7 +9,7 @@ import { InspectorIcon } from "./icons/InspectorIcon";
|
||||
import { HyperframesLogo } from "./ui/HyperframesLogo";
|
||||
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export interface StudioHeaderProps {
|
||||
interface StudioHeaderProps {
|
||||
captureFrameHref: string;
|
||||
captureFrameFilename: string;
|
||||
handleCaptureFrameClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
@@ -35,7 +35,7 @@ export function shouldOpenInspector(
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioHeader({
|
||||
export const StudioHeader = memo(function StudioHeader({
|
||||
captureFrameHref,
|
||||
captureFrameFilename,
|
||||
handleCaptureFrameClick,
|
||||
@@ -184,4 +184,4 @@ export function StudioHeader({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { SourceEditor } from "./editor/SourceEditor";
|
||||
import { FileTree } from "./editor/FileTree";
|
||||
import { MediaPreview } from "./MediaPreview";
|
||||
@@ -36,7 +36,7 @@ function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactN
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioLeftPanels({
|
||||
export const StudioLeftPanels = memo(function StudioLeftPanels({
|
||||
onSelectComposition,
|
||||
onAddBlock,
|
||||
onPreviewBlock,
|
||||
@@ -159,7 +159,7 @@ export function StudioLeftPanels({
|
||||
</Dock.Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function CodeBody({
|
||||
projectId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { memo, type ComponentProps } from "react";
|
||||
import { LintModal } from "./LintModal";
|
||||
import { AskAgentModal } from "./AskAgentModal";
|
||||
import { StudioToast } from "./StudioToast";
|
||||
@@ -9,7 +9,7 @@ import type { useToast } from "../hooks/useToast";
|
||||
|
||||
type LintFindings = ComponentProps<typeof LintModal>["findings"];
|
||||
|
||||
export interface StudioOverlaysProps {
|
||||
interface StudioOverlaysProps {
|
||||
projectId: string;
|
||||
projectDir?: string | null;
|
||||
lintModal: LintFindings | null;
|
||||
@@ -28,7 +28,7 @@ export interface StudioOverlaysProps {
|
||||
* `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioOverlays({
|
||||
export const StudioOverlays = memo(function StudioOverlays({
|
||||
projectId,
|
||||
projectDir,
|
||||
lintModal,
|
||||
@@ -91,4 +91,4 @@ export function StudioOverlays({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import type { StudioRightPanelsProps } from "./StudioRightPanels.types";
|
||||
|
||||
import { PropertyPanel } from "./editor/PropertyPanel";
|
||||
@@ -29,7 +29,9 @@ import { useRemoveBackground } from "../hooks/useRemoveBackground";
|
||||
import { useApplyColorGradingScope } from "../hooks/useApplyColorGradingScope";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioRightPanels({
|
||||
const seekToTime = (t: number) => usePlayerStore.getState().requestSeek(t);
|
||||
|
||||
export const StudioRightPanels = memo(function StudioRightPanels({
|
||||
activeBlockParams,
|
||||
onCloseBlockParams,
|
||||
onDismissBlockParams,
|
||||
@@ -166,7 +168,7 @@ export function StudioRightPanels({
|
||||
handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
|
||||
[handleDomAttributeLiveCommit],
|
||||
);
|
||||
const handleHideAllSelected = () => {
|
||||
const handleHideAllSelected = useCallback(() => {
|
||||
// Audio has no visual to hide, and `data-hidden` on an audio element is what
|
||||
// MUTES it — preview silences it and the render drops it from the mix. The
|
||||
// timeline withholds the eye on an audio track for that reason
|
||||
@@ -181,7 +183,12 @@ export function StudioRightPanels({
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
|
||||
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
|
||||
};
|
||||
}, [domEditGroupSelections, showToast, activeCompPath, onToggleElementHidden]);
|
||||
const convertToKeyframes = useCallback(
|
||||
(animId: string, duration?: number) =>
|
||||
handleGsapConvertToKeyframes(animId, undefined, duration),
|
||||
[handleGsapConvertToKeyframes],
|
||||
);
|
||||
const propertyPanel = (
|
||||
<DesignPanelPromoteProvider
|
||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
@@ -246,10 +253,8 @@ export function StudioRightPanels({
|
||||
onCommitAnimatedProperties={commitAnimatedProperties}
|
||||
onAddKeyframe={handleGsapAddKeyframe}
|
||||
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
||||
onConvertToKeyframes={(animId, duration) =>
|
||||
handleGsapConvertToKeyframes(animId, undefined, duration)
|
||||
}
|
||||
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
||||
onConvertToKeyframes={convertToKeyframes}
|
||||
onSeekToTime={seekToTime}
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
onUnroll={handleUnroll}
|
||||
@@ -307,4 +312,4 @@ export function StudioRightPanels({
|
||||
</Dock.Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,5 +46,5 @@ export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastPr
|
||||
}
|
||||
|
||||
export function toastSurface(isError: boolean): string {
|
||||
return `border shadow-popover backdrop-blur-md ${isError ? "border-danger/40 bg-raised" : "border-border bg-raised/95"}`;
|
||||
return `border shadow-popover bg-raised ${isError ? "border-danger/40" : "border-border"}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import {
|
||||
ArrowsOutLineHorizontal,
|
||||
Image,
|
||||
@@ -141,7 +141,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function TimelineToolbar({
|
||||
export const TimelineToolbar = memo(function TimelineToolbar({
|
||||
domEditSession,
|
||||
onSplitElement,
|
||||
history,
|
||||
@@ -558,4 +558,4 @@ export function TimelineToolbar({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EditHistoryHandle,
|
||||
type UseEditHistoryActionsOptions,
|
||||
} from "./useEditHistoryActions";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
try {
|
||||
@@ -72,6 +73,7 @@ function tryApplyBeatHistory(
|
||||
// ── Types ──
|
||||
|
||||
interface UseAppHotkeysParams {
|
||||
projectId?: string | null;
|
||||
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
||||
handleLinkEdit?: LinkShortcutCallbacks["handleLinkEdit"];
|
||||
handleTimelineElementDeleteOnly?: LinkShortcutCallbacks["handleTimelineElementDeleteOnly"];
|
||||
@@ -117,6 +119,7 @@ interface UseAppHotkeysParams {
|
||||
// ── Hook ──
|
||||
|
||||
export function useAppHotkeys({
|
||||
projectId,
|
||||
handleTimelineElementsDelete,
|
||||
handleLinkEdit,
|
||||
handleTimelineElementDeleteOnly,
|
||||
@@ -266,9 +269,12 @@ export function useAppHotkeys({
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
syncPreviewHotkeys,
|
||||
};
|
||||
return useStableHandlers(
|
||||
{
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
syncPreviewHotkeys,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, type MouseEvent } from "react";
|
||||
import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import { liveTime, usePlayerStore } from "../player";
|
||||
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
||||
@@ -34,8 +34,10 @@ export function useFrameCapture({
|
||||
return isPlaying ? livePlayheadRef.current : currentTime;
|
||||
}, []);
|
||||
|
||||
const [captureStamp, setCaptureStamp] = useState(Date.now);
|
||||
const refreshCaptureFrameTime = useCallback(() => {
|
||||
setCaptureFrameTime(playheadTime());
|
||||
setCaptureStamp(Date.now());
|
||||
}, [playheadTime]);
|
||||
|
||||
const handleCaptureFrameClick = useCallback(
|
||||
@@ -103,13 +105,18 @@ export function useFrameCapture({
|
||||
[activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
|
||||
);
|
||||
|
||||
const captureFrameHref = projectId
|
||||
? buildFrameCaptureUrl({
|
||||
projectId,
|
||||
compositionPath: activeCompPath,
|
||||
currentTime: captureFrameTime,
|
||||
})
|
||||
: "#";
|
||||
const captureFrameHref = useMemo(
|
||||
() =>
|
||||
projectId
|
||||
? buildFrameCaptureUrl({
|
||||
projectId,
|
||||
compositionPath: activeCompPath,
|
||||
currentTime: captureFrameTime,
|
||||
version: captureStamp,
|
||||
})
|
||||
: "#",
|
||||
[projectId, activeCompPath, captureFrameTime, captureStamp],
|
||||
);
|
||||
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
||||
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
type RecordedKeyframe = {
|
||||
percentage: number;
|
||||
@@ -83,6 +84,7 @@ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOpt
|
||||
let gestureRecordingCommitCounter = 0;
|
||||
|
||||
interface UseGestureCommitParams {
|
||||
projectId?: string | null;
|
||||
domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
@@ -98,6 +100,7 @@ export interface UseGestureCommitResult {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useGestureCommit({
|
||||
projectId,
|
||||
domEditSessionRef,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
@@ -401,5 +404,5 @@ export function useGestureCommit({
|
||||
readOnlyPreview,
|
||||
]);
|
||||
|
||||
return { gestureState, gestureRecording, handleToggleRecording };
|
||||
return useStableHandlers({ gestureState, gestureRecording, handleToggleRecording }, projectId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { GestureTrailOverlay } from "../components/editor/GestureTrailOverlay";
|
||||
import type { UseGestureCommitResult } from "./useGestureCommit";
|
||||
|
||||
/** The live gesture trail drawn over the preview while a gesture is recording, or nothing. */
|
||||
export function useGestureTrailOverlay(
|
||||
{
|
||||
gestureState,
|
||||
gestureRecording,
|
||||
}: Pick<UseGestureCommitResult, "gestureState" | "gestureRecording">,
|
||||
previewIframe: HTMLIFrameElement | null,
|
||||
compositionSize: { width: number; height: number } | null,
|
||||
) {
|
||||
const canvasRectRef = useRef<DOMRect | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (gestureState !== "recording" || !previewIframe) {
|
||||
canvasRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
canvasRectRef.current = previewIframe.getBoundingClientRect();
|
||||
}, [gestureState, previewIframe]);
|
||||
if (gestureState !== "recording" || !previewIframe) return undefined;
|
||||
return (
|
||||
<GestureTrailOverlay
|
||||
samples={gestureRecording.samplesRef.current}
|
||||
sampleCount={gestureRecording.samplesRef.current.length}
|
||||
trail={gestureRecording.trailRef.current}
|
||||
canvasRect={canvasRectRef.current!}
|
||||
compositionSize={compositionSize ?? undefined}
|
||||
mode="recording"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
||||
import { PANEL_DEFINITIONS, type PanelId } from "../components/dock/panelRegistry";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
export interface InitialPanelLayoutState {
|
||||
rightCollapsed?: boolean | null;
|
||||
@@ -66,5 +67,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
store.setZoneVisible("right", !collapsed);
|
||||
}, []);
|
||||
|
||||
return { rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab };
|
||||
return useStableHandlers(
|
||||
{ rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab },
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import type { EnableKeyframesSession } from "./useEnableKeyframes";
|
||||
|
||||
interface HistoryFlags {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undoLabel: string | undefined;
|
||||
redoLabel: string | undefined;
|
||||
}
|
||||
|
||||
/** The four history fields the shell context carries, as one object that changes only when they do. */
|
||||
export function useHistoryFlags({ canUndo, canRedo, undoLabel, redoLabel }: HistoryFlags) {
|
||||
return useMemo(
|
||||
() => ({ canUndo, canRedo, undoLabel, redoLabel }),
|
||||
[canUndo, canRedo, undoLabel, redoLabel],
|
||||
);
|
||||
}
|
||||
|
||||
/** The session fields the timeline toolbar reads, so a hover or any other session change leaves it alone. */
|
||||
export function useToolbarSession(session: EnableKeyframesSession): EnableKeyframesSession {
|
||||
const {
|
||||
domEditSelection,
|
||||
selectedGsapAnimations,
|
||||
previewIframeRef,
|
||||
commitMutation,
|
||||
handleGsapAddAnimation,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveKeyframe,
|
||||
} = session;
|
||||
return useMemo(
|
||||
() => ({
|
||||
domEditSelection,
|
||||
selectedGsapAnimations,
|
||||
previewIframeRef,
|
||||
commitMutation,
|
||||
handleGsapAddAnimation,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveKeyframe,
|
||||
}),
|
||||
[
|
||||
domEditSelection,
|
||||
selectedGsapAnimations,
|
||||
previewIframeRef,
|
||||
commitMutation,
|
||||
handleGsapAddAnimation,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveKeyframe,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
interface Result {
|
||||
count: number;
|
||||
read: () => number;
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
});
|
||||
|
||||
function renderStable(seen: Result[]) {
|
||||
function Probe({ count, base }: { count: number; base: number }) {
|
||||
seen.push(useStableHandlers({ count, read: () => base }, "project"));
|
||||
return null;
|
||||
}
|
||||
root = createRoot(document.createElement("div"));
|
||||
return (count: number, base: number) =>
|
||||
act(() => root!.render(<Probe count={count} base={base} />));
|
||||
}
|
||||
|
||||
describe("useStableHandlers", () => {
|
||||
it("keeps the object while only its handlers change, and the handler calls the latest one", () => {
|
||||
const seen: Result[] = [];
|
||||
const render = renderStable(seen);
|
||||
render(1, 10);
|
||||
render(1, 20);
|
||||
expect(seen[1]).toBe(seen[0]);
|
||||
expect(seen[1]!.read()).toBe(20);
|
||||
});
|
||||
|
||||
it("gives a new object when a data field changes, with the same handler identity", () => {
|
||||
const seen: Result[] = [];
|
||||
const render = renderStable(seen);
|
||||
render(1, 10);
|
||||
render(2, 10);
|
||||
expect(seen[1]).not.toBe(seen[0]);
|
||||
expect(seen[1]!.count).toBe(2);
|
||||
expect(seen[1]!.read).toBe(seen[0]!.read);
|
||||
});
|
||||
|
||||
it("keeps a wrapper from an earlier scope calling that scope's handler", () => {
|
||||
const seen: { write: () => string }[] = [];
|
||||
function Scoped({ project }: { project: string }) {
|
||||
seen.push(useStableHandlers({ write: () => project }, project));
|
||||
return null;
|
||||
}
|
||||
root = createRoot(document.createElement("div"));
|
||||
act(() => root!.render(<Scoped project="a" />));
|
||||
const kept = seen[0]!.write;
|
||||
act(() => root!.render(<Scoped project="b" />));
|
||||
expect(kept()).toBe("a");
|
||||
expect(seen[1]!.write).not.toBe(kept);
|
||||
expect(seen[1]!.write()).toBe("b");
|
||||
});
|
||||
|
||||
it("gives a new object when a key is swapped for another with the same value", () => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
function Keys({ name }: { name: string }) {
|
||||
seen.push(useStableHandlers({ [name]: undefined }, "project"));
|
||||
return null;
|
||||
}
|
||||
root = createRoot(document.createElement("div"));
|
||||
act(() => root!.render(<Keys name="old" />));
|
||||
act(() => root!.render(<Keys name="fresh" />));
|
||||
expect(Object.keys(seen[1]!)).toEqual(["fresh"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
type Handler = (...args: never[]) => unknown;
|
||||
type Epoch = {
|
||||
scope: unknown;
|
||||
latest: object;
|
||||
wrappers: Map<string, Handler>;
|
||||
previous: object | null;
|
||||
};
|
||||
|
||||
// The same object while its non-function fields are unchanged, and one identity per function field calling the
|
||||
// latest one; a new `scope` (the project) starts new identities, and a wrapper kept from before calls its own scope's.
|
||||
export function useStableHandlers<T extends object>(value: T, scope: unknown): T {
|
||||
const epoch = useRef<Epoch | null>(null);
|
||||
if (!epoch.current || !Object.is(epoch.current.scope, scope))
|
||||
epoch.current = { scope, latest: value, wrappers: new Map(), previous: null };
|
||||
const current = epoch.current;
|
||||
current.latest = value;
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(value)) {
|
||||
if (typeof field !== "function") {
|
||||
next[key] = field;
|
||||
continue;
|
||||
}
|
||||
let wrapper = current.wrappers.get(key);
|
||||
if (!wrapper) {
|
||||
wrapper = (...args: never[]) =>
|
||||
(current.latest as Record<string, (...a: never[]) => unknown>)[key]!(...args);
|
||||
current.wrappers.set(key, wrapper);
|
||||
}
|
||||
next[key] = wrapper;
|
||||
}
|
||||
const prev = current.previous as Record<string, unknown> | null;
|
||||
const keys = Object.keys(next);
|
||||
if (
|
||||
prev &&
|
||||
keys.length === Object.keys(prev).length &&
|
||||
keys.every((k) => k in prev && prev[k] === next[k])
|
||||
)
|
||||
return prev as T;
|
||||
current.previous = next;
|
||||
return next as T;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSdkSession } from "./useSdkSession";
|
||||
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
/**
|
||||
* Open the studio's SDK session with master-view semantics.
|
||||
@@ -37,5 +38,5 @@ export function useStudioSdkSessions(
|
||||
useEffect(() => {
|
||||
usePreviewVariablesStore.getState().setValues(null);
|
||||
}, [projectId, activeCompPath]);
|
||||
return { sdkHandle, editFlowSdkSession };
|
||||
return { sdkHandle: useStableHandlers(sdkHandle, projectId), editFlowSdkSession };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StudioLeftPanels } from "../components/StudioLeftPanels";
|
||||
import type { StudioRightPanels } from "../components/StudioRightPanels";
|
||||
import { useStudioSidePanels } from "./useStudioSidePanels";
|
||||
|
||||
const rightProps = vi.hoisted(() => [] as { recordEdit: (project: string) => string }[]);
|
||||
vi.mock("../components/StudioLeftPanels", () => ({ StudioLeftPanels: () => null }));
|
||||
vi.mock("../components/StudioRightPanels", () => ({
|
||||
StudioRightPanels: (props: { recordEdit: (project: string) => string }) => {
|
||||
rightProps.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
rightProps.length = 0;
|
||||
});
|
||||
|
||||
function Panels({ project }: { project: string }) {
|
||||
const right = { recordEdit: () => project } as unknown as ComponentProps<
|
||||
typeof StudioRightPanels
|
||||
>;
|
||||
return useStudioSidePanels({} as ComponentProps<typeof StudioLeftPanels>, right, project);
|
||||
}
|
||||
|
||||
describe("useStudioSidePanels", () => {
|
||||
it("keeps a panel handler from one project calling that project after a switch", () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
act(() => root!.render(<Panels project="a" />));
|
||||
const kept = rightProps.at(-1)!.recordEdit;
|
||||
act(() => root!.render(<Panels project="b" />));
|
||||
expect(kept("")).toBe("a");
|
||||
expect(rightProps.at(-1)!.recordEdit("")).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo, type ComponentProps } from "react";
|
||||
import { StudioLeftPanels } from "../components/StudioLeftPanels";
|
||||
import { StudioRightPanels } from "../components/StudioRightPanels";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
/** Both side panels as one element that changes only when a panel's data does, never for a new handler. */
|
||||
export function useStudioSidePanels(
|
||||
left: ComponentProps<typeof StudioLeftPanels>,
|
||||
right: ComponentProps<typeof StudioRightPanels>,
|
||||
projectId: string | null,
|
||||
) {
|
||||
const stableLeft = useStableHandlers(left, projectId);
|
||||
const stableRight = useStableHandlers(right, projectId);
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<StudioLeftPanels {...stableLeft} />
|
||||
<StudioRightPanels {...stableRight} />
|
||||
</>
|
||||
),
|
||||
[stableLeft, stableRight],
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
|
||||
type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
|
||||
|
||||
export function useTimelineAddAtPlayhead(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
|
||||
export function useTimelineAddAtPlayhead(
|
||||
addAsset: AddAtPlacement,
|
||||
addComposition: AddAtPlacement,
|
||||
projectId: string | null,
|
||||
) {
|
||||
const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
|
||||
return {
|
||||
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
|
||||
addCompositionAtPlayhead: useCallback(
|
||||
(path: string) => addComposition(path, placement()),
|
||||
[addComposition],
|
||||
),
|
||||
};
|
||||
return useStableHandlers(
|
||||
{
|
||||
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
|
||||
addCompositionAtPlayhead: useCallback(
|
||||
(path: string) => addComposition(path, placement()),
|
||||
[addComposition],
|
||||
),
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useStableHandlers } from "./useStableHandlers";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
@@ -508,13 +509,41 @@ export function useTimelineEditing({
|
||||
return [...flatMembers, ...domMembers];
|
||||
};
|
||||
|
||||
const audioGroupAttribute = {
|
||||
...setAudioGroupAttribute,
|
||||
// Same two-array member lookup syncStoredGroupAttribute mirrors into
|
||||
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
||||
// no flat twin, only a domClipChildren entry, so both are checked.
|
||||
setQuiet: track(
|
||||
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
||||
setAudioGroupAttribute.revertLive(groupId, attr);
|
||||
return refused(reason);
|
||||
}),
|
||||
),
|
||||
};
|
||||
const stableAudioGroupAttribute = useStableHandlers(audioGroupAttribute, projectId);
|
||||
const elementFxAttribute = {
|
||||
...setElementFxAttribute,
|
||||
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
||||
setQuiet: track(
|
||||
guard(
|
||||
(element) => [element],
|
||||
setElementFxAttribute.setQuiet,
|
||||
(reason, element, attr) => {
|
||||
setElementFxAttribute.revertLive(element, attr);
|
||||
return refused(reason);
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
const stableElementFxAttribute = useStableHandlers(elementFxAttribute, projectId);
|
||||
// Every write-handler is tracked here, the one place all hand edits
|
||||
// converge, so undo never races a write; canEdit gates the same point.
|
||||
// Coverage boundary: see the PR body, not every kind resolves an element.
|
||||
const trackedRazorSplit = track(
|
||||
guard((element) => withLinkPartners([element]), handleRazorSplit),
|
||||
);
|
||||
return {
|
||||
const editing = {
|
||||
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
||||
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
||||
handleToggleTrackHidden: track(
|
||||
@@ -530,32 +559,8 @@ export function useTimelineEditing({
|
||||
}, handleToggleElementHidden),
|
||||
),
|
||||
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|
||||
setAudioGroupAttribute: {
|
||||
...setAudioGroupAttribute,
|
||||
// Same two-array member lookup syncStoredGroupAttribute mirrors into
|
||||
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
||||
// no flat twin, only a domClipChildren entry, so both are checked.
|
||||
setQuiet: track(
|
||||
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
||||
setAudioGroupAttribute.revertLive(groupId, attr);
|
||||
return refused(reason);
|
||||
}),
|
||||
),
|
||||
},
|
||||
setElementFxAttribute: {
|
||||
...setElementFxAttribute,
|
||||
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
||||
setQuiet: track(
|
||||
guard(
|
||||
(element) => [element],
|
||||
setElementFxAttribute.setQuiet,
|
||||
(reason, element, attr) => {
|
||||
setElementFxAttribute.revertLive(element, attr);
|
||||
return refused(reason);
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
setAudioGroupAttribute: stableAudioGroupAttribute,
|
||||
setElementFxAttribute: stableElementFxAttribute,
|
||||
handleTimelineElementDelete: track(
|
||||
guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
|
||||
),
|
||||
@@ -591,4 +596,5 @@ export function useTimelineEditing({
|
||||
setAudioGroupAttribute.restoreLive(restore);
|
||||
},
|
||||
};
|
||||
return useStableHandlers(editing, projectId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface FrameCaptureRequest {
|
||||
compositionPath: string | null;
|
||||
currentTime: number;
|
||||
origin?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
function normalizeCompositionPath(compositionPath: string | null): string {
|
||||
@@ -16,6 +17,7 @@ export function buildFrameCaptureUrl({
|
||||
compositionPath,
|
||||
currentTime,
|
||||
origin = window.location.origin,
|
||||
version = Date.now(),
|
||||
}: FrameCaptureRequest): string {
|
||||
const compPath = normalizeCompositionPath(compositionPath);
|
||||
const url = new URL(
|
||||
@@ -25,7 +27,7 @@ export function buildFrameCaptureUrl({
|
||||
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
||||
url.searchParams.set("format", "png");
|
||||
url.searchParams.set("output", "source");
|
||||
url.searchParams.set("v", String(Date.now()));
|
||||
url.searchParams.set("v", String(version));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -592,34 +592,40 @@ function topLevelTasks(trace, { pid, tid }) {
|
||||
return tops;
|
||||
}
|
||||
|
||||
// Thread CPU time, spread evenly over the task, so a loaded machine descheduling the thread does not count as work.
|
||||
// Thread CPU time, spread evenly over the task, so a descheduled thread does not count; untimed tasks count wall time.
|
||||
const cpuUs = (e, a, b) =>
|
||||
(Math.max(0, Math.min(b, e.ts + e.dur) - Math.max(a, e.ts)) * e.tdur) / (e.dur || 1);
|
||||
(Math.max(0, Math.min(b, e.ts + e.dur) - Math.max(a, e.ts)) * (e.tdur ?? e.dur)) / (e.dur || 1);
|
||||
|
||||
/** Main-thread CPU ms inside each frame interval, on the thread that ran the end mark; null when unknown. */
|
||||
function mainThreadPerFrame({ frames, mark, trace }) {
|
||||
const anchor = trace.find((e) => e.name === TRACE_MARK && e.cat.includes("user_timing"));
|
||||
const tasks = anchor ? topLevelTasks(trace, anchor) : [];
|
||||
// Without the mark or thread CPU time the work is unknown, which fails smoothness alone.
|
||||
if (!anchor || tasks.some((e) => e.tdur === undefined)) return null;
|
||||
// Without the mark the work is unknown, which fails smoothness alone.
|
||||
if (!anchor) return { work: null, wallTimed: 0, unknown: "no end mark in the trace" };
|
||||
const toTrace = (ms) => anchor.ts + (ms - mark) * 1000;
|
||||
return frames.slice(1).map((t, i) => {
|
||||
const [from, to] = [toTrace(frames[0]), toTrace(frames.at(-1))];
|
||||
const tasks = topLevelTasks(trace, anchor);
|
||||
const inFrames = (e) => e.ts < to && e.ts + e.dur > from;
|
||||
const wallTimed = tasks.filter((e) => e.tdur === undefined && inFrames(e)).length;
|
||||
const work = frames.slice(1).map((t, i) => {
|
||||
const [a, b] = [toTrace(frames[i]), toTrace(t)];
|
||||
return tasks.reduce((sum, e) => sum + cpuUs(e, a, b), 0) / 1000;
|
||||
});
|
||||
return { work, wallTimed };
|
||||
}
|
||||
|
||||
const hundredth = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
export function smoothness(rec) {
|
||||
const intervals = rec.frames.slice(1).map((t, i) => t - rec.frames[i]);
|
||||
const work = mainThreadPerFrame(rec);
|
||||
const { work, wallTimed, unknown } = mainThreadPerFrame(rec);
|
||||
return {
|
||||
p95: percentile(intervals, 95),
|
||||
frames: intervals.length,
|
||||
longTasks: rec.long.length,
|
||||
intervals: intervals.map(hundredth),
|
||||
work: work && work.map(hundredth),
|
||||
wallTimed,
|
||||
...(unknown && { unknown }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@ function mergeSmooth(parts) {
|
||||
longTasks: parts.reduce((n, s) => n + s.longTasks, 0),
|
||||
intervals,
|
||||
work: parts.every((s) => s.work) ? parts.flatMap((s) => s.work) : null,
|
||||
wallTimed: parts.reduce((n, s) => n + s.wallTimed, 0),
|
||||
...(parts.some((s) => s.unknown) && {
|
||||
unknown: parts
|
||||
.map((s) => s.unknown)
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,7 +264,11 @@ async function editText(c, step, state) {
|
||||
}
|
||||
// Typing with no session open would land on Studio's shortcuts, so a failed open types nothing.
|
||||
if (opened) {
|
||||
await page.keyboard.type(step.word);
|
||||
// One key per frame, as the nudges press theirs: a whole word in one frame is no typist's pace.
|
||||
for (const key of step.word) {
|
||||
await page.keyboard.type(key);
|
||||
await nextFrame(page);
|
||||
}
|
||||
await page.keyboard.press("Enter");
|
||||
await nextFrame(page);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { smoothness } from "./case.mjs";
|
||||
|
||||
const thread = { pid: 1, tid: 2 };
|
||||
const mark = { name: "edit-bench-end", cat: "blink.user_timing", ts: 1_000_000, ...thread };
|
||||
const task = (ts, dur, tdur) => ({ name: "RunTask", ph: "X", ts, dur, tdur, ...thread });
|
||||
// Frame stamps 50, 66 and 82 ms before a mark at 100 ms: trace time 950, 966 and 982 ms.
|
||||
const rec = (trace) => ({ frames: [50, 66, 82], mark: 100, long: [], trace });
|
||||
|
||||
describe("smoothness work per frame", () => {
|
||||
it("measures the frames when a task outside them lacks thread time", () => {
|
||||
const s = smoothness(rec([mark, task(900_000, 10_000), task(960_000, 4_000, 2_000)]));
|
||||
expect(s.work).toEqual([2, 0]);
|
||||
expect(s.wallTimed).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a task inside the frames that lacks thread time at its wall time", () => {
|
||||
const s = smoothness(rec([mark, task(960_000, 4_000)]));
|
||||
expect(s.work).toEqual([4, 0]);
|
||||
expect(s.wallTimed).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves the work unknown without the end mark", () => {
|
||||
const s = smoothness(rec([task(960_000, 4_000, 4_000)]));
|
||||
expect(s.work).toBeNull();
|
||||
expect(s.unknown).toBe("no end mark in the trace");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user