@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.
Files changed (39) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +132 -41
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLEPreview.tsx +5 -1
  15. package/src/components/sidebar/AssetsTab.tsx +3 -4
  16. package/src/player/components/AudioWaveform.tsx +44 -29
  17. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  18. package/src/player/components/CompositionThumbnail.tsx +42 -10
  19. package/src/player/components/EditModal.tsx +5 -20
  20. package/src/player/components/PlayerControls.tsx +120 -11
  21. package/src/player/components/Timeline.test.ts +84 -0
  22. package/src/player/components/Timeline.tsx +198 -27
  23. package/src/player/components/timelineEditing.test.ts +2 -2
  24. package/src/player/components/timelineEditing.ts +1 -1
  25. package/src/player/components/timelineZoom.test.ts +21 -0
  26. package/src/player/components/timelineZoom.ts +11 -0
  27. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  29. package/src/player/lib/time.test.ts +19 -1
  30. package/src/player/lib/time.ts +20 -0
  31. package/src/player/store/playerStore.test.ts +11 -1
  32. package/src/player/store/playerStore.ts +5 -1
  33. package/src/styles/studio.css +9 -0
  34. package/src/utils/clipboard.test.ts +88 -0
  35. package/src/utils/clipboard.ts +57 -0
  36. package/src/utils/timelineAssetDrop.test.ts +64 -4
  37. package/src/utils/timelineAssetDrop.ts +27 -5
  38. package/dist/assets/index-Bi30tos-.js +0 -105
  39. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("disables move and trims for generic motion clips even when patchable", () => {
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: false,
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
+ });