@hyperframes/studio 0.8.82 → 0.8.84

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-Dd74fv7B.js} +3 -3
  2. package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
  3. package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
  4. package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-7VDQIGOU.js +1871 -0
  10. package/dist/chunk-7VDQIGOU.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20512 -23172
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +234 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useDomEditCommits.ts +1 -0
  53. package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
  54. package/src/hooks/useDomEditTextCommits.ts +8 -0
  55. package/src/hooks/useElementLifecycleOps.ts +1 -1
  56. package/src/hooks/useInlineTextEdit.test.tsx +39 -0
  57. package/src/hooks/useInlineTextEdit.ts +5 -9
  58. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  59. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  60. package/src/hooks/usePreviewPersistence.ts +1 -1
  61. package/src/index.ts +45 -32
  62. package/src/lightEntries.test.ts +32 -0
  63. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  64. package/src/player/components/TimelineClipFades.tsx +2 -1
  65. package/src/player/components/TimelineTypes.ts +4 -2
  66. package/src/player/components/studioShortcuts.ts +8 -5
  67. package/src/player/components/timelineClipChildren.test.ts +34 -1
  68. package/src/player/components/timelineClipChildren.tsx +7 -4
  69. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  70. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  71. package/src/player/components/timelinePointerRelease.ts +4 -0
  72. package/src/player/components/useTimelineProviderState.tsx +4 -0
  73. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  74. package/src/player/components/useTimelineStackingSync.ts +18 -11
  75. package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
  76. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  77. package/src/player/lib/timelineDOM.test.ts +41 -7
  78. package/src/player/lib/timelineElementHelpers.ts +7 -7
  79. package/src/playerEntry.ts +1 -0
  80. package/src/timelineStackingSyncExport.test.tsx +67 -0
  81. package/src/ui.ts +31 -0
  82. package/src/utils/mediaTypes.test.ts +33 -0
  83. package/src/utils/mediaTypes.ts +12 -5
  84. package/dist/chunk-I7XNAIOF.js.map +0 -1
  85. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -11,86 +11,108 @@ afterEach(() => {
11
11
  usePlayerStore.getState().reset();
12
12
  });
13
13
 
