@hyperframes/studio 0.8.71 → 0.8.73
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-DuN39PN6.js → hyperframes-player-BUauoSsb.js} +1 -1
- package/dist/assets/{index-BoQsbDgf.js → index-CMFPPAGq.js} +1 -1
- package/dist/assets/{index-BmCEvqsr.js → index-DKInxDDv.js} +1 -1
- package/dist/assets/{index-CUcdruHw.js → index-DxIHGgGZ.js} +163 -163
- package/dist/index.d.ts +16 -1
- package/dist/index.html +1 -1
- package/dist/index.js +129 -92
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/NLEPreview.test.ts +19 -1
- package/src/components/nle/NLEPreview.tsx +22 -7
- package/src/hooks/appHotkeysDispatch.ts +9 -3
- package/src/index.ts +2 -0
- package/src/player/components/PlayerControls.tsx +5 -0
- package/src/player/components/ShortcutsPanel.test.tsx +51 -0
- package/src/player/components/ShortcutsPanel.tsx +7 -90
- package/src/player/components/studioShortcuts.ts +101 -0
- package/src/player/components/useTimelinePlayhead.test.tsx +65 -0
- package/src/player/components/useTimelinePlayhead.ts +2 -1
- package/src/player/hooks/useShadowPreviewReload.ts +15 -5
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +43 -6
- package/src/player/lib/timelineDOM.test.ts +86 -0
- package/src/player/lib/timelineDOM.ts +5 -5
- package/src/player/lib/timelineElementHelpers.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.73",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/core": "0.8.
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/player": "0.8.
|
|
58
|
-
"@hyperframes/studio-server": "0.8.
|
|
59
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/core": "0.8.73",
|
|
56
|
+
"@hyperframes/sdk": "0.8.73",
|
|
57
|
+
"@hyperframes/player": "0.8.73",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.73",
|
|
59
|
+
"@hyperframes/parsers": "0.8.73"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"vite": "^6.4.2",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
74
|
"zustand": "^5.0.0",
|
|
75
|
-
"@hyperframes/producer": "0.8.
|
|
75
|
+
"@hyperframes/producer": "0.8.73"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"react": "19",
|
|
@@ -80,6 +80,10 @@ function renderPreview(
|
|
|
80
80
|
previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [
|
|
81
81
|
{ gen: 0, role: "live" },
|
|
82
82
|
],
|
|
83
|
+
{
|
|
84
|
+
box = { width: 800, height: 600 },
|
|
85
|
+
fillBox,
|
|
86
|
+
}: { box?: { width: number; height: number }; fillBox?: boolean } = {},
|
|
83
87
|
) {
|
|
84
88
|
resizeCallbacks = [];
|
|
85
89
|
const host = document.createElement("div");
|
|
@@ -99,6 +103,7 @@ function renderPreview(
|
|
|
99
103
|
onShadowError: () => {},
|
|
100
104
|
setShadowIframeNode: () => {},
|
|
101
105
|
resetPreviewSlots: () => {},
|
|
106
|
+
fillBox,
|
|
102
107
|
}),
|
|
103
108
|
);
|
|
104
109
|
});
|
|
@@ -108,7 +113,7 @@ function renderPreview(
|
|
|
108
113
|
expect(viewport).toBeTruthy();
|
|
109
114
|
expect(stage).toBeTruthy();
|
|
110
115
|
|
|
111
|
-
setRect(viewport,
|
|
116
|
+
setRect(viewport, box);
|
|
112
117
|
act(() => {
|
|
113
118
|
for (const fire of resizeCallbacks) fire();
|
|
114
119
|
});
|
|
@@ -251,6 +256,19 @@ describe("NLEPreview", () => {
|
|
|
251
256
|
view.cleanup();
|
|
252
257
|
});
|
|
253
258
|
|
|
259
|
+
it("insets the picture by default and fills a same-shape box when fillBox is on", () => {
|
|
260
|
+
const box = { width: 640, height: 360 };
|
|
261
|
+
const inset = renderPreview(undefined, { box });
|
|
262
|
+
expect([inset.stage.style.width, inset.stage.style.height]).toEqual(["611.5556px", "344px"]);
|
|
263
|
+
expect(parseFloat(inset.stage.parentElement!.style.inset)).toBe(8);
|
|
264
|
+
inset.cleanup();
|
|
265
|
+
|
|
266
|
+
const filled = renderPreview(undefined, { box, fillBox: true });
|
|
267
|
+
expect([filled.stage.style.width, filled.stage.style.height]).toEqual(["640px", "360px"]);
|
|
268
|
+
expect(parseFloat(filled.stage.parentElement!.style.inset)).toBe(0);
|
|
269
|
+
filled.cleanup();
|
|
270
|
+
});
|
|
271
|
+
|
|
254
272
|
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
|
|
255
273
|
const view = renderPreview([
|
|
256
274
|
{ gen: 0, role: "live" },
|
|
@@ -36,6 +36,8 @@ interface NLEPreviewProps {
|
|
|
36
36
|
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
|
|
37
37
|
/** Reports the authored composition size measured from the loaded preview. */
|
|
38
38
|
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
39
|
+
/** Draws the picture edge to edge in this box, without Studio's inset band. */
|
|
40
|
+
fillBox?: boolean;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function getPreviewPlayerKey({
|
|
@@ -50,7 +52,7 @@ export function getPreviewPlayerKey({
|
|
|
50
52
|
|
|
51
53
|
const ZOOM_HUD_TIMEOUT_MS = 1200;
|
|
52
54
|
const ZOOM_SETTLE_MS = 200;
|
|
53
|
-
const PREVIEW_STAGE_INSET_PX =
|
|
55
|
+
const PREVIEW_STAGE_INSET_PX = 8;
|
|
54
56
|
|
|
55
57
|
// clip-path as well as visibility: the player's loading overlay sets its own
|
|
56
58
|
// visibility:visible and would otherwise paint over the live frame.
|
|
@@ -87,9 +89,10 @@ export function resolvePreviewStageSize(
|
|
|
87
89
|
compositionSize: PreviewCompositionSize | null,
|
|
88
90
|
portrait: boolean | undefined,
|
|
89
91
|
gutterPx = 0,
|
|
92
|
+
insetPx = PREVIEW_STAGE_INSET_PX,
|
|
90
93
|
): { width: number; height: number } {
|
|
91
|
-
const availableWidth = Math.max(0, viewportWidth -
|
|
92
|
-
const availableHeight = Math.max(0, viewportHeight -
|
|
94
|
+
const availableWidth = Math.max(0, viewportWidth - 2 * (insetPx + gutterPx));
|
|
95
|
+
const availableHeight = Math.max(0, viewportHeight - 2 * (insetPx + gutterPx));
|
|
93
96
|
const aspectRatio =
|
|
94
97
|
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
|
|
95
98
|
? compositionSize.width / compositionSize.height
|
|
@@ -130,6 +133,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
130
133
|
suppressLoadingOverlay,
|
|
131
134
|
onStageRef,
|
|
132
135
|
onCompositionSizeChange,
|
|
136
|
+
fillBox,
|
|
133
137
|
}: NLEPreviewProps) {
|
|
134
138
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
135
139
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -151,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
151
155
|
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
|
|
152
156
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
153
157
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
158
|
+
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
154
159
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
155
160
|
|
|
156
161
|
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
@@ -181,7 +186,14 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
181
186
|
const updateStageSize = () => {
|
|
182
187
|
const rect = viewport.getBoundingClientRect();
|
|
183
188
|
setStageSize(
|
|
184
|
-
resolvePreviewStageSize(
|
|
189
|
+
resolvePreviewStageSize(
|
|
190
|
+
rect.width,
|
|
191
|
+
rect.height,
|
|
192
|
+
compositionSize,
|
|
193
|
+
portrait,
|
|
194
|
+
gutterPx,
|
|
195
|
+
insetPx,
|
|
196
|
+
),
|
|
185
197
|
);
|
|
186
198
|
};
|
|
187
199
|
|
|
@@ -189,7 +201,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
189
201
|
const observer = new ResizeObserver(updateStageSize);
|
|
190
202
|
observer.observe(viewport);
|
|
191
203
|
return () => observer.disconnect();
|
|
192
|
-
}, [compositionSize, portrait, gutterPx]);
|
|
204
|
+
}, [compositionSize, portrait, gutterPx, insetPx]);
|
|
193
205
|
|
|
194
206
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
195
207
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
@@ -467,11 +479,14 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
467
479
|
<div className="flex flex-col h-full min-h-0">
|
|
468
480
|
<div
|
|
469
481
|
ref={viewportRef}
|
|
470
|
-
className="relative flex-1 flex items-center justify-center
|
|
482
|
+
className="relative flex-1 flex items-center justify-center overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
|
|
471
483
|
tabIndex={0}
|
|
472
484
|
aria-label="Composition preview"
|
|
473
485
|
>
|
|
474
|
-
<div
|
|
486
|
+
<div
|
|
487
|
+
className="absolute flex items-center justify-center pointer-events-none"
|
|
488
|
+
style={{ inset: insetPx }}
|
|
489
|
+
>
|
|
475
490
|
<div
|
|
476
491
|
ref={stageRef}
|
|
477
492
|
className="relative shrink-0 pointer-events-auto"
|
|
@@ -8,6 +8,7 @@ import { isEditableTarget } from "../utils/timelineDiscovery";
|
|
|
8
8
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
10
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
|
+
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
11
12
|
|
|
12
13
|
// Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
|
|
13
14
|
// following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
|
|
@@ -158,7 +159,7 @@ export function dispatchModifierKey(
|
|
|
158
159
|
* Delete arbitration between keyframes, an automation range and the clip can
|
|
159
160
|
* be asserted without standing up the whole hook. */
|
|
160
161
|
export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
|
|
161
|
-
if (key ===
|
|
162
|
+
if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
|
|
162
163
|
event.preventDefault();
|
|
163
164
|
if (document.fullscreenElement) void document.exitFullscreen();
|
|
164
165
|
else
|
|
@@ -166,7 +167,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
if (event.key ===
|
|
170
|
+
if (event.key === STUDIO_PLAIN_KEYS.split && !event.altKey) {
|
|
170
171
|
// Reserve bare `s` for Split even when the current selection cannot split,
|
|
171
172
|
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
|
172
173
|
event.preventDefault();
|
|
@@ -274,7 +275,12 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
274
275
|
return;
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
if (
|
|
278
|
+
if (
|
|
279
|
+
event.key === STUDIO_PLAIN_KEYS.record &&
|
|
280
|
+
!event.shiftKey &&
|
|
281
|
+
!event.altKey &&
|
|
282
|
+
cb.onToggleRecording
|
|
283
|
+
) {
|
|
278
284
|
event.preventDefault();
|
|
279
285
|
cb.onToggleRecording();
|
|
280
286
|
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ export type { ToggleProps } from "./components/ui/Toggle";
|
|
|
35
35
|
export { EditorShell } from "./components/EditorShell";
|
|
36
36
|
export type { EditorShellProps } from "./components/EditorShell";
|
|
37
37
|
export { NLEPreview } from "./components/nle/NLEPreview";
|
|
38
|
+
export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
|
|
39
|
+
export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
|
|
38
40
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
39
41
|
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
40
42
|
|
|
@@ -7,6 +7,7 @@ import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
9
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
10
|
+
import type { ShortcutSection } from "./studioShortcuts";
|
|
10
11
|
import { SpeedMenu } from "./SpeedMenu";
|
|
11
12
|
import { VolumeControl } from "./VolumeControl";
|
|
12
13
|
|
|
@@ -161,6 +162,8 @@ interface PlayerControlsProps {
|
|
|
161
162
|
disabled?: boolean;
|
|
162
163
|
isFullscreen?: boolean;
|
|
163
164
|
onToggleFullscreen?: () => void;
|
|
165
|
+
/** Replaces the shortcuts panel's list, e.g. DEFAULT_SHORTCUT_SECTIONS with entries dropped or relabelled. */
|
|
166
|
+
shortcutSections?: readonly ShortcutSection[];
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
export const PlayerControls = memo(function PlayerControls({
|
|
@@ -169,6 +172,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
169
172
|
disabled = false,
|
|
170
173
|
isFullscreen = false,
|
|
171
174
|
onToggleFullscreen,
|
|
175
|
+
shortcutSections,
|
|
172
176
|
}: PlayerControlsProps) {
|
|
173
177
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
174
178
|
const duration = usePlayerStore((s) => s.duration);
|
|
@@ -291,6 +295,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
291
295
|
setInPoint={setInPoint}
|
|
292
296
|
setOutPoint={setOutPoint}
|
|
293
297
|
onSeek={onSeek}
|
|
298
|
+
sections={shortcutSections}
|
|
294
299
|
/>
|
|
295
300
|
</div>
|
|
296
301
|
</div>
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import React, { act, Profiler } from "react";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
6
|
+
import { PlayerControls } from "./PlayerControls";
|
|
7
|
+
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
6
8
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
7
9
|
|
|
8
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -149,4 +151,53 @@ describe("ShortcutsPanel", () => {
|
|
|
149
151
|
expect(panel?.getAttribute("aria-modal")).toBeNull();
|
|
150
152
|
expect(panel?.id).toBe(panelId);
|
|
151
153
|
});
|
|
154
|
+
|
|
155
|
+
it("lists what an embedder passes to PlayerControls instead of Studio's defaults", () => {
|
|
156
|
+
const listed = (sections?: readonly ShortcutSection[]) => {
|
|
157
|
+
const host = document.createElement("div");
|
|
158
|
+
document.body.append(host);
|
|
159
|
+
act(() => {
|
|
160
|
+
mount(host).render(
|
|
161
|
+
<PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
|
|
165
|
+
return host.textContent ?? "";
|
|
166
|
+
};
|
|
167
|
+
const withoutSplit = DEFAULT_SHORTCUT_SECTIONS.map((section) => ({
|
|
168
|
+
...section,
|
|
169
|
+
hints: section.hints.filter((hint) => hint.label !== "Split clip at playhead"),
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
expect(listed()).toContain("Split clip at playhead");
|
|
173
|
+
expect(listed(withoutSplit)).not.toContain("Split clip at playhead");
|
|
174
|
+
expect(listed(withoutSplit)).toContain("Toggle fullscreen");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("renders an embedder list that repeats a key or a section title without key clashes", () => {
|
|
178
|
+
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
179
|
+
const host = document.createElement("div");
|
|
180
|
+
document.body.append(host);
|
|
181
|
+
const sections = [
|
|
182
|
+
{
|
|
183
|
+
title: "Keys",
|
|
184
|
+
hints: [
|
|
185
|
+
{ key: "S", label: "Select" },
|
|
186
|
+
{ key: "S", label: "Snap" },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{ title: "Keys", hints: [{ key: "V", label: "Move" }] },
|
|
190
|
+
];
|
|
191
|
+
act(() => {
|
|
192
|
+
mount(host).render(
|
|
193
|
+
<PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
|
|
197
|
+
|
|
198
|
+
expect(host.textContent).toContain("Select");
|
|
199
|
+
expect(host.textContent).toContain("Snap");
|
|
200
|
+
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
|
|
201
|
+
errors.mockRestore();
|
|
202
|
+
});
|
|
152
203
|
});
|
|
@@ -2,92 +2,7 @@ import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
|
|
|
2
2
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
3
3
|
import { Tooltip } from "../../components/ui";
|
|
4
4
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
5
|
-
|
|
6
|
-
const SHORTCUT_SECTIONS = [
|
|
7
|
-
{
|
|
8
|
-
title: "Playback",
|
|
9
|
-
hints: [
|
|
10
|
-
{ key: "Space", label: "Play / Pause" },
|
|
11
|
-
{ key: "J", label: "Play backward" },
|
|
12
|
-
{ key: "K", label: "Stop" },
|
|
13
|
-
{ key: "L", label: "Play forward" },
|
|
14
|
-
{ key: "M", label: "Toggle mute" },
|
|
15
|
-
{ key: "⇧L", label: "Toggle loop" },
|
|
16
|
-
{ key: "←/→", label: "Step 1 frame" },
|
|
17
|
-
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
18
|
-
{ key: "F", label: "Toggle fullscreen" },
|
|
19
|
-
],
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
title: "Keyframes (when an element is selected)",
|
|
23
|
-
hints: [
|
|
24
|
-
{ key: "K", label: "Add keyframe at playhead" },
|
|
25
|
-
{ key: "Del", label: "Delete selected keyframe" },
|
|
26
|
-
{ key: "H", label: "Toggle hold / bezier" },
|
|
27
|
-
{ key: "U", label: "Expand / collapse properties" },
|
|
28
|
-
{ key: "R", label: "Record gesture" },
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
title: "Editing",
|
|
33
|
-
hints: [
|
|
34
|
-
{ key: "⌘Z", label: "Undo" },
|
|
35
|
-
{ key: "⌘⇧Z", label: "Redo" },
|
|
36
|
-
{ key: "⌘C", label: "Copy element" },
|
|
37
|
-
{ key: "⌘V", label: "Paste element" },
|
|
38
|
-
{ key: "⌘X", label: "Cut element" },
|
|
39
|
-
{ key: "S", label: "Split clip at playhead" },
|
|
40
|
-
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
41
|
-
{ key: "⌘G", label: "Group elements" },
|
|
42
|
-
{ key: "⌘⇧G", label: "Ungroup" },
|
|
43
|
-
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
title: "Gesture recording modifiers",
|
|
48
|
-
hints: [
|
|
49
|
-
{ key: "Drag", label: "Record x / y position" },
|
|
50
|
-
{ key: "Scroll", label: "Record z depth" },
|
|
51
|
-
{ key: "⇧ Drag", label: "Record rotationX / rotationY" },
|
|
52
|
-
{ key: "⌥ Drag", label: "Record rotation" },
|
|
53
|
-
{ key: "⌘ Drag↕", label: "Record opacity" },
|
|
54
|
-
{ key: "⌘ Scroll", label: "Record scale" },
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
title: "Canvas",
|
|
59
|
-
hints: [
|
|
60
|
-
{ key: "Drag", label: "Move element / add keyframe" },
|
|
61
|
-
{ key: "⌥ Drag", label: "Move entire animation path" },
|
|
62
|
-
{ key: "⇧ Drag", label: "Uniform resize" },
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
title: "Crop",
|
|
67
|
-
hints: [
|
|
68
|
-
{ key: "Drag edge", label: "Crop a side" },
|
|
69
|
-
{ key: "Drag center", label: "Reposition the crop" },
|
|
70
|
-
],
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
title: "Panels",
|
|
74
|
-
hints: [
|
|
75
|
-
{ key: "⌘1", label: "Compositions tab" },
|
|
76
|
-
{ key: "⌘2", label: "Assets tab" },
|
|
77
|
-
],
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
title: "Work area",
|
|
81
|
-
hints: [
|
|
82
|
-
{ key: "I", label: "Set in-point" },
|
|
83
|
-
{ key: "⇧I", label: "Clear in-point" },
|
|
84
|
-
{ key: "O", label: "Set out-point" },
|
|
85
|
-
{ key: "⇧O", label: "Clear out-point" },
|
|
86
|
-
{ key: "A", label: "Jump to in-point" },
|
|
87
|
-
{ key: "E", label: "Jump to out-point" },
|
|
88
|
-
],
|
|
89
|
-
},
|
|
90
|
-
] as const;
|
|
5
|
+
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
91
6
|
|
|
92
7
|
interface ShortcutsPanelProps {
|
|
93
8
|
disabled: boolean;
|
|
@@ -97,6 +12,7 @@ interface ShortcutsPanelProps {
|
|
|
97
12
|
setInPoint: (v: number | null) => void;
|
|
98
13
|
setOutPoint: (v: number | null) => void;
|
|
99
14
|
onSeek: (time: number) => void;
|
|
15
|
+
sections?: readonly ShortcutSection[];
|
|
100
16
|
}
|
|
101
17
|
|
|
102
18
|
export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
@@ -107,6 +23,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
107
23
|
setInPoint,
|
|
108
24
|
setOutPoint,
|
|
109
25
|
onSeek,
|
|
26
|
+
sections = DEFAULT_SHORTCUT_SECTIONS,
|
|
110
27
|
}: ShortcutsPanelProps) {
|
|
111
28
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
112
29
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
@@ -319,14 +236,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
319
236
|
</div>
|
|
320
237
|
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
321
238
|
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
|
|
322
|
-
{
|
|
323
|
-
<div key={
|
|
239
|
+
{sections.map((section, sectionIndex) => (
|
|
240
|
+
<div key={sectionIndex}>
|
|
324
241
|
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
325
242
|
{section.title}
|
|
326
243
|
</p>
|
|
327
244
|
<div className="flex flex-col gap-1">
|
|
328
|
-
{section.hints.map((hint) => (
|
|
329
|
-
<div key={
|
|
245
|
+
{section.hints.map((hint, hintIndex) => (
|
|
246
|
+
<div key={hintIndex} className="flex items-center gap-3">
|
|
330
247
|
<span
|
|
331
248
|
className="font-mono text-[10px] rounded-sm border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
|
|
332
249
|
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Bare keys Studio's app hotkeys bind; the shortcuts list names them from here. */
|
|
2
|
+
export const STUDIO_PLAIN_KEYS = { fullscreen: "f", split: "s", record: "r" } as const;
|
|
3
|
+
|
|
4
|
+
export interface ShortcutHint {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ShortcutSection {
|
|
10
|
+
title: string;
|
|
11
|
+
hints: readonly ShortcutHint[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const hintKey = (key: string) => key.toUpperCase();
|
|
15
|
+
|
|
16
|
+
/** What PlayerControls' shortcuts panel lists unless an embedder passes its own sections. */
|
|
17
|
+
export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
18
|
+
{
|
|
19
|
+
title: "Playback",
|
|
20
|
+
hints: [
|
|
21
|
+
{ key: "Space", label: "Play / Pause" },
|
|
22
|
+
{ key: "J", label: "Play backward" },
|
|
23
|
+
{ key: "K", label: "Stop" },
|
|
24
|
+
{ key: "L", label: "Play forward" },
|
|
25
|
+
{ key: "M", label: "Toggle mute" },
|
|
26
|
+
{ key: "⇧L", label: "Toggle loop" },
|
|
27
|
+
{ key: "←/→", label: "Step 1 frame" },
|
|
28
|
+
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
29
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.fullscreen), label: "Toggle fullscreen" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: "Keyframes (when an element is selected)",
|
|
34
|
+
hints: [
|
|
35
|
+
{ key: "K", label: "Add keyframe at playhead" },
|
|
36
|
+
{ key: "Del", label: "Delete selected keyframe" },
|
|
37
|
+
{ key: "H", label: "Toggle hold / bezier" },
|
|
38
|
+
{ key: "U", label: "Expand / collapse properties" },
|
|
39
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: "Editing",
|
|
44
|
+
hints: [
|
|
45
|
+
{ key: "⌘Z", label: "Undo" },
|
|
46
|
+
{ key: "⌘⇧Z", label: "Redo" },
|
|
47
|
+
{ key: "⌘C", label: "Copy element" },
|
|
48
|
+
{ key: "⌘V", label: "Paste element" },
|
|
49
|
+
{ key: "⌘X", label: "Cut element" },
|
|
50
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
|
|
51
|
+
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
52
|
+
{ key: "⌘G", label: "Group elements" },
|
|
53
|
+
{ key: "⌘⇧G", label: "Ungroup" },
|
|
54
|
+
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: "Gesture recording modifiers",
|
|
59
|
+
hints: [
|
|
60
|
+
{ key: "Drag", label: "Record x / y position" },
|
|
61
|
+
{ key: "Scroll", label: "Record z depth" },
|
|
62
|
+
{ key: "⇧ Drag", label: "Record rotationX / rotationY" },
|
|
63
|
+
{ key: "⌥ Drag", label: "Record rotation" },
|
|
64
|
+
{ key: "⌘ Drag↕", label: "Record opacity" },
|
|
65
|
+
{ key: "⌘ Scroll", label: "Record scale" },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
title: "Canvas",
|
|
70
|
+
hints: [
|
|
71
|
+
{ key: "Drag", label: "Move element / add keyframe" },
|
|
72
|
+
{ key: "⌥ Drag", label: "Move entire animation path" },
|
|
73
|
+
{ key: "⇧ Drag", label: "Uniform resize" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
title: "Crop",
|
|
78
|
+
hints: [
|
|
79
|
+
{ key: "Drag edge", label: "Crop a side" },
|
|
80
|
+
{ key: "Drag center", label: "Reposition the crop" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
title: "Panels",
|
|
85
|
+
hints: [
|
|
86
|
+
{ key: "⌘1", label: "Compositions tab" },
|
|
87
|
+
{ key: "⌘2", label: "Assets tab" },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
title: "Work area",
|
|
92
|
+
hints: [
|
|
93
|
+
{ key: "I", label: "Set in-point" },
|
|
94
|
+
{ key: "⇧I", label: "Clear in-point" },
|
|
95
|
+
{ key: "O", label: "Set out-point" },
|
|
96
|
+
{ key: "⇧O", label: "Clear out-point" },
|
|
97
|
+
{ key: "A", label: "Jump to in-point" },
|
|
98
|
+
{ key: "E", label: "Jump to out-point" },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
|
+
|
|
10
|
+
function scrollBox(scrollLeft: number) {
|
|
11
|
+
const el = document.createElement("div");
|
|
12
|
+
let left = scrollLeft;
|
|
13
|
+
Object.defineProperties(el, {
|
|
14
|
+
clientWidth: { value: 800 },
|
|
15
|
+
scrollWidth: { value: 20_000 },
|
|
16
|
+
scrollLeft: { get: () => left, set: (v: number) => (left = v) },
|
|
17
|
+
});
|
|
18
|
+
return el;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
|
|
22
|
+
useTimelinePlayhead({
|
|
23
|
+
playheadRef: createRef(),
|
|
24
|
+
scrollRef: { current: scroll },
|
|
25
|
+
ppsRef: { current: pps },
|
|
26
|
+
durationRef: { current: 60 },
|
|
27
|
+
isDragging: { current: false },
|
|
28
|
+
currentTime: 0,
|
|
29
|
+
zoomMode: "manual",
|
|
30
|
+
manualZoomPercent: 100,
|
|
31
|
+
zoomModeRef: { current: "manual" },
|
|
32
|
+
manualZoomPercentRef: { current: 100 },
|
|
33
|
+
fitPps: pps,
|
|
34
|
+
fitPpsRef: { current: pps },
|
|
35
|
+
effectiveDuration: 60,
|
|
36
|
+
pps,
|
|
37
|
+
timelineReady: true,
|
|
38
|
+
elementsLength: 1,
|
|
39
|
+
setZoomMode: () => {},
|
|
40
|
+
setManualZoomPercent: () => {},
|
|
41
|
+
contentOrigin: 32,
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function zoom(scrollLeft: number, fromPps: number, toPps: number) {
|
|
47
|
+
const scroll = scrollBox(scrollLeft);
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
const root = createRoot(host);
|
|
50
|
+
act(() => root.render(<Harness pps={fromPps} scroll={scroll} />));
|
|
51
|
+
act(() => root.render(<Harness pps={toPps} scroll={scroll} />));
|
|
52
|
+
act(() => root.unmount());
|
|
53
|
+
return scroll.scrollLeft;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("useTimelinePlayhead centre anchor", () => {
|
|
57
|
+
it("keeps a view at the start at the start when the scale changes", () => {
|
|
58
|
+
expect(zoom(0, 100, 114)).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the time at the viewport centre when the view is scrolled", () => {
|
|
62
|
+
// Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400.
|
|
63
|
+
expect(zoom(400, 100, 200)).toBe(1168);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -75,7 +75,8 @@ export function useTimelinePlayhead({
|
|
|
75
75
|
// it true and the next toolbar zoom would wrongly skip center-anchoring.
|
|
76
76
|
const skip = skipCenterAnchorRef.current;
|
|
77
77
|
skipCenterAnchorRef.current = false;
|
|
78
|
-
|
|
78
|
+
// A view at the start stays there: a resize or a host zoom must not hide 00:00.
|
|
79
|
+
if (!scroll || pps === prevPps || skip || scroll.scrollLeft < 1) return;
|
|
79
80
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
80
81
|
pointerX: scroll.clientWidth / 2,
|
|
81
82
|
currentScrollLeft: scroll.scrollLeft,
|
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
19
19
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
20
20
|
// It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
|
|
21
21
|
export const SHADOW_READY_TIMEOUT_MS = 15_000;
|
|
22
|
+
// A busy machine can need more than one budget; the shadow keeps loading for this many before it is dropped.
|
|
23
|
+
export const SHADOW_READY_BUDGETS = 3;
|
|
22
24
|
|
|
23
25
|
function isDocumentHidden(): boolean {
|
|
24
26
|
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
@@ -61,6 +63,7 @@ export function useShadowPreviewReload({
|
|
|
61
63
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
62
64
|
onReloadFailedRef.current = onReloadFailed;
|
|
63
65
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
|
+
const budgetsSpentRef = useRef(0);
|
|
64
67
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
65
68
|
const budgetGenRef = useRef<number | null>(null);
|
|
66
69
|
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
|
@@ -160,10 +163,16 @@ export function useShadowPreviewReload({
|
|
|
160
163
|
(gen: number) => {
|
|
161
164
|
clearTimeout(readyTimerRef.current);
|
|
162
165
|
if (isDocumentHidden()) return;
|
|
163
|
-
readyTimerRef.current = setTimeout(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
readyTimerRef.current = setTimeout(() => {
|
|
167
|
+
if (gen !== shadowGenRef.current) return;
|
|
168
|
+
budgetsSpentRef.current += 1;
|
|
169
|
+
if (budgetsSpentRef.current < SHADOW_READY_BUDGETS) {
|
|
170
|
+
logReload("shadow-slow", { budgetsSpent: budgetsSpentRef.current });
|
|
171
|
+
armReadyTimerRef.current(gen);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
failShadow(gen, "it took too long to load");
|
|
175
|
+
}, SHADOW_READY_TIMEOUT_MS);
|
|
167
176
|
},
|
|
168
177
|
[failShadow],
|
|
169
178
|
);
|
|
@@ -176,6 +185,7 @@ export function useShadowPreviewReload({
|
|
|
176
185
|
const gen = shadowGenRef.current;
|
|
177
186
|
stopPendingShadow();
|
|
178
187
|
budgetGenRef.current = gen;
|
|
188
|
+
budgetsSpentRef.current = 0;
|
|
179
189
|
armReadyTimer(gen);
|
|
180
190
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
181
191
|
// Thumbnails of the edit wait for the new preview instead of competing with it.
|