@hyperframes/studio 0.8.52 → 0.8.53
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-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/index-DrHQAuGO.css +1 -0
- package/dist/assets/index-HfP3sap2.js +390 -0
- package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +13 -6
- package/dist/index.html +2 -2
- package/dist/index.js +7664 -7953
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/App.tsx +42 -48
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +52 -56
- package/src/components/StudioHeader.dom.test.tsx +1 -1
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +10 -13
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/dock/Dock.test.tsx +16 -2
- package/src/components/dock/Dock.tsx +13 -3
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +21 -4
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/TimelinePane.tsx +46 -67
- package/src/components/panels/SlideshowPanel.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +1 -1
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/ui/Select.tsx +4 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +64 -0
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/index.ts +2 -1
- package/src/main.tsx +26 -0
- package/src/player/components/Timeline.tsx +1 -1
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/utils/studioHelpers.ts +0 -7
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +0 -16
- package/dist/assets/index-CxAu_Okg.css +0 -1
- package/dist/assets/index-DocJlW2v.js +0 -376
- package/src/components/StudioLeftSidebar.tsx +0 -207
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -348
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -25,6 +25,8 @@ export interface DockSnapshot {
|
|
|
25
25
|
/** Open panels whose tab is showing and whose group is not hidden. */
|
|
26
26
|
visiblePanels: ReadonlySet<PanelId>;
|
|
27
27
|
activePanel: PanelId | null;
|
|
28
|
+
/** The tab each group shows, so a restored layout still records what every side column last showed. */
|
|
29
|
+
groupActivePanels?: readonly PanelId[];
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
type LastActive = Partial<Record<PanelZone, PanelId>>;
|
|
@@ -53,13 +55,17 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
|
|
|
53
55
|
lastActive: {},
|
|
54
56
|
pendingActivation: null,
|
|
55
57
|
attach: (controller) => set({ controller }),
|
|
56
|
-
detach: () => set({ controller: null }),
|
|
58
|
+
detach: () => set({ controller: null, lastActive: {} }),
|
|
57
59
|
sync: (snapshot) =>
|
|
58
60
|
set((state) => {
|
|
61
|
+
const lastActive = { ...state.lastActive };
|
|
62
|
+
for (const id of snapshot.groupActivePanels ?? []) {
|
|
63
|
+
const zone = PANEL_DEFINITIONS[id].zone;
|
|
64
|
+
if (zone !== "center") lastActive[zone] = id;
|
|
65
|
+
}
|
|
59
66
|
const { activePanel } = snapshot;
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
return { ...snapshot, lastActive: { ...state.lastActive, [zone]: activePanel } };
|
|
67
|
+
if (activePanel) lastActive[PANEL_DEFINITIONS[activePanel].zone] = activePanel;
|
|
68
|
+
return { ...snapshot, lastActive };
|
|
63
69
|
}),
|
|
64
70
|
takePendingActivation: () => {
|
|
65
71
|
const { pendingActivation } = get();
|
|
@@ -89,3 +95,14 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
|
|
|
89
95
|
},
|
|
90
96
|
resetLayout: () => get().controller?.reset(),
|
|
91
97
|
}));
|
|
98
|
+
|
|
99
|
+
/** The panel of `zone` the user is looking at: the last one focused if still showing, else any showing. */
|
|
100
|
+
export function visiblePanelInZone(
|
|
101
|
+
zone: PanelZone,
|
|
102
|
+
lastActive: LastActive,
|
|
103
|
+
visiblePanels: ReadonlySet<PanelId>,
|
|
104
|
+
): PanelId | null {
|
|
105
|
+
const last = lastActive[zone];
|
|
106
|
+
if (last && visiblePanels.has(last)) return last;
|
|
107
|
+
return panelsInZone(zone).find((id) => visiblePanels.has(id)) ?? null;
|
|
108
|
+
}
|
|
@@ -5,7 +5,6 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
|
-
import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
|
|
9
8
|
import type { DomEditSelection } from "./domEditing";
|
|
10
9
|
import { SnapToolbar } from "./SnapToolbar";
|
|
11
10
|
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
@@ -31,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) {
|
|
|
31
30
|
function AppHotkeyHarness() {
|
|
32
31
|
const domEditSelectionRef = useRef<DomEditSelection | null>(null);
|
|
33
32
|
const clearDomSelectionRef = useRef<() => void>(() => undefined);
|
|
34
|
-
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
|
|
35
33
|
|
|
36
34
|
useAppHotkeys({
|
|
37
35
|
handleTimelineElementsDelete: vi.fn(async () => {}),
|
|
@@ -50,7 +48,6 @@ function AppHotkeyHarness() {
|
|
|
50
48
|
showToast: vi.fn(),
|
|
51
49
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
52
50
|
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
53
|
-
leftSidebarRef,
|
|
54
51
|
handleCopy: vi.fn(() => false),
|
|
55
52
|
handlePaste: vi.fn(async () => undefined),
|
|
56
53
|
handleCut: vi.fn(async () => false),
|
|
@@ -4,17 +4,11 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
|
|
|
4
4
|
import type { TimelineElement } from "../../player";
|
|
5
5
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
6
|
import { useCompositionStack } from "./useCompositionStack";
|
|
7
|
-
import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
8
7
|
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
|
9
8
|
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
|
10
|
-
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
11
9
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
12
10
|
import { createStableContext } from "../../utils/hmrStableContext";
|
|
13
11
|
|
|
14
|
-
// Timeline gets a generous default height so the preview isn't oversized and the
|
|
15
|
-
// tracks have room to breathe (CapCut-style). Users can still drag the divider.
|
|
16
|
-
const DEFAULT_TIMELINE_H = 360;
|
|
17
|
-
|
|
18
12
|
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
19
13
|
return compositionLoading;
|
|
20
14
|
}
|
|
@@ -34,10 +28,6 @@ export interface NLEContextValue {
|
|
|
34
28
|
handleDrillDown: (element: TimelineElement) => void;
|
|
35
29
|
compIdToSrc: Map<string, string>;
|
|
36
30
|
// layout state
|
|
37
|
-
timelineH: number;
|
|
38
|
-
setTimelineH: React.Dispatch<React.SetStateAction<number>>;
|
|
39
|
-
persistTimelineH: (height: number) => void;
|
|
40
|
-
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
41
31
|
// composition loading
|
|
42
32
|
compositionLoading: boolean;
|
|
43
33
|
setCompositionLoading: (loading: boolean) => void;
|
|
@@ -256,35 +246,6 @@ export function NLEProvider({
|
|
|
256
246
|
});
|
|
257
247
|
}, [compIdToSrc]);
|
|
258
248
|
|
|
259
|
-
// Resizable timeline height — persisted alongside zoom/pan so the user's
|
|
260
|
-
// workspace layout survives reloads.
|
|
261
|
-
const [timelineH, setTimelineH] = useState(() => {
|
|
262
|
-
const stored = readStudioUiPreferences().timelineHeight;
|
|
263
|
-
return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
|
|
264
|
-
});
|
|
265
|
-
const persistTimelineH = useCallback((height: number) => {
|
|
266
|
-
writeStudioUiPreferences({ timelineHeight: Math.round(height) });
|
|
267
|
-
}, []);
|
|
268
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
269
|
-
// A height persisted on a tall window can exceed this window's container and
|
|
270
|
-
// collapse the flex-1 preview to 0px. Observing the container rather than
|
|
271
|
-
// clamping once at mount is what makes a window RESIZED after load behave the
|
|
272
|
-
// same as one loaded at that size: dragging 760 -> 520 tall used to leave the
|
|
273
|
-
// timeline at its stored 429px and the preview at 47px.
|
|
274
|
-
useEffect(() => {
|
|
275
|
-
const element = containerRef.current;
|
|
276
|
-
if (!element || typeof ResizeObserver === "undefined") return;
|
|
277
|
-
const reconcile = () => {
|
|
278
|
-
const containerH = element.getBoundingClientRect().height;
|
|
279
|
-
if (!containerH) return;
|
|
280
|
-
setTimelineH((prev) => fitTimelineHeight(containerH, prev));
|
|
281
|
-
};
|
|
282
|
-
reconcile();
|
|
283
|
-
const observer = new ResizeObserver(reconcile);
|
|
284
|
-
observer.observe(element);
|
|
285
|
-
return () => observer.disconnect();
|
|
286
|
-
}, []);
|
|
287
|
-
|
|
288
249
|
const hasLoadedOnceRef = useRef(false);
|
|
289
250
|
const [compositionLoading, setCompositionLoadingRaw] = useState(true);
|
|
290
251
|
const setCompositionLoading = useCallback((loading: boolean) => {
|
|
@@ -317,10 +278,6 @@ export function NLEProvider({
|
|
|
317
278
|
handleNavigateComposition,
|
|
318
279
|
handleDrillDown,
|
|
319
280
|
compIdToSrc,
|
|
320
|
-
timelineH,
|
|
321
|
-
setTimelineH,
|
|
322
|
-
persistTimelineH,
|
|
323
|
-
containerRef,
|
|
324
281
|
compositionLoading,
|
|
325
282
|
setCompositionLoading,
|
|
326
283
|
timelineDisabled,
|
|
@@ -2,7 +2,6 @@ import { useCallback, type ReactNode } from "react";
|
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement } from "../../player";
|
|
4
4
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
|
-
import { TimelineResizeDivider } from "./TimelineResizeDivider";
|
|
6
5
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
7
6
|
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
8
7
|
import { useNLEContext } from "./NLEContext";
|
|
@@ -132,10 +131,6 @@ export function TimelinePane({
|
|
|
132
131
|
handleDrillDown,
|
|
133
132
|
compositionStack,
|
|
134
133
|
updateCompositionStack,
|
|
135
|
-
timelineH,
|
|
136
|
-
setTimelineH,
|
|
137
|
-
persistTimelineH,
|
|
138
|
-
containerRef,
|
|
139
134
|
timelineDisabled,
|
|
140
135
|
timelineSessionEpoch,
|
|
141
136
|
} = useNLEContext();
|
|
@@ -254,72 +249,56 @@ export function TimelinePane({
|
|
|
254
249
|
);
|
|
255
250
|
|
|
256
251
|
return (
|
|
257
|
-
|
|
258
|
-
<TimelineResizeDivider
|
|
259
|
-
timelineH={timelineH}
|
|
260
|
-
setTimelineH={setTimelineH}
|
|
261
|
-
persistTimelineH={persistTimelineH}
|
|
262
|
-
containerRef={containerRef}
|
|
263
|
-
disabled={timelineDisabled}
|
|
264
|
-
/>
|
|
265
|
-
|
|
266
|
-
{/* Timeline section — inner padding (not margin) keeps the divider's
|
|
267
|
-
height math exact while giving the panel a gap from the shell edges. */}
|
|
252
|
+
<div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
|
|
268
253
|
<div
|
|
269
|
-
className="
|
|
270
|
-
|
|
271
|
-
|
|
254
|
+
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
|
|
255
|
+
onDoubleClick={(e) => {
|
|
256
|
+
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
257
|
+
if (timelineDisabled) return;
|
|
258
|
+
if (compositionStack.length > 1) {
|
|
259
|
+
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
260
|
+
}
|
|
261
|
+
}}
|
|
272
262
|
>
|
|
263
|
+
<div className="shrink-0">{timelineToolbar}</div>
|
|
264
|
+
<Timeline
|
|
265
|
+
sessionEpoch={timelineSessionEpoch}
|
|
266
|
+
onSeek={seek}
|
|
267
|
+
onDrillDown={handleDrillDown}
|
|
268
|
+
renderClipContent={renderClipContent}
|
|
269
|
+
onFileDrop={onFileDrop}
|
|
270
|
+
onDeleteElement={handleDeleteElement}
|
|
271
|
+
onAssetDrop={onAssetDrop}
|
|
272
|
+
onBlockDrop={onBlockDrop}
|
|
273
|
+
onCompositionDrop={onCompositionDrop}
|
|
274
|
+
onMoveElement={handleMoveElement}
|
|
275
|
+
onMoveElements={handleMoveElements}
|
|
276
|
+
onResizeElement={handleResizeElement}
|
|
277
|
+
onResizeElements={handleResizeElements}
|
|
278
|
+
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
279
|
+
onSplitElement={handleSplitElement}
|
|
280
|
+
onSelectElement={onSelectTimelineElement}
|
|
281
|
+
onCopyClip={onCopyClip}
|
|
282
|
+
onPasteClip={onPasteClip}
|
|
283
|
+
onDuplicateClip={onDuplicateClip}
|
|
284
|
+
canPasteClip={canPasteClip}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
|
|
288
|
+
{timelineDisabled && (
|
|
273
289
|
<div
|
|
274
|
-
className="
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
}}
|
|
290
|
+
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
291
|
+
data-testid="timeline-loading-disabled-overlay"
|
|
292
|
+
role="status"
|
|
293
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
294
|
+
onDragOver={(event) => event.preventDefault()}
|
|
295
|
+
onDrop={(event) => event.preventDefault()}
|
|
282
296
|
>
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
onSeek={seek}
|
|
287
|
-
onDrillDown={handleDrillDown}
|
|
288
|
-
renderClipContent={renderClipContent}
|
|
289
|
-
onFileDrop={onFileDrop}
|
|
290
|
-
onDeleteElement={handleDeleteElement}
|
|
291
|
-
onAssetDrop={onAssetDrop}
|
|
292
|
-
onBlockDrop={onBlockDrop}
|
|
293
|
-
onCompositionDrop={onCompositionDrop}
|
|
294
|
-
onMoveElement={handleMoveElement}
|
|
295
|
-
onMoveElements={handleMoveElements}
|
|
296
|
-
onResizeElement={handleResizeElement}
|
|
297
|
-
onResizeElements={handleResizeElements}
|
|
298
|
-
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
299
|
-
onSplitElement={handleSplitElement}
|
|
300
|
-
onSelectElement={onSelectTimelineElement}
|
|
301
|
-
onCopyClip={onCopyClip}
|
|
302
|
-
onPasteClip={onPasteClip}
|
|
303
|
-
onDuplicateClip={onDuplicateClip}
|
|
304
|
-
canPasteClip={canPasteClip}
|
|
305
|
-
/>
|
|
297
|
+
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
|
|
298
|
+
Loading composition…
|
|
299
|
+
</span>
|
|
306
300
|
</div>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<div
|
|
310
|
-
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
311
|
-
data-testid="timeline-loading-disabled-overlay"
|
|
312
|
-
role="status"
|
|
313
|
-
onPointerDown={(event) => event.preventDefault()}
|
|
314
|
-
onDragOver={(event) => event.preventDefault()}
|
|
315
|
-
onDrop={(event) => event.preventDefault()}
|
|
316
|
-
>
|
|
317
|
-
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
|
|
318
|
-
Loading composition…
|
|
319
|
-
</span>
|
|
320
|
-
</div>
|
|
321
|
-
)}
|
|
322
|
-
</div>
|
|
323
|
-
</>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
324
303
|
);
|
|
325
304
|
}
|
|
@@ -156,7 +156,7 @@ export function makeSlideshowNotesController(): NotesController {
|
|
|
156
156
|
// ── Component ─────────────────────────────────────────────────────────────
|
|
157
157
|
|
|
158
158
|
export interface SlideshowPanelProps {
|
|
159
|
-
/** Scenes from the live clip manifest (passed from
|
|
159
|
+
/** Scenes from the live clip manifest (passed from StudioRightPanels). */
|
|
160
160
|
scenes: import("./slideshowPanelHelpers").SceneInfo[];
|
|
161
161
|
/**
|
|
162
162
|
* Called with the updated manifest after every discrete edit (toggle, add,
|
|
@@ -246,7 +246,7 @@ const EMPTY_STATE = (
|
|
|
246
246
|
</p>
|
|
247
247
|
);
|
|
248
248
|
|
|
249
|
-
// Panel orchestrator — JSX conditionals per section, same shape as
|
|
249
|
+
// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanels.
|
|
250
250
|
// fallow-ignore-next-line complexity
|
|
251
251
|
export const VariablesPanel = memo(function VariablesPanel({
|
|
252
252
|
sdkSession,
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
|
-
import { RenderQueue } from "./RenderQueue";
|
|
6
|
+
import { RenderQueue, type StartRenderHandler } from "./RenderQueue";
|
|
7
|
+
import { getPersistedRenderSettings } from "./renderSettings";
|
|
8
|
+
import { buttonBase, buttonSizes, buttonVariants, cn } from "../ui";
|
|
9
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
10
|
+
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
7
11
|
import type { FfmpegStatus } from "./useFfmpegStatus";
|
|
8
12
|
|
|
9
13
|
// Encoder availability arrives as a prop (useRenderQueue owns the probe), so
|
|
@@ -18,6 +22,9 @@ let root: Root | null = null;
|
|
|
18
22
|
beforeEach(() => {
|
|
19
23
|
ffmpegStatus = { ok: true };
|
|
20
24
|
recheck.mockClear();
|
|
25
|
+
// The format, frame-rate and quality controls write through to the real
|
|
26
|
+
// store, so each case has to start from the shipped defaults.
|
|
27
|
+
localStorage.clear();
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
afterEach(() => {
|
|
@@ -26,7 +33,10 @@ afterEach(() => {
|
|
|
26
33
|
document.body.innerHTML = "";
|
|
27
34
|
});
|
|
28
35
|
|
|
29
|
-
function mountRenderQueue(
|
|
36
|
+
function mountRenderQueue(
|
|
37
|
+
onStartRender: Mock<StartRenderHandler>,
|
|
38
|
+
compositionDimensions = { width: 1920, height: 1080 },
|
|
39
|
+
) {
|
|
30
40
|
const host = document.createElement("div");
|
|
31
41
|
document.body.append(host);
|
|
32
42
|
root = createRoot(host);
|
|
@@ -39,7 +49,7 @@ function mountRenderQueue(onStartRender: Mock) {
|
|
|
39
49
|
onClearCompleted={vi.fn()}
|
|
40
50
|
onStartRender={onStartRender}
|
|
41
51
|
isRendering={false}
|
|
42
|
-
compositionDimensions={
|
|
52
|
+
compositionDimensions={compositionDimensions}
|
|
43
53
|
ffmpeg={ffmpegStatus}
|
|
44
54
|
ffmpegChecking={false}
|
|
45
55
|
onRecheckFfmpeg={recheck}
|
|
@@ -49,37 +59,135 @@ function mountRenderQueue(onStartRender: Mock) {
|
|
|
49
59
|
return host;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
/** Base UI moves focus a task later than React renders; happy-dom is no faster. */
|
|
63
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
64
|
+
|
|
65
|
+
function fire(el: Element, type: string, key?: string) {
|
|
66
|
+
const event =
|
|
67
|
+
key === undefined
|
|
68
|
+
? new MouseEvent(type, { bubbles: true })
|
|
69
|
+
: new KeyboardEvent(type, { bubbles: true, key });
|
|
70
|
+
act(() => void el.dispatchEvent(event));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function triggerFor(host: HTMLElement, label: string): HTMLElement {
|
|
74
|
+
const trigger = host.querySelector<HTMLElement>(`[role="combobox"][aria-label="${label}"]`);
|
|
75
|
+
if (!trigger) throw new Error(`no select labelled ${label}`);
|
|
76
|
+
return trigger;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Opens a Select and arrows down `steps` items before committing, the way a keyboard user
|
|
80
|
+
* does. happy-dom does not synthesise the click Space would trigger, so it is dispatched here. */
|
|
81
|
+
async function chooseByArrowing(trigger: HTMLElement, steps: number) {
|
|
82
|
+
fire(trigger, "keydown", " ");
|
|
83
|
+
fire(trigger, "keyup", " ");
|
|
84
|
+
act(() => trigger.click());
|
|
85
|
+
await settle();
|
|
86
|
+
for (let i = 0; i < steps; i += 1) {
|
|
87
|
+
fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
|
|
88
|
+
await settle();
|
|
89
|
+
}
|
|
90
|
+
fire(document.activeElement ?? document.body, "keydown", "Enter");
|
|
91
|
+
await settle();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exportButtonIn(host: HTMLElement): HTMLButtonElement {
|
|
95
|
+
const button = host.querySelector<HTMLButtonElement>('[data-testid="renders-export"]');
|
|
96
|
+
if (!button) throw new Error("export button did not render");
|
|
97
|
+
return button;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("RenderQueue controls", () => {
|
|
101
|
+
it("has no native select left in the panel", () => {
|
|
102
|
+
// R8. The four format / resolution / frame-rate / quality controls are the
|
|
103
|
+
// shared Select now; a native one would bring back an OS popup that no
|
|
104
|
+
// token can reach.
|
|
105
|
+
expect(mountRenderQueue(vi.fn()).querySelector("select")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("wears exactly the header Export's recipe, plus the full width", () => {
|
|
109
|
+
// AE3. Set equality, not "contains": the bug this replaces was an extra
|
|
110
|
+
// `text-[11px]` on this button, which `cn` resolved by dropping the size
|
|
111
|
+
// recipe's `text-step-12` and left the two Exports a type step apart.
|
|
112
|
+
const shared = cn(buttonBase, buttonVariants.primary, buttonSizes.md);
|
|
113
|
+
const classes = new Set(exportButtonIn(mountRenderQueue(vi.fn())).className.split(/\s+/));
|
|
114
|
+
|
|
115
|
+
expect(classes).toEqual(new Set([...shared.split(/\s+/), "w-full"]));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("classifies the format Select the way it classified the native one (KTD13)", async () => {
|
|
119
|
+
const host = mountRenderQueue(vi.fn());
|
|
120
|
+
const reference = document.createElement("select");
|
|
121
|
+
document.body.append(reference);
|
|
122
|
+
await settle();
|
|
123
|
+
|
|
124
|
+
const trigger = triggerFor(host, "Format");
|
|
125
|
+
|
|
126
|
+
// Both true, not merely equal: two falses would agree and prove nothing.
|
|
127
|
+
expect(isTypingTarget(reference)).toBe(true);
|
|
128
|
+
expect(shouldIgnorePlaybackShortcutTarget(reference)).toBe(true);
|
|
129
|
+
expect(isTypingTarget(trigger)).toBe(isTypingTarget(reference));
|
|
130
|
+
expect(shouldIgnorePlaybackShortcutTarget(trigger)).toBe(
|
|
131
|
+
shouldIgnorePlaybackShortcutTarget(reference),
|
|
58
132
|
);
|
|
59
|
-
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("submits the canonical landscape 4K preset selected by the user", async () => {
|
|
136
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
137
|
+
const host = mountRenderQueue(onStartRender);
|
|
60
138
|
|
|
139
|
+
// Auto, 1080p, 4K: two steps down from the default.
|
|
140
|
+
await chooseByArrowing(triggerFor(host, "Resolution"), 2);
|
|
61
141
|
act(() => {
|
|
62
|
-
|
|
63
|
-
resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
142
|
+
exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
64
143
|
});
|
|
65
144
|
|
|
66
|
-
|
|
67
|
-
|
|
145
|
+
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("refuses a resolution the composition cannot reach, and says why", async () => {
|
|
149
|
+
// 1080p on a 1280x720 comp is a 1.5x scale, which the producer rejects; the
|
|
150
|
+
// option stays listed (its label explains why) but the keyboard skips it.
|
|
151
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
152
|
+
const host = mountRenderQueue(onStartRender, { width: 1280, height: 720 });
|
|
153
|
+
const trigger = triggerFor(host, "Resolution");
|
|
154
|
+
|
|
155
|
+
fire(trigger, "keydown", " ");
|
|
156
|
+
fire(trigger, "keyup", " ");
|
|
157
|
+
act(() => trigger.click());
|
|
158
|
+
await settle();
|
|
159
|
+
const blocked = [...document.querySelectorAll('[role="option"]')].filter((option) =>
|
|
160
|
+
option.hasAttribute("data-disabled"),
|
|
68
161
|
);
|
|
69
|
-
|
|
162
|
+
expect(blocked).toHaveLength(1);
|
|
163
|
+
expect(blocked[0]?.textContent).toContain("not an integer scale of 1280×720");
|
|
164
|
+
|
|
165
|
+
fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
|
|
166
|
+
await settle();
|
|
167
|
+
fire(document.activeElement ?? document.body, "keydown", "Enter");
|
|
168
|
+
await settle();
|
|
169
|
+
fire(document.activeElement ?? document.body, "keydown", "Escape");
|
|
170
|
+
await settle();
|
|
70
171
|
act(() => {
|
|
71
|
-
|
|
172
|
+
exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
173
|
});
|
|
73
174
|
|
|
74
|
-
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "
|
|
175
|
+
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
|
|
75
176
|
});
|
|
76
|
-
});
|
|
77
177
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
178
|
+
it("persists a changed format as the literal union value", async () => {
|
|
179
|
+
const host = mountRenderQueue(vi.fn());
|
|
180
|
+
|
|
181
|
+
// MP4, MOV, WebM: one step down commits "mov", not the label "MOV (ProRes)".
|
|
182
|
+
await chooseByArrowing(triggerFor(host, "Format"), 1);
|
|
183
|
+
|
|
184
|
+
expect(getPersistedRenderSettings()).toEqual({
|
|
185
|
+
format: "mov",
|
|
186
|
+
quality: "standard",
|
|
187
|
+
fps: 30,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
83
191
|
|
|
84
192
|
describe("RenderQueue Export button", () => {
|
|
85
193
|
it("leaves the font size to the shared Button", () => {
|
|
@@ -97,7 +205,7 @@ describe("RenderQueue FFmpeg gate", () => {
|
|
|
97
205
|
hint: "brew install ffmpeg",
|
|
98
206
|
command: "brew install ffmpeg",
|
|
99
207
|
};
|
|
100
|
-
const onStartRender = vi.fn();
|
|
208
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
101
209
|
const host = mountRenderQueue(onStartRender);
|
|
102
210
|
|
|
103
211
|
expect(host.textContent).toContain("FFmpeg not found");
|
|
@@ -130,7 +238,7 @@ describe("RenderQueue FFmpeg gate", () => {
|
|
|
130
238
|
// as "not installed" would lock Export for setups that render fine.
|
|
131
239
|
it("leaves Export usable when the probe returns no answer", () => {
|
|
132
240
|
ffmpegStatus = null;
|
|
133
|
-
const onStartRender = vi.fn();
|
|
241
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
134
242
|
const host = mountRenderQueue(onStartRender);
|
|
135
243
|
|
|
136
244
|
expect(host.textContent).not.toContain("FFmpeg not found");
|