@hyperframes/studio 0.8.83 → 0.8.85
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-BAerMCex.js → hyperframes-player-BK18SggX.js} +3 -3
- package/dist/assets/{index-BUhcVhNy.js → index-Bus1BKtk.js} +1 -1
- package/dist/assets/{index-Iv0HI7eM.js → index-CZxhjQpw.js} +1 -1
- package/dist/assets/{index-BYhK_KMc.js → index-ClLdmgua.js} +153 -153
- package/dist/assets/{index-BWJqxeaH.css → index-mzZCha_N.css} +1 -1
- package/dist/{chunk-LPZMAGS7.js → chunk-7VDQIGOU.js} +7 -7
- package/dist/chunk-7VDQIGOU.js.map +1 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.html +2 -2
- package/dist/index.js +34 -20
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/previewReadOnly.overlay.test.tsx +16 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +5 -5
- package/src/compositionStackExport.test.ts +7 -0
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -0
- package/src/hooks/useInlineTextEdit.test.tsx +39 -0
- package/src/hooks/useInlineTextEdit.ts +5 -9
- package/src/index.ts +1 -0
- package/src/player/components/TimelineLanes.tsx +2 -3
- package/src/player/components/timelineKeyboardNavigation.ts +2 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +24 -1
- package/src/player/components/useTimelineKeyboardActor.ts +10 -2
- package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +41 -7
- package/src/player/lib/timelineElementHelpers.ts +7 -7
- package/dist/chunk-LPZMAGS7.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.85",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/player": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/studio-server": "0.8.
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.85",
|
|
64
|
+
"@hyperframes/player": "0.8.85",
|
|
65
|
+
"@hyperframes/parsers": "0.8.85",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.85",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.85",
|
|
68
|
+
"@hyperframes/sdk": "0.8.85"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.85"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -20,6 +20,11 @@ const layout = vi.hoisted(() => ({
|
|
|
20
20
|
offCanvasElements: new Map<string, HTMLElement>(),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
const actions = vi.hoisted(() => ({ current: null as unknown }));
|
|
24
|
+
vi.mock("../../contexts/DomEditContext", async (importOriginal) => ({
|
|
25
|
+
...(await importOriginal<typeof import("../../contexts/DomEditContext")>()),
|
|
26
|
+
useDomEditActionsContextOptional: () => actions.current,
|
|
27
|
+
}));
|
|
23
28
|
vi.mock("./useOffCanvasIndicators", () => ({
|
|
24
29
|
useOffCanvasIndicators: () => ({
|
|
25
30
|
offCanvasRects: layout.offCanvas,
|
|
@@ -141,6 +146,7 @@ beforeEach(() => {
|
|
|
141
146
|
|
|
142
147
|
afterEach(() => {
|
|
143
148
|
act(() => root.unmount());
|
|
149
|
+
actions.current = null;
|
|
144
150
|
document.body.innerHTML = "";
|
|
145
151
|
layout.group = [];
|
|
146
152
|
layout.hover = null;
|
|
@@ -454,10 +460,18 @@ describe("DomEditOverlay onTextEditingChange", () => {
|
|
|
454
460
|
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
455
461
|
});
|
|
456
462
|
|
|
457
|
-
it("reports false when the overlay unmounts mid-edit", () => {
|
|
458
|
-
const
|
|
463
|
+
it("saves the typed words and reports false when the overlay unmounts mid-edit", () => {
|
|
464
|
+
const handleDomRichTextCommit = vi.fn();
|
|
465
|
+
actions.current = { handleDomRichTextCommit };
|
|
466
|
+
const { spies, selection, overlay } = fixture();
|
|
459
467
|
pressEnter(overlay);
|
|
468
|
+
selection.element.textContent = "Typed";
|
|
460
469
|
act(() => root.unmount());
|
|
470
|
+
expect(handleDomRichTextCommit).toHaveBeenCalledWith({
|
|
471
|
+
element: selection.element,
|
|
472
|
+
html: "Typed",
|
|
473
|
+
previousHtml: "Title",
|
|
474
|
+
});
|
|
461
475
|
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
462
476
|
});
|
|
463
477
|
});
|
|
@@ -21,7 +21,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
21
21
|
return (
|
|
22
22
|
<nav
|
|
23
23
|
aria-label="Composition navigation"
|
|
24
|
-
className="flex items-center gap-1 px-2 h-8 border-b border-
|
|
24
|
+
className="flex items-center gap-1 px-2 h-8 border-b border-hairline bg-bg-1 shrink-0"
|
|
25
25
|
>
|
|
26
26
|
{/* Back button — always goes to parent */}
|
|
27
27
|
<button
|
|
@@ -33,7 +33,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
33
33
|
});
|
|
34
34
|
onNavigate(stack.length - 2);
|
|
35
35
|
}}
|
|
36
|
-
className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-
|
|
36
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-text-3 hover:text-text-0 hover:bg-hover active:scale-[0.98] transition-colors"
|
|
37
37
|
title="Back (Esc, or double-click empty timeline)"
|
|
38
38
|
aria-label="Back to parent composition"
|
|
39
39
|
>
|
|
@@ -45,9 +45,9 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
45
45
|
const isLast = i === stack.length - 1;
|
|
46
46
|
return (
|
|
47
47
|
<span key={level.id} className="flex items-center gap-1">
|
|
48
|
-
{i > 0 && <CaretRight size={10} className="text-
|
|
48
|
+
{i > 0 && <CaretRight size={10} className="text-text-4 shrink-0" />}
|
|
49
49
|
{isLast ? (
|
|
50
|
-
<span className="text-xs text-
|
|
50
|
+
<span className="text-xs text-text-1 font-medium">{level.label}</span>
|
|
51
51
|
) : (
|
|
52
52
|
<button
|
|
53
53
|
type="button"
|
|
@@ -55,7 +55,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
55
55
|
trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
|
|
56
56
|
onNavigate(i);
|
|
57
57
|
}}
|
|
58
|
-
className="text-xs text-
|
|
58
|
+
className="text-xs text-text-3 hover:text-text-1 transition-colors"
|
|
59
59
|
>
|
|
60
60
|
{level.label}
|
|
61
61
|
</button>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { CompositionBreadcrumb, useCompositionStack } from "@hyperframes/studio";
|
|
3
|
+
|
|
4
|
+
it("exports the composition stack hook beside the breadcrumb it feeds", () => {
|
|
5
|
+
expect(typeof useCompositionStack).toBe("function");
|
|
6
|
+
expect(typeof CompositionBreadcrumb).toBe("function");
|
|
7
|
+
});
|
|
@@ -98,6 +98,7 @@ function commitParams(
|
|
|
98
98
|
buildDomSelectionFromTarget: vi.fn(async () => null),
|
|
99
99
|
persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
|
|
100
100
|
resolveImportedFontAsset: () => null,
|
|
101
|
+
readOnlyPreview: false,
|
|
101
102
|
...overrides,
|
|
102
103
|
};
|
|
103
104
|
}
|
|
@@ -124,6 +125,41 @@ afterEach(() => {
|
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
describe("useDomEditTextCommits", () => {
|
|
128
|
+
function richTextProbe() {
|
|
129
|
+
const { iframe, element } = previewElement('<h1 id="t">Old</h1>', "t");
|
|
130
|
+
const persist = vi.fn().mockResolvedValue(undefined);
|
|
131
|
+
const base = commitParams({
|
|
132
|
+
previewIframeRef: { current: iframe },
|
|
133
|
+
domEditSelection: selectionFor(element),
|
|
134
|
+
persistDomEditOperations: persist,
|
|
135
|
+
});
|
|
136
|
+
const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
|
|
137
|
+
function Probe({ readOnlyPreview }: { readOnlyPreview: boolean }) {
|
|
138
|
+
captured.hook = useDomEditTextCommits({ ...base, readOnlyPreview });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const root = mountReactHarness(<Probe readOnlyPreview={false} />);
|
|
142
|
+
cleanup = () => act(() => root.unmount());
|
|
143
|
+
const save = captured.hook!.handleDomRichTextCommit;
|
|
144
|
+
const commit = { element, html: "New", previousHtml: "Old" };
|
|
145
|
+
element.innerHTML = "New";
|
|
146
|
+
return { root, Probe, persist, element, save: () => act(async () => save(commit)) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it("saves in-place text while the preview is editable", async () => {
|
|
150
|
+
const { persist, save } = richTextProbe();
|
|
151
|
+
await save();
|
|
152
|
+
expect(persist).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("refuses in-place text once the preview turns read-only, through an earlier handler, and puts the old text back", async () => {
|
|
156
|
+
const { root, Probe, persist, element, save } = richTextProbe();
|
|
157
|
+
act(() => root.render(<Probe readOnlyPreview />));
|
|
158
|
+
await save();
|
|
159
|
+
expect(persist).not.toHaveBeenCalled();
|
|
160
|
+
expect(element.innerHTML).toBe("Old");
|
|
161
|
+
});
|
|
162
|
+
|
|
127
163
|
it("keeps concurrent text commit ownership isolated by target", async () => {
|
|
128
164
|
const { iframe, element: firstElement } = previewElement(
|
|
129
165
|
"<div id='first'>First</div><div id='second'>Second</div>",
|
|
@@ -53,6 +53,7 @@ export interface UseDomEditTextCommitsParams {
|
|
|
53
53
|
) => Promise<DomEditSelection | null>;
|
|
54
54
|
persistDomEditOperations: PersistDomEditOperations;
|
|
55
55
|
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
|
|
56
|
+
readOnlyPreview: boolean;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function canCommitInlineTextSelection(selection: DomEditSelection, element: HTMLElement): boolean {
|
|
@@ -114,7 +115,10 @@ export function useDomEditTextCommits({
|
|
|
114
115
|
buildDomSelectionFromTarget,
|
|
115
116
|
persistDomEditOperations,
|
|
116
117
|
resolveImportedFontAsset,
|
|
118
|
+
readOnlyPreview,
|
|
117
119
|
}: UseDomEditTextCommitsParams) {
|
|
120
|
+
const latestReadOnlyPreviewRef = useRef(readOnlyPreview);
|
|
121
|
+
latestReadOnlyPreviewRef.current = readOnlyPreview;
|
|
118
122
|
const domTextCommitVersionRef = useRef(new Map<string, symbol>());
|
|
119
123
|
const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
|
|
120
124
|
|
|
@@ -319,6 +323,10 @@ export function useDomEditTextCommits({
|
|
|
319
323
|
const handleDomRichTextCommit = useCallback(
|
|
320
324
|
async ({ element, html, previousHtml }: InlineTextEditCommit) => {
|
|
321
325
|
if (!domEditSelection) return;
|
|
326
|
+
if (latestReadOnlyPreviewRef.current) {
|
|
327
|
+
if (element.isConnected && element.innerHTML === html) element.innerHTML = previousHtml;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
322
330
|
// The same gate that let the edit open, not the design panel's.
|
|
323
331
|
//
|
|
324
332
|
// The panel's rule is about its text fields, and it has none for an
|
|
@@ -170,6 +170,45 @@ describe("useInlineTextEdit", () => {
|
|
|
170
170
|
expect(element.hasAttribute("contenteditable")).toBe(false);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
it("saves an open edit when the editor unmounts", () => {
|
|
174
|
+
const element = heading();
|
|
175
|
+
const { controls, root, onCommit } = mount();
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
controls().start(element);
|
|
179
|
+
});
|
|
180
|
+
element.textContent = "Motion Playground Live";
|
|
181
|
+
act(() => root.unmount());
|
|
182
|
+
|
|
183
|
+
expect(onCommit.mock.calls).toEqual([
|
|
184
|
+
[{ element, html: "Motion Playground Live", previousHtml: "Motion Playground" }],
|
|
185
|
+
]);
|
|
186
|
+
expect(element.hasAttribute("contenteditable")).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("keeps the edit open when the commit function changes", () => {
|
|
190
|
+
const element = heading();
|
|
191
|
+
const controls: { current: InlineTextEditControls | null } = { current: null };
|
|
192
|
+
function Probe({ onCommit }: { onCommit: () => void }) {
|
|
193
|
+
controls.current = useInlineTextEdit({ onCommit });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
197
|
+
const first = vi.fn();
|
|
198
|
+
const second = vi.fn();
|
|
199
|
+
act(() => root.render(<Probe onCommit={first} />));
|
|
200
|
+
act(() => {
|
|
201
|
+
controls.current!.start(element);
|
|
202
|
+
});
|
|
203
|
+
act(() => root.render(<Probe onCommit={second} />));
|
|
204
|
+
|
|
205
|
+
expect(controls.current!.session?.element).toBe(element);
|
|
206
|
+
expect(first).not.toHaveBeenCalled();
|
|
207
|
+
act(() => root.unmount());
|
|
208
|
+
expect(first).not.toHaveBeenCalled();
|
|
209
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
173
212
|
it("hands the current text over exactly once when it commits", () => {
|
|
174
213
|
const element = heading();
|
|
175
214
|
const { controls, root, onCommit } = mount();
|
|
@@ -245,15 +245,11 @@ export function useInlineTextEdit({
|
|
|
245
245
|
};
|
|
246
246
|
}, [session, commit, cancel]);
|
|
247
247
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
teardown();
|
|
254
|
-
},
|
|
255
|
-
[teardown],
|
|
256
|
-
);
|
|
248
|
+
// Unmount keeps the typed words, as blur does. Read through a ref so a new
|
|
249
|
+
// onCommit never closes the session mid-edit.
|
|
250
|
+
const commitRef = useRef(commit);
|
|
251
|
+
commitRef.current = commit;
|
|
252
|
+
useEffect(() => () => commitRef.current(), []);
|
|
257
253
|
|
|
258
254
|
return { session, start, commit, cancel };
|
|
259
255
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
|
|
|
9
9
|
export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
|
|
10
10
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
11
11
|
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
12
|
+
export { useCompositionStack } from "./components/nle/useCompositionStack";
|
|
12
13
|
|
|
13
14
|
// Player (preview, timeline, playback controls)
|
|
14
15
|
export {
|
|
@@ -134,9 +134,8 @@ export function TimelineLanes({
|
|
|
134
134
|
focusedTargetId,
|
|
135
135
|
rowGeometry,
|
|
136
136
|
scrollRef,
|
|
137
|
-
onToggleRow: (row) =>
|
|
138
|
-
|
|
139
|
-
},
|
|
137
|
+
onToggleRow: (row) => row.elementId && toggleClipExpandedTracked(row.elementId),
|
|
138
|
+
onDrillDown,
|
|
140
139
|
});
|
|
141
140
|
return (
|
|
142
141
|
<div
|
|
@@ -49,6 +49,7 @@ export interface TimelineLogicalItem {
|
|
|
49
49
|
elementId: string;
|
|
50
50
|
/** The item's time anchor. Clips use their midpoint; ease controls use the segment midpoint. */
|
|
51
51
|
time: number;
|
|
52
|
+
element?: TimelineElement;
|
|
52
53
|
keyframeTarget?: TimelineKeyframeTarget;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -113,6 +114,7 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
|
|
|
113
114
|
rowId,
|
|
114
115
|
elementId: id,
|
|
115
116
|
time: element.start + element.duration / 2,
|
|
117
|
+
element,
|
|
116
118
|
};
|
|
117
119
|
});
|
|
118
120
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { act, useRef } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
8
8
|
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
9
9
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
@@ -57,6 +57,7 @@ interface HarnessProps {
|
|
|
57
57
|
logicalRows?: readonly TimelineLogicalRow[];
|
|
58
58
|
rowHeights?: readonly number[];
|
|
59
59
|
onToggleRow?: (target: TimelineLogicalRow) => void;
|
|
60
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function Harness({
|
|
@@ -64,6 +65,7 @@ function Harness({
|
|
|
64
65
|
logicalRows = rows,
|
|
65
66
|
rowHeights = logicalRows.map(() => 48),
|
|
66
67
|
onToggleRow = vi.fn(),
|
|
68
|
+
onDrillDown,
|
|
67
69
|
}: HarnessProps) {
|
|
68
70
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
69
71
|
const keyboard = useTimelineKeyboardActor({
|
|
@@ -75,6 +77,7 @@ function Harness({
|
|
|
75
77
|
),
|
|
76
78
|
scrollRef,
|
|
77
79
|
onToggleRow,
|
|
80
|
+
onDrillDown,
|
|
78
81
|
});
|
|
79
82
|
return (
|
|
80
83
|
<div ref={scrollRef} onFocus={keyboard.onFocus} onKeyDown={keyboard.onKeyDown}>
|
|
@@ -228,6 +231,26 @@ describe("useTimelineKeyboardActor", () => {
|
|
|
228
231
|
act(() => root.unmount());
|
|
229
232
|
});
|
|
230
233
|
|
|
234
|
+
it("drills into a composition clip on Enter and leaves a plain clip alone", () => {
|
|
235
|
+
const scene = { id: "scene", tag: "div", start: 0, duration: 2, track: 1 };
|
|
236
|
+
const withElements = rows.map((row, index) => ({
|
|
237
|
+
...row,
|
|
238
|
+
items: row.items.map((item) => ({
|
|
239
|
+
...item,
|
|
240
|
+
element: index === 0 ? { ...scene, compositionSrc: "scene.html" } : scene,
|
|
241
|
+
})),
|
|
242
|
+
}));
|
|
243
|
+
const onDrillDown = vi.fn();
|
|
244
|
+
const { host, root } = renderHarness({ logicalRows: withElements, onDrillDown });
|
|
245
|
+
const composition = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
|
|
246
|
+
const plain = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-2"]')!;
|
|
247
|
+
expect(key(plain, "Enter").defaultPrevented).toBe(false);
|
|
248
|
+
expect(onDrillDown).not.toHaveBeenCalled();
|
|
249
|
+
expect(key(composition, "Enter").defaultPrevented).toBe(true);
|
|
250
|
+
expect(onDrillDown).toHaveBeenCalledWith(withElements[0]!.items[0]!.element);
|
|
251
|
+
act(() => root.unmount());
|
|
252
|
+
});
|
|
253
|
+
|
|
231
254
|
it("collapses expanded rows and leaves native header controls to the browser", () => {
|
|
232
255
|
const onToggleRow = vi.fn();
|
|
233
256
|
const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
|
|
2
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
4
4
|
import {
|
|
5
5
|
isTimelineNavigationKey,
|
|
@@ -14,6 +14,7 @@ interface TimelineKeyboardActorInput {
|
|
|
14
14
|
rowGeometry: TimelineRowGeometry;
|
|
15
15
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
16
16
|
onToggleRow: (target: TimelineLogicalRow) => void;
|
|
17
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
|
|
@@ -70,6 +71,7 @@ export function useTimelineKeyboardActor({
|
|
|
70
71
|
rowGeometry,
|
|
71
72
|
scrollRef,
|
|
72
73
|
onToggleRow,
|
|
74
|
+
onDrillDown,
|
|
73
75
|
}: TimelineKeyboardActorInput) {
|
|
74
76
|
const rovingTargetId =
|
|
75
77
|
(focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ??
|
|
@@ -130,6 +132,12 @@ export function useTimelineKeyboardActor({
|
|
|
130
132
|
openContextMenu(targetElement);
|
|
131
133
|
return;
|
|
132
134
|
}
|
|
135
|
+
const composition = located.target.kind === "clip" && located.target.element;
|
|
136
|
+
if (event.key === "Enter" && composition && composition.compositionSrc && onDrillDown) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
onDrillDown(composition);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
133
141
|
if (
|
|
134
142
|
(event.key !== "Enter" && event.key !== " ") ||
|
|
135
143
|
located.target.kind !== "row" ||
|
|
@@ -140,7 +148,7 @@ export function useTimelineKeyboardActor({
|
|
|
140
148
|
event.preventDefault();
|
|
141
149
|
onToggleRow(located.target);
|
|
142
150
|
},
|
|
143
|
-
[logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef],
|
|
151
|
+
[logicalRowCountByTrack, logicalRows, onDrillDown, onToggleRow, rowGeometry, scrollRef],
|
|
144
152
|
);
|
|
145
153
|
|
|
146
154
|
return { rovingTargetId, onFocus, onKeyDown };
|
|
@@ -13,6 +13,7 @@ describe("timelineElementsChanged", () => {
|
|
|
13
13
|
["audio group hidden", { audioGroupHidden: true }],
|
|
14
14
|
["fade in", { fadeIn: 1 }],
|
|
15
15
|
["fade out", { fadeOut: 1 }],
|
|
16
|
+
["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
|
|
16
17
|
])("sees a clip whose %s changed with no timing change", (_name, change) => {
|
|
17
18
|
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
|
|
18
19
|
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
MAX_PLAYBACK_RATE,
|
|
14
|
+
MIN_PLAYBACK_RATE,
|
|
15
|
+
readMediaOffsetSeconds,
|
|
16
|
+
} from "@hyperframes/parsers/media-duration";
|
|
13
17
|
|
|
14
18
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
15
19
|
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
|
|
@@ -269,19 +273,49 @@ describe("group info cache", () => {
|
|
|
269
273
|
|
|
270
274
|
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
|
271
275
|
it.each([
|
|
272
|
-
["
|
|
273
|
-
["
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
["8", 8],
|
|
277
|
+
["10", MAX_PLAYBACK_RATE],
|
|
278
|
+
["50", MAX_PLAYBACK_RATE],
|
|
279
|
+
["0.01", MIN_PLAYBACK_RATE],
|
|
280
|
+
])(
|
|
281
|
+
"clamps authored rate %s to %s, as playback does, for trim and split math",
|
|
282
|
+
(authored, expected) => {
|
|
283
|
+
const doc = makeDoc(`
|
|
276
284
|
<div data-composition-id="root">
|
|
277
285
|
<div id="nested" class="clip" data-composition-src="scene.html"
|
|
278
286
|
data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
|
|
279
287
|
</div>
|
|
280
288
|
`);
|
|
281
289
|
|
|
282
|
-
|
|
290
|
+
const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
|
|
291
|
+
|
|
292
|
+
expect(nested?.playbackRate).toBe(expected);
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
});
|
|
283
296
|
|
|
284
|
-
|
|
297
|
+
describe("parseTimelineFromDOM — head trim at an authored speed above 5x", () => {
|
|
298
|
+
it("moves the in-point by the speed playback uses", () => {
|
|
299
|
+
const doc = makeDoc(`
|
|
300
|
+
<div data-composition-id="root">
|
|
301
|
+
<video id="v" class="clip" data-start="2" data-duration="4" data-media-start="1"
|
|
302
|
+
data-playback-rate="8"></video>
|
|
303
|
+
</div>
|
|
304
|
+
`);
|
|
305
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
306
|
+
const preview = computeResizePreview(
|
|
307
|
+
{
|
|
308
|
+
element,
|
|
309
|
+
edge: "start",
|
|
310
|
+
originClientX: 0,
|
|
311
|
+
previewStart: 2,
|
|
312
|
+
previewDuration: 4,
|
|
313
|
+
started: true,
|
|
314
|
+
},
|
|
315
|
+
100,
|
|
316
|
+
{ scroll: null, pps: 100, buildSnapTargets: () => [] },
|
|
317
|
+
);
|
|
318
|
+
expect([preview.previewStart, preview.previewPlaybackStart]).toEqual([3, 9]);
|
|
285
319
|
});
|
|
286
320
|
});
|
|
287
321
|
|
|
@@ -13,7 +13,11 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type AttrReader,
|
|
18
|
+
clampPlaybackRate,
|
|
19
|
+
readMediaOffsetSeconds,
|
|
20
|
+
} from "@hyperframes/parsers/media-duration";
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Layer-reveal lift transparency
|
|
@@ -79,10 +83,6 @@ function readDurationAttribute(el: Element | null | undefined): number {
|
|
|
79
83
|
return isFinitePositive(duration) ? duration : 0;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
function normalizePlaybackRate(raw: number): number {
|
|
83
|
-
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
86
|
export function isTimelineIgnoredElement(el: Element): boolean {
|
|
87
87
|
// An `<hf-audio-group>` is a mixer bus with no timing of its own, drawn as a group row, never a clip.
|
|
88
88
|
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
|
|
@@ -199,7 +199,7 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
199
199
|
|
|
200
200
|
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
201
201
|
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
|
|
202
|
-
entry.playbackRate =
|
|
202
|
+
entry.playbackRate = clampPlaybackRate(authoredPlaybackRate);
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -262,7 +262,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
|
|
|
262
262
|
|
|
263
263
|
const playbackRate = mediaEl.defaultPlaybackRate;
|
|
264
264
|
if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
|
|
265
|
-
entry.playbackRate =
|
|
265
|
+
entry.playbackRate = clampPlaybackRate(playbackRate);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|