14
+ const element: TimelineElement = {
15
+ id: "clip-1",
16
+ tag: "div",
17
+ start: 0,
18
+ duration: 2,
19
+ track: 0,
20
+ };
21
+
22
+ function mountGesture(kind: "drag" | "resize") {
23
+ const drag: DraggedClipState = {
24
+ pointerId: 0,
25
+ element,
26
+ originClientX: 0,
27
+ originClientY: 0,
28
+ originScrollLeft: 0,
29
+ originScrollTop: 0,
30
+ pointerClientX: 0,
31
+ pointerClientY: 0,
32
+ pointerOffsetX: 0,
33
+ pointerOffsetY: 0,
34
+ previewStart: 1,
35
+ previewTrack: 0,
36
+ desiredTrack: 0,
37
+ insertRow: null,
38
+ snapTime: null,
39
+ snapType: null,
40
+ started: true,
41
+ };
42
+ const resize: ResizingClipState = {
43
+ pointerId: 0,
44
+ element,
45
+ edge: "end",
46
+ originClientX: 0,
47
+ previewStart: 0,
48
+ previewDuration: 3,
49
+ started: true,
50
+ };
51
+ const draggedClipRef = { current: kind === "drag" ? drag : null };
52
+ const resizingClipRef = { current: kind === "resize" ? resize : null };
53
+ const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
54
+ draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
55
+ };
56
+ const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
57
+ ...previous,
58
+ previewStart: 3,
59
+ }));
60
+ const onMoveElement = vi.fn();
61
+ const onResizeElement = vi.fn();
62
+ const updateElement = vi.fn();
63
+ const stopAutoScroll = vi.fn();
64
+ const cancelGestureRef = { current: () => false };
65
+ const dispose = mountTimelineClipDragGestureLifecycle({
66
+ lifecycleRef: { current: { kind, phase: "active", pointerId: null, sessionEpoch: 0 } },
67
+ sessionEpochRef: { current: 0 },
68
+ cancelGestureRef,
69
+ scrollRef: { current: null },
70
+ draggedClipRef,
71
+ resizingClipRef,
72
+ blockedClipRef: { current: null },
73
+ groupResizeRef: { current: null },
74
+ suppressClickRef: { current: false },
75
+ gestureSelectedKeysRef: { current: new Set() },
76
+ elementsRef: { current: [element] },
77
+ trackOrderRef: { current: [0] },
78
+ setDraggedClipState: setDraggedClip,
79
+ setResizingClipState: () => {},
80
+ setShowPopover: () => {},
81
+ setRangeSelectionRef: { current: null },
82
+ applyResizePointerRef: { current: () => {} },
83
+ syncClipDragAutoScrollRef: { current: () => {} },
84
+ stopClipDragAutoScrollRef: { current: stopAutoScroll },
85
+ updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
86
+ publishDraggedClip: setDraggedClip,
87
+ updateElement,
88
+ onMoveElementRef: { current: onMoveElement },
89
+ onMoveElementsRef: { current: undefined },
90
+ onResizeElementRef: { current: onResizeElement },
91
+ onResizeElementsRef: { current: undefined },
92
+ onBlockedEditAttemptRef: { current: undefined },
93
+ readZIndexRef: { current: undefined },
94
+ onStackingPatchesRef: { current: undefined },
95
+ refreshAfterLaneMoveRef: { current: undefined },
96
+ });
97
+ return {
98
+ draggedClipRef,
99
+ resizingClipRef,
100
+ updateDraggedClipPreview,
101
+ onMoveElement,
102
+ onResizeElement,
103
+ updateElement,
104
+ stopAutoScroll,
105
+ cancelGestureRef,
106
+ dispose,
107
+ };
108
+ }
109
+
110
+ const release = (clientX: number, clientY: number) =>
111
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));
112
+
14
113
  describe("timeline clip drag gesture lifecycle", () => {
15
114
  it("finishes a drag after virtualization unmounts its source row", () => {
16
- const element: TimelineElement = {
17
- id: "clip-1",
18
- tag: "div",
19
- start: 0,
20
- duration: 2,
21
- track: 0,
22
- };
23
- const drag: DraggedClipState = {
24
- pointerId: 0,
25
- element,
26
- originClientX: 0,
27
- originClientY: 0,
28
- originScrollLeft: 0,
29
- originScrollTop: 0,
30
- pointerClientX: 0,
31
- pointerClientY: 0,
32
- pointerOffsetX: 0,
33
- pointerOffsetY: 0,
34
- previewStart: 1,
35
- previewTrack: 0,
36
- desiredTrack: 0,
37
- insertRow: null,
38
- snapTime: null,
39
- snapType: null,
40
- started: true,
41
- };
42
- const draggedClipRef = { current: drag as DraggedClipState | null };
43
- const resizingClipRef = { current: null as ResizingClipState | null };
44
- const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
45
- draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
46
- };
47
- const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
48
- ...previous,
49
- previewStart: 3,
50
- }));
51
- const onMoveElement = vi.fn();
52
- const stopAutoScroll = vi.fn();
53
- const cancelGestureRef = { current: () => false };
54
- const dispose = mountTimelineClipDragGestureLifecycle({
55
- lifecycleRef: {
56
- current: {
57
- kind: "drag",
58
- phase: "active",
59
- pointerId: null,
60
- sessionEpoch: 0,
61
- },
62
- },
63
- sessionEpochRef: { current: 0 },
64
- cancelGestureRef,
65
- scrollRef: { current: null },
66
- draggedClipRef,
67
- resizingClipRef,
68
- blockedClipRef: { current: null },
69
- groupResizeRef: { current: null },
70
- suppressClickRef: { current: false },
71
- gestureSelectedKeysRef: { current: new Set() },
72
- elementsRef: { current: [element] },
73
- trackOrderRef: { current: [0] },
74
- setDraggedClipState: setDraggedClip,
75
- setResizingClipState: () => {},
76
- setShowPopover: () => {},
77
- setRangeSelectionRef: { current: null },
78
- applyResizePointerRef: { current: () => {} },
79
- syncClipDragAutoScrollRef: { current: () => {} },
80
- stopClipDragAutoScrollRef: { current: stopAutoScroll },
81
- updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
82
- publishDraggedClip: setDraggedClip,
83
- updateElement: vi.fn(),
84
- onMoveElementRef: { current: onMoveElement },
85
- onMoveElementsRef: { current: undefined },
86
- onResizeElementRef: { current: undefined },
87
- onResizeElementsRef: { current: undefined },
88
- onBlockedEditAttemptRef: { current: undefined },
89
- readZIndexRef: { current: undefined },
90
- onStackingPatchesRef: { current: undefined },
91
- refreshAfterLaneMoveRef: { current: undefined },
92
- });
93
-
115
+ const g = mountGesture("drag");
94
116
  const sourceRow = document.createElement("div");
