@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10
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/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +132 -41
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("allows moving generic motion clips while keeping trims blocked", () => {
|
|
252
252
|
expect(
|
|
253
253
|
getTimelineEditCapabilities({
|
|
254
254
|
tag: "section",
|
|
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
256
256
|
selector: ".feature-card",
|
|
257
257
|
}),
|
|
258
258
|
).toEqual({
|
|
259
|
-
canMove:
|
|
259
|
+
canMove: true,
|
|
260
260
|
canTrimStart: false,
|
|
261
261
|
canTrimEnd: false,
|
|
262
262
|
});
|
|
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
|
|
|
233
233
|
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
234
234
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
235
235
|
return {
|
|
236
|
-
canMove: canPatch && hasDeterministicWindow,
|
|
236
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
237
237
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
238
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
239
|
};
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
4
|
getNextTimelineZoomPercent,
|
|
5
|
+
getPinchTimelineZoomPercent,
|
|
5
6
|
getTimelinePixelsPerSecond,
|
|
6
7
|
getTimelineZoomPercent,
|
|
7
8
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
describe("getPinchTimelineZoomPercent", () => {
|
|
66
|
+
it("zooms in for upward pinch wheel deltas", () => {
|
|
67
|
+
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("zooms out for downward pinch wheel deltas", () => {
|
|
71
|
+
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the current zoom for zero or invalid deltas", () => {
|
|
75
|
+
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
|
76
|
+
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps pinch zoom to the supported range", () => {
|
|
80
|
+
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
5
|
const ZOOM_OUT_FACTOR = 0.8;
|
|
6
6
|
const ZOOM_IN_FACTOR = 1.25;
|
|
7
|
+
const PINCH_ZOOM_SENSITIVITY = 0.0035;
|
|
7
8
|
|
|
8
9
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
9
10
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
|
|
|
36
37
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
|
37
38
|
return clampTimelineZoomPercent(next);
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function getPinchTimelineZoomPercent(
|
|
42
|
+
deltaY: number,
|
|
43
|
+
zoomMode: ZoomMode,
|
|
44
|
+
manualZoomPercent: number,
|
|
45
|
+
): number {
|
|
46
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
47
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
|
+
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
|
+
}
|
|
@@ -1,10 +1,59 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
2
3
|
import {
|
|
3
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
findTimelineDomNodeForClip,
|
|
6
|
+
getTimelineElementSelector,
|
|
7
|
+
type ClipManifestClip,
|
|
4
8
|
mergeTimelineElementsPreservingDowngrades,
|
|
5
9
|
resolveStandaloneRootCompositionSrc,
|
|
10
|
+
shouldIgnorePlaybackShortcutEvent,
|
|
11
|
+
shouldIgnorePlaybackShortcutTarget,
|
|
6
12
|
} from "./useTimelinePlayer";
|
|
7
13
|
|
|
14
|
+
function createDocument(markup: string): Document {
|
|
15
|
+
const window = new Window();
|
|
16
|
+
window.document.body.innerHTML = markup;
|
|
17
|
+
return window.document;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
21
|
+
return {
|
|
22
|
+
id: null,
|
|
23
|
+
label: "",
|
|
24
|
+
start: 0,
|
|
25
|
+
duration: 4,
|
|
26
|
+
track: 0,
|
|
27
|
+
kind: "element",
|
|
28
|
+
tagName: "div",
|
|
29
|
+
compositionId: null,
|
|
30
|
+
parentCompositionId: null,
|
|
31
|
+
compositionSrc: null,
|
|
32
|
+
assetUrl: null,
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
38
|
+
return {
|
|
39
|
+
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
40
|
+
} as unknown as EventTarget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockKeyboardEvent(
|
|
44
|
+
code: string,
|
|
45
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
|
46
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
|
47
|
+
return {
|
|
48
|
+
altKey: false,
|
|
49
|
+
ctrlKey: false,
|
|
50
|
+
metaKey: false,
|
|
51
|
+
code,
|
|
52
|
+
target: mockTargetMatching("[data-missing]"),
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
8
57
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
9
58
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
10
59
|
expect(
|
|
@@ -65,6 +114,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
|
|
|
65
114
|
});
|
|
66
115
|
});
|
|
67
116
|
|
|
117
|
+
describe("findTimelineDomNodeForClip", () => {
|
|
118
|
+
it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
|
|
119
|
+
const doc = createDocument(`
|
|
120
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
121
|
+
<section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
|
|
122
|
+
<div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
|
|
123
|
+
<div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
|
|
124
|
+
</div>
|
|
125
|
+
`);
|
|
126
|
+
const used = new Set<Element>();
|
|
127
|
+
|
|
128
|
+
const first = findTimelineDomNodeForClip(
|
|
129
|
+
doc,
|
|
130
|
+
createClip({ id: "__node__index_2", track: 1 }),
|
|
131
|
+
1,
|
|
132
|
+
used,
|
|
133
|
+
) as HTMLElement;
|
|
134
|
+
used.add(first);
|
|
135
|
+
const second = findTimelineDomNodeForClip(
|
|
136
|
+
doc,
|
|
137
|
+
createClip({ id: "__node__index_3", track: 2 }),
|
|
138
|
+
2,
|
|
139
|
+
used,
|
|
140
|
+
) as HTMLElement;
|
|
141
|
+
|
|
142
|
+
expect(first.className).toBe("clip duplicate-card first");
|
|
143
|
+
expect(second.className).toBe("clip duplicate-card second");
|
|
144
|
+
expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
|
|
145
|
+
expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
68
149
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
69
150
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
70
151
|
expect(
|
|
@@ -94,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
94
175
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
95
176
|
});
|
|
96
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("shouldIgnorePlaybackShortcutTarget", () => {
|
|
180
|
+
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
|
|
181
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
|
|
185
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("allows non-interactive preview targets to use playback shortcuts", () => {
|
|
189
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("shouldIgnorePlaybackShortcutEvent", () => {
|
|
194
|
+
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
|
|
195
|
+
expect(
|
|
196
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
|
|
199
|
+
true,
|
|
200
|
+
);
|
|
201
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
|
|
207
|
+
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
|
208
|
+
|
|
209
|
+
expect(
|
|
210
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
expect(
|
|
213
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
|
|
216
|
+
false,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
|
221
|
+
expect(
|
|
222
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
223
|
+
isCaptionEditMode: true,
|
|
224
|
+
selectedCaptionSegmentCount: 0,
|
|
225
|
+
}),
|
|
226
|
+
).toBe(false);
|
|
227
|
+
expect(
|
|
228
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
229
|
+
isCaptionEditMode: false,
|
|
230
|
+
selectedCaptionSegmentCount: 1,
|
|
231
|
+
}),
|
|
232
|
+
).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|