95
117
  sourceRow.dataset.timelineRow = "";
96
118
  sourceRow.append(document.createElement("div"));
@@ -98,15 +120,49 @@ describe("timeline clip drag gesture lifecycle", () => {
98
120
  sourceRow.remove();
99
121
 
100
122
  window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
101
- expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
102
- expect(draggedClipRef.current?.previewStart).toBe(3);
123
+ expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
124
+ expect(g.draggedClipRef.current?.previewStart).toBe(3);
103
125
 
104
126
  window.dispatchEvent(new MouseEvent("pointerup"));
105
- expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
106
- expect(draggedClipRef.current).toBeNull();
107
- expect(stopAutoScroll).toHaveBeenCalledTimes(1);
127
+ expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
128
+ expect(g.draggedClipRef.current).toBeNull();
129
+ expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);
130
+
131
+ g.dispose();
132
+ expect(g.cancelGestureRef.current()).toBe(false);
133
+ });
134
+
135
+ it.each([
136
+ ["above the window", 20, -40],
137
+ ["past the window's right edge", window.innerWidth + 40, 10],
138
+ ["on the window's right edge", window.innerWidth, 10],
139
+ ["on the window's bottom edge", 20, window.innerHeight],
140
+ ])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
141
+ const g = mountGesture("drag");
142
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
143
+ release(clientX, clientY);
144
+ expect(g.onMoveElement).not.toHaveBeenCalled();
145
+ expect(g.updateElement).not.toHaveBeenCalled();
146
+ expect(g.draggedClipRef.current).toBeNull();
147
+ // The cancel ended the gesture, so a later release has nothing to commit.
148
+ release(20, 10);
149
+ expect(g.onMoveElement).not.toHaveBeenCalled();
150
+ g.dispose();
151
+ });
152
+
153
+ it("cancels a trim released outside the window", () => {
154
+ const g = mountGesture("resize");
155
+ release(20, -40);
156
+ expect(g.onResizeElement).not.toHaveBeenCalled();
157
+ expect(g.updateElement).not.toHaveBeenCalled();
158
+ expect(g.resizingClipRef.current).toBeNull();
159
+ g.dispose();
160
+ });
108
161
 
109
- dispose();
110
- expect(cancelGestureRef.current()).toBe(false);
162
+ it("still commits a drag released on the window's last pixel", () => {
163
+ const g = mountGesture("drag");
164
+ release(window.innerWidth - 1, window.innerHeight - 1);
165
+ expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
166
+ g.dispose();
111
167
  });
112
168
  });
@@ -8,6 +8,7 @@ import type {
8
8
  } from "./timelineClipDragTypes";
9
9
  import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
10
10
  import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
11
+ import { releasedOutsideWindow } from "./timelinePointerRelease";
11
12
  import {
12
13
  beginTimelineOptimisticGesture,
13
14
  rollbackLatestTimelineOptimisticGesture,
@@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
349
350
  };
350
351
 
351
352
  const handleWindowPointerUp = (event: PointerEvent) => {
353
+ if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
352
354
  const claimed = claimActiveGesture(event);
353
355
  if (claimed === "ignored") return;
354
356
  if (claimed) {
@@ -0,0 +1,4 @@
1
+ export function releasedOutsideWindow(point: Pick<MouseEvent, "clientX" | "clientY">): boolean {
2
+ const { clientX: x, clientY: y } = point;
3
+ return x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight;
4
+ }
@@ -71,6 +71,8 @@ export function useTimelineProviderState({
71
71
  canPasteClip,
72
72
  theme: themeOverrides,
73
73
  sessionEpoch = 0,
74
+ previewIframeRef,
75
+ onZIndexReorder,
74
76
  }: TimelineProps = {}): TimelineContextValue {
75
77
  const {
76
78
  onMoveElement,
@@ -184,6 +186,8 @@ export function useTimelineProviderState({
184
186
  });
185
187
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
186
188
  expandedElementsRef: timelineElementsRef,
189
+ previewIframeRef,
190
+ onZIndexReorder,
187
191
  });
188
192
  const {
189
193
  draggedClip,
@@ -73,4 +73,50 @@ describe("useTimelineStackingSync", () => {
73
73
  mocks.actions = null;
74
74
  iframe.remove();
75
75
  });
76
+
77
+ it("uses a host's iframe and commit over Studio's own", async () => {
78
+ const studioCommit = vi.fn().mockResolvedValue(undefined);
79
+ mocks.actions = {
80
+ previewIframeRef: { current: null },
81
+ handleDomZIndexReorderCommit: studioCommit,
82
+ };
83
+ const iframe = document.createElement("iframe");
84
+ document.body.appendChild(iframe);
85
+ const node = iframe.contentDocument!.createElement("div");
86
+ node.id = "a";
87
+ iframe.contentDocument!.body.appendChild(node);
88
+ const hostCommit = vi.fn().mockResolvedValue(undefined);
89
+ const element: TimelineElement = {
90
+ id: "a",
91
+ key: "a",
92
+ tag: "div",
93
+ start: 0,
94
+ duration: 2,
95
+ track: 0,
96
+ };
97
+ let sync: ReturnType<typeof useTimelineStackingSync> | null = null;
98
+ function Harness() {
99
+ sync = useTimelineStackingSync({
100
+ expandedElementsRef: { current: [element] },
101
+ previewIframeRef: { current: iframe },
102
+ onZIndexReorder: hostCommit,
103
+ });
104
+ return null;
105
+ }
106
+ const root = mountReactHarness(<Harness />);
107
+
108
+ await act(async () => {
109
+ await sync!.applyStackingPatches([{ key: "a", zIndex: 3 }]);
110
+ });
111
+
112
+ expect(sync!.zSyncEnabled).toBe(true);
113
+ expect(hostCommit).toHaveBeenCalledWith(
114
+ [expect.objectContaining({ element: node, zIndex: 3 })],
115
+ undefined,
116
+ );
117
+ expect(studioCommit).not.toHaveBeenCalled();
118
+ act(() => root.unmount());
119
+ mocks.actions = null;
120
+ iframe.remove();
121
+ });
76
122
  });
@@ -5,23 +5,30 @@ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
5
5
  import { findElementForSelection } from "../../components/editor/domEditingElement";
6
6
  import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
7
7
  import type { StackingPatch } from "./timelineStackingSync";
8
+ import type { TimelineZIndexReorderCommit } from "../../hooks/useTimelineEditingTypes";
8
9
 
9
- interface UseTimelineStackingSyncInput {
10
+ /** With both set, a lane move restacks the preview's z-index to match the rows; each overrides Studio's own. */
11
+ export interface TimelineStackingSyncProps {
12
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
13
+ onZIndexReorder?: TimelineZIndexReorderCommit;
14
+ }
15
+
16
+ interface UseTimelineStackingSyncInput extends TimelineStackingSyncProps {
10
17
  expandedElementsRef: RefObject<TimelineElement[]>;
11
18
  }
12
19
 
13
- // Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the
14
- // two deps commitDraggedClipMove accepts so a lane-change drag also patches the
15
- // edited clip's z-index. Both read the SAME preview iframe + z-order persist path
16
- // the canvas right-click menu / LayersPanel use, so a timeline lane move and a
17
- // menu z-edit produce one shared inline-style commit shape. Optional contexts:
18
- // outside the NLE (standalone <Timeline>) these are null ⇒ deps undefined ⇒ the
19
- // commit's z-sync is a no-op (backward compatible).
20
- export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
20
+ // Lane ↔ stacking sync: the two deps commitDraggedClipMove takes so a lane-change drag also patches
21
+ // z-index, through the same iframe + z persist path the canvas menu and LayersPanel use. Either missing ⇒ no-op.
22
+ export function useTimelineStackingSync({
23
+ expandedElementsRef,
24
+ previewIframeRef,
25
+ onZIndexReorder,
26
+ }: UseTimelineStackingSyncInput) {
21
27
  const domEditActions = useDomEditActionsContextOptional();
22
28
  const shell = useStudioShellContextOptional();
23
- const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
24
- const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit;
29
+ const zSyncPreviewIframeRef = previewIframeRef ?? domEditActions?.previewIframeRef ?? null;
30
+ const handleDomZIndexReorderCommit =
31
+ onZIndexReorder ?? domEditActions?.handleDomZIndexReorderCommit;
25
32
  const zSyncActiveCompPath = shell?.activeCompPath ?? null;
26
33
 
27
34
  // Resolve a TimelineElement to its live iframe HTMLElement via the same
@@ -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);
@@ -15,6 +15,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
15
15
  "audioGroupHidden",
16
16
  "fadeIn",
17
17
  "fadeOut",
18
+ "src",
18
19
  ];
19
20
 
20
21
  /** Whether a derived timeline changes any field that affects rendering. */
@@ -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 { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
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
- ["10", 5],
273
- ["0.01", 0.1],
274
- ])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
275
- const doc = makeDoc(`
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
- const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
290
+ const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
291
+
292
+ expect(nested?.playbackRate).toBe(expected);
293
+ },
294
+ );
295
+ });
283
296
 
284
- expect(nested?.playbackRate).toBe(expected);
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 { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
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 = normalizePlaybackRate(authoredPlaybackRate);
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 = normalizePlaybackRate(playbackRate);
265
+ entry.playbackRate = clampPlaybackRate(playbackRate);
266
266
  }
267
267
  }
268
268
 
@@ -0,0 +1 @@
1
+ export { Player } from "./player/components/Player";
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+ // Mounts the timeline by package name with no Studio shell, as a host app does.
3
+ import { act } from "react";
4
+ import { expect, it, vi } from "vitest";
5
+ import { Timeline, usePlayerStore, type TimelineZIndexReorderCommit } from "@hyperframes/studio";
6
+ import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
7
+
8
+ installReactActEnvironment();
9
+ // happy-dom has no layout; the timeline only renders clips inside a sized viewport.
10
+ for (const [prop, px] of [
11
+ ["clientWidth", 1000],
12
+ ["clientHeight", 400],
13
+ ["offsetWidth", 1000],
14
+ ["offsetHeight", 400],
15
+ ] as const) {
16
+ vi.spyOn(HTMLElement.prototype, prop, "get").mockReturnValue(px);
17
+ }
18
+
19
+ const settle = () => act(async () => new Promise((r) => setTimeout(r, 50)));
20
+ const pointer = (type: string, clientY: number) =>
21
+ new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX: 150, clientY });
22
+
23
+ it("restacks the host's preview when a lane move puts a clip below another", async () => {
24
+ const iframe = document.createElement("iframe");
25
+ document.body.appendChild(iframe);
26
+ const doc = iframe.contentDocument!;
27
+ // No authored z: DOM order paints tag over title, which matches the rows until the move.
28
+ doc.body.innerHTML = '<div id="title"></div><div id="tag"></div><div id="note"></div>';
29
+ const clip = (id: string, start: number, duration: number, track: number) =>
30
+ ({ id, key: id, domId: id, tag: "div", start, duration, track }) as const;
31
+ usePlayerStore.setState({
32
+ duration: 10,
33
+ timelineReady: true,
34
+ elements: [clip("title", 0, 4, 1), clip("tag", 0, 4, 0), clip("note", 6, 2, 2)],
35
+ });
36
+ const commit = vi.fn<TimelineZIndexReorderCommit>(async (entries) => {
37
+ for (const e of entries) e.element.style.zIndex = String(e.zIndex);
38
+ });
39
+ const root = mountReactHarness(
40
+ <Timeline
41
+ previewIframeRef={{ current: iframe }}
42
+ onZIndexReorder={commit}
43
+ onMoveElement={async () => {}}
44
+ onMoveElements={async () => {}}
45
+ />,
46
+ );
47
+ await settle();
48
+ const tagClip = document.querySelector<HTMLElement>('.timeline-clip[aria-label^="tag,"]')!;
49
+ vi.spyOn(tagClip, "getBoundingClientRect").mockReturnValue(new DOMRect(80, 77, 360, 42));
50
+
51
+ // Drag tag from the top row to the bottom row, clear of note in time.
52
+ act(() => void tagClip.dispatchEvent(pointer("pointerdown", 98)));
53
+ act(() => {
54
+ window.dispatchEvent(pointer("pointermove", 140));
55
+ window.dispatchEvent(pointer("pointermove", 194));
56
+ });
57
+ await act(async () => void window.dispatchEvent(pointer("pointerup", 194)));
58
+ await settle();
59
+
60
+ expect(usePlayerStore.getState().elements.find((e) => e.id === "tag")?.track).toBe(2);
61
+ expect(commit).toHaveBeenCalledTimes(1);
62
+ expect(commit.mock.calls[0]![0].every((e) => e.element.ownerDocument === doc)).toBe(true);
63
+ const z = (id: string) => Number(doc.getElementById(id)!.style.zIndex || 0);
64
+ expect(z("title")).toBeGreaterThan(z("tag"));
65
+ await act(async () => root.unmount());
66
+ usePlayerStore.setState({ elements: [], timelineReady: false });
67
+ });
package/src/ui.ts ADDED
@@ -0,0 +1,31 @@
1
+ export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
2
+ export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
3
+ export { HyperframesLogo } from "./components/ui/HyperframesLogo";
4
+ export { IconButton } from "./components/ui/IconButton";
5
+ export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
6
+ export { HyperframesLoader } from "./components/ui/HyperframesLoader";
7
+ export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
8
+ export { Tooltip } from "./components/ui/Tooltip";
9
+ export { cn } from "./components/ui/cn";
10
+ export {
11
+ ContextMenu,
12
+ Menu,
13
+ MenuItem,
14
+ MenuRadioGroup,
15
+ MenuRadioItem,
16
+ MenuSeparator,
17
+ MenuShortcut,
18
+ popupSurface,
19
+ } from "./components/ui/Menu";
20
+ export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
21
+ export { Popover } from "./components/ui/Popover";
22
+ export { Input, fieldBase, fieldText } from "./components/ui/Input";
23
+ export type { InputProps } from "./components/ui/Input";
24
+ export { NumberField } from "./components/ui/NumberField";
25
+ export type { NumberFieldProps } from "./components/ui/NumberField";
26
+ export { Select } from "./components/ui/Select";
27
+ export type { SelectOption, SelectProps } from "./components/ui/Select";
28
+ export { Slider } from "./components/ui/Slider";
29
+ export type { SliderProps } from "./components/ui/Slider";
30
+ export { Toggle } from "./components/ui/Toggle";
31
+ export type { ToggleProps } from "./components/ui/Toggle";
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AUDIO_EXT, IMAGE_EXT, isMediaFile, MEDIA_EXT, VIDEO_EXT } from "./mediaTypes";
3
+ import { getTimelineAssetKind } from "./timelineAssetDrop";
4
+
5
+ describe("media types", () => {
6
+ it.each([
7
+ ["clip.m4v", "video"],
8
+ ["assets/Take 2.M4V", "video"],
9
+ ["song.flac", "audio"],
10
+ ["voice.FLAC", "audio"],
11
+ ["clip.mp4", "video"],
12
+ ["music.mp3", "audio"],
13
+ ["still.png", "image"],
14
+ ] as const)("the timeline takes %s as %s", (path, kind) => {
15
+ expect(getTimelineAssetKind(path)).toBe(kind);
16
+ expect(isMediaFile(path)).toBe(true);
17
+ });
18
+
19
+ it("still refuses files that are not media", () => {
20
+ for (const path of ["notes.pdf", "font.woff2", "grade.cube", "clip.m4v.txt", "flac"]) {
21
+ expect(getTimelineAssetKind(path)).toBeNull();
22
+ expect(isMediaFile(path)).toBe(false);
23
+ }
24
+ });
25
+
26
+ it("counts a file as media exactly when it is an image, video or audio file", () => {
27
+ const paths = ["a.mp4", "a.m4v", "a.flac", "a.png", "a.ico", "a.woff", "a.pdf", "a.cube"];
28
+ for (const path of paths) {
29
+ const kinds = [IMAGE_EXT, VIDEO_EXT, AUDIO_EXT].filter((re) => re.test(path)).length;
30
+ expect(MEDIA_EXT.test(path)).toBe(kinds > 0);
31
+ }
32
+ });
33
+ });
@@ -1,10 +1,17 @@
1
- export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
2
- export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
- export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
1
+ const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "ico"];
2
+ const VIDEO_EXTS = ["mp4", "webm", "mov", "m4v"];
3
+ const AUDIO_EXTS = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
4
+
5
+ function extensionPattern(extensions: readonly string[]): RegExp {
6
+ return new RegExp(`\\.(${extensions.join("|")})$`, "i");
7
+ }
8
+
9
+ export const IMAGE_EXT = extensionPattern(IMAGE_EXTS);
10
+ export const VIDEO_EXT = extensionPattern(VIDEO_EXTS);
11
+ export const AUDIO_EXT = extensionPattern(AUDIO_EXTS);
4
12
  export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
5
13
  export const LUT_EXT = /\.cube$/i;
6
- export const MEDIA_EXT =
7
- /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
14
+ export const MEDIA_EXT = extensionPattern([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS]);
8
15
 
9
16
  export function isMediaFile(path: string): boolean {
10
17
  return MEDIA_EXT.test(path);