@hyperframes/studio 0.8.109 → 0.8.111

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
  2. package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
  3. package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
  6. package/dist/index.d.ts +22 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5352 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +5 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/studioPendingEdits.test.ts +18 -0
  92. package/src/utils/typingTarget.test.ts +8 -0
  93. package/src/utils/typingTarget.ts +1 -0
@@ -382,17 +382,6 @@ export function resolveBlockedTimelineEditIntent(input: {
382
382
  return "move";
383
383
  }
384
384
 
385
- export function buildClipRangeSelection(
386
- clip: { start: number; duration: number },
387
- anchor: { anchorX: number; anchorY: number },
388
- ): TimelineRangeSelection {
389
- return {
390
- start: clip.start,
391
- end: clip.start + clip.duration,
392
- anchorX: anchor.anchorX,
393
- anchorY: anchor.anchorY,
394
- };
395
- }
396
385
  export function buildTimelineAgentPrompt({
397
386
  rangeStart,
398
387
  rangeEnd,
@@ -66,11 +66,6 @@ export interface TimelineLaneBaseProps {
66
66
  setResizingClip: (v: ResizingClipState | null) => void;
67
67
  setDraggedClip: (v: DraggedClipState | null) => void;
68
68
  setSelectedElementId: (id: string | null) => void;
69
- shiftClickClipRef: React.RefObject<{
70
- element: TimelineElement;
71
- anchorX: number;
72
- anchorY: number;
73
- } | null>;
74
69
  getPreviewElement: (element: TimelineElement) => TimelineElement;
75
70
  getTrackStyle: (tag: string) => TrackVisualStyle;
76
71
  keyframeCache?: Map<string, KeyframeCacheEntry>;
@@ -1,5 +1,5 @@
1
- import { usePlayerStore } from "../store/playerStore";
2
- import { expandToLinkedMembers } from "./audioClipLink";
1
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
+ import { expandToLinkedMembers, linkedMembersOf } from "./audioClipLink";
3
3
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
4
4
 
5
5
  /**
@@ -21,3 +21,21 @@ export function selectClipWithLinks(
21
21
  const expanded = expandToLinkedMembers(selectedElementIds, elements);
22
22
  if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
23
23
  }
24
+
25
+ export function toggleClipWithLinks(key: string, altKey: boolean): TimelineElement | null {
26
+ const state = usePlayerStore.getState();
27
+ const element = state.elements.find((el) => (el.key ?? el.id) === key);
28
+ const members = element
29
+ ? linkedMembersOf(element, state.elements, !altKey && isLinkedSelectionOn())
30
+ : [];
31
+ const memberKeys = members.length > 0 ? members.map((el) => el.key ?? el.id) : [key];
32
+ const next = new Set(state.selectedElementIds);
33
+ const removing = next.has(key);
34
+ for (const memberKey of memberKeys) {
35
+ if (removing) next.delete(memberKey);
36
+ else next.add(memberKey);
37
+ }
38
+ state.setSelection(next, removing ? state.selectedElementId : key);
39
+ const primary = usePlayerStore.getState().selectedElementId;
40
+ return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
41
+ }
@@ -40,7 +40,10 @@ export function getTimelineScrollTopForGeometryChange(
40
40
  previous: TimelineRowGeometry,
41
41
  next: TimelineRowGeometry,
42
42
  scrollTop: number,
43
+ dragAutoScrollOwnsScrollTop = false,
43
44
  ): number {
45
+ const atTopSoRowsAddedAboveStayShown = scrollTop <= 0;
46
+ if (dragAutoScrollOwnsScrollTop || atTopSoRowsAddedAboveStayShown) return scrollTop;
44
47
  const anchor = previous.getRowPositionFromY(scrollTop + RULER_H);
45
48
  if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop;
46
49
  const anchorKey = previous.rowKeys[anchor.row];
@@ -11,14 +11,11 @@ import { committedClipPath } from "../../components/editor/cropPresetStore";
11
11
  import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
12
12
  import type { ClipToolState } from "./clipToolAttrs";
13
13
 
14
- const LINK_ATTR = "data-link";
15
-
16
14
  const WATCHED_ATTRS = [
17
15
  HF_AUDIO_FX_ATTR,
18
16
  HF_AUDIO_AUTOMATION_ATTR,
19
17
  HF_AUDIO_CARVE_ATTR,
20
18
  HF_COLOR_GRADING_ATTR,
21
- LINK_ATTR,
22
19
  "data-volume",
23
20
  "data-has-audio",
24
21
  "muted",
@@ -42,7 +39,6 @@ function toolStateFromNode(node: Element): ClipToolState {
42
39
  colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
43
40
  clipPath: committedClipPath(node),
44
41
  carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
45
- link: node.getAttribute(LINK_ATTR),
46
42
  };
47
43
  }
48
44
 
@@ -58,7 +54,6 @@ function toolStateFromElement(el: TimelineElement): ClipToolState {
58
54
  colorGrading: null,
59
55
  clipPath: null,
60
56
  carve: null,
61
- link: null,
62
57
  };
63
58
  }
64
59
 
@@ -27,7 +27,7 @@ import type {
27
27
  BlockedClipState,
28
28
  } from "./timelineClipDragTypes";
29
29
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
- import { linkedGestureKeys } from "./audioClipLink";
30
+ import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
31
31
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
32
32
  import type { TimelineRowGeometry } from "./timelineLayout";
33
33
  import {
@@ -242,12 +242,17 @@ export function useTimelineClipDrag({
242
242
  return;
243
243
  }
244
244
  beginGesture("resize", next.pointerId);
245
- gestureSelectedKeysRef.current = linkedGestureKeys(
246
- gestureSelectedKeysRef.current,
245
+ gestureSelectedKeysRef.current = dropMisalignedTrimPartners(
246
+ linkedGestureKeys(
247
+ gestureSelectedKeysRef.current,
248
+ next.element,
249
+ elementsRef.current,
250
+ next.altKey === true,
251
+ isLinkedSelectionOn(),
252
+ ),
247
253
  next.element,
248
254
  elementsRef.current,
249
- next.altKey === true,
250
- isLinkedSelectionOn(),
255
+ next.edge,
251
256
  );
252
257
  publishResizingClip(next);
253
258
  },
@@ -324,6 +329,8 @@ export function useTimelineClipDrag({
324
329
  scroll: scrollRef.current,
325
330
  pps: ppsRef.current,
326
331
  buildSnapTargets,
332
+ elements: elementsRef.current,
333
+ gestureKeys: gestureSelectedKeysRef.current,
327
334
  });
328
335
  trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime;
329
336
  const setResizeState = (v: ResizePreviewResult) => {
@@ -32,7 +32,6 @@ type OverlayStateInputs = Omit<
32
32
 
33
33
  export type TimelineOverlaysStateResult = {
34
34
  overlays: TimelineOverlaysState;
35
- shiftClickClipRef: ReturnType<typeof useTimelineRangeSelection>["shiftClickClipRef"];
36
35
  marqueeRect: ReturnType<typeof useTimelineRangeSelection>["marqueeRect"];
37
36
  isScrubbing: ReturnType<typeof useTimelineRangeSelection>["isScrubbing"];
38
37
  handlePointerDown: ReturnType<typeof useTimelineRangeSelection>["handlePointerDown"];
@@ -55,7 +54,6 @@ export function useTimelineOverlaysState({
55
54
  const {
56
55
  rangeSelection,
57
56
  setRangeSelection,
58
- shiftClickClipRef,
59
57
  marqueeRect,
60
58
  isScrubbing,
61
59
  handlePointerDown,
@@ -86,7 +84,6 @@ export function useTimelineOverlaysState({
86
84
  onCloseAllTrackGaps: gap.closeAllTrackGaps,
87
85
  onHoverGapAction: gap.setHoveredGapAction,
88
86
  },
89
- shiftClickClipRef,
90
87
  marqueeRect,
91
88
  isScrubbing,
92
89
  handlePointerDown,
@@ -420,7 +420,6 @@ export function useTimelineProviderState({
420
420
  gapHighlight,
421
421
  openGapMenu,
422
422
  onContextMenuClip,
423
- shiftClickClipRef,
424
423
  marqueeRect,
425
424
  isScrubbing,
426
425
  handlePointerDown,
@@ -503,7 +502,6 @@ export function useTimelineProviderState({
503
502
  setResizingClip,
504
503
  setDraggedClip,
505
504
  setSelectedElementId,
506
- shiftClickClipRef,
507
505
  getPreviewElement,
508
506
  getTrackStyle,
509
507
  keyframeCache,
@@ -1,6 +1,5 @@
1
1
  import { useRef, useState, useCallback, useEffect } from "react";
2
2
  import {
3
- buildClipRangeSelection,
4
3
  applyTimelineAutoScrollStep,
5
4
  resolveTimelineAutoScrollLoopAction,
6
5
  type TimelineRangeSelection,
@@ -149,11 +148,6 @@ export function useTimelineRangeSelection({
149
148
  // rendering). Drives the playhead head's filled-vs-hollow state.
150
149
  const [isScrubbing, setIsScrubbing] = useState(false);
151
150
  const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
152
- const shiftClickClipRef = useRef<{
153
- element: TimelineElement;
154
- anchorX: number;
155
- anchorY: number;
156
- } | null>(null);
157
151
 
158
152
  const seekRafRef = useRef(0);
159
153
  const pendingClientXRef = useRef(0);
@@ -322,7 +316,6 @@ export function useTimelineRangeSelection({
322
316
  beginRangeSelection(e);
323
317
  return;
324
318
  }
325
- shiftClickClipRef.current = null;
326
319
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
327
320
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
328
321
  activePointerIdRef.current = e.pointerId;
@@ -427,13 +420,7 @@ export function useTimelineRangeSelection({
427
420
  // clip range), otherwise clear it.
428
421
  const finishRangeSelection = useCallback(() => {
429
422
  isRangeSelecting.current = false;
430
- const pendingShiftClick = shiftClickClipRef.current;
431
- shiftClickClipRef.current = null;
432
423
  setRangeSelection((prev) => {
433
- if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
434
- setShowPopover(true);
435
- return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
436
- }
437
424
  if (prev && Math.abs(prev.end - prev.start) > 0.2) {
438
425
  setShowPopover(true);
439
426
  return prev;
@@ -589,7 +576,6 @@ export function useTimelineRangeSelection({
589
576
  return {
590
577
  rangeSelection,
591
578
  setRangeSelection,
592
- shiftClickClipRef,
593
579
  marqueeRect,
594
580
  isScrubbing,
595
581
  handlePointerDown,
@@ -120,6 +120,7 @@ export function useTimelineRowVirtualization({
120
120
  previousGeometry,
121
121
  rowGeometry,
122
122
  scroll.scrollTop,
123
+ draggedRowKey !== undefined,
123
124
  );
124
125
  if (nextScrollTop !== scroll.scrollTop) {
125
126
  scroll.scrollTop = nextScrollTop;
@@ -127,7 +128,7 @@ export function useTimelineRowVirtualization({
127
128
  }
128
129
  }
129
130
  previousLayoutRef.current = rowGeometry;
130
- }, [lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
131
+ }, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
131
132
 
132
133
  return {
133
134
  enabled,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { openComposition } from "@hyperframes/sdk";
3
+ import { cutoverCommittedOrThrow, sdkTimingBatchPersist } from "./sdkCutover";
4
+ import { getStudioSaveErrorMessage } from "./studioSaveDiagnostics";
5
+
6
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
7
+ STUDIO_SDK_CUTOVER_ENABLED: true,
8
+ STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
9
+ }));
10
+ vi.mock("./studioTelemetry", () => ({
11
+ trackStudioEvent: vi.fn(),
12
+ }));
13
+
14
+ const OFFSET_LINKED_HTML = `
15
+ <div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
16
+ <video data-hf-id="hf-talk" src="talk.mp4" muted data-link="lk-1" data-start="2" data-duration="6" data-track-index="0"></video>
17
+ <audio data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5" data-track-index="2"></audio>
18
+ </div>
19
+ `.trim();
20
+
21
+ describe("a linked trim the SDK refuses", () => {
22
+ it("fails the cutover with the SDK's reason, which the resize toast shows, and writes nothing", async () => {
23
+ const session = await openComposition(OFFSET_LINKED_HTML);
24
+ const before = session.serialize();
25
+ const deps = {
26
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
27
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
28
+ reloadPreview: vi.fn(),
29
+ publishSession: vi.fn(),
30
+ createCandidateSession: async () => session,
31
+ };
32
+ const result = await sdkTimingBatchPersist(
33
+ [{ hfId: "hf-talk", timingUpdate: { start: 2, duration: 0.5 } }],
34
+ "index.html",
35
+ session,
36
+ deps,
37
+ );
38
+ expect(result.status).toBe("failed");
39
+ let thrown: unknown;
40
+ try {
41
+ cutoverCommittedOrThrow(result);
42
+ } catch (error) {
43
+ thrown = error;
44
+ }
45
+ expect(getStudioSaveErrorMessage(thrown)).toBe(
46
+ "Linked audio would start after the new end — unlink or trim the audio first.",
47
+ );
48
+ expect(deps.writeProjectFile).not.toHaveBeenCalled();
49
+ expect(deps.editHistory.recordEdit).not.toHaveBeenCalled();
50
+ expect(session.serialize()).toBe(before);
51
+ });
52
+ });
@@ -10,6 +10,7 @@ import {
10
10
  trackedStudioEdit,
11
11
  } from "./studioPendingEdits";
12
12
  import { StudioFileConflictError } from "./studioSaveDiagnostics";
13
+ import { revertNewestStudioPendingEdit as hostRevert } from "../index";
13
14
 
14
15
  describe("studio pending edit flush", () => {
15
16
  it("waits for mounted panels to persist pending local edits", async () => {
@@ -277,3 +278,20 @@ describe("a pending edit whose start throws", () => {
277
278
  expect(hasStudioPendingEdits()).toBe(false);
278
279
  });
279
280
  });
281
+
282
+ describe("the package's public revert", () => {
283
+ it("lets a host's own undo key paint a still-saving move back at once", async () => {
284
+ let left = "120px";
285
+ const edit = beginStudioPendingEdit(() => ((left = "0px"), () => void (left = "120px")));
286
+ try {
287
+ const putBack = hostRevert();
288
+ expect(left).toBe("0px");
289
+ expect(hostRevert()).toBeNull();
290
+ putBack?.();
291
+ expect(left).toBe("120px");
292
+ } finally {
293
+ edit.settle();
294
+ await flushStudioPendingEdits();
295
+ }
296
+ });
297
+ });
@@ -54,6 +54,14 @@ describe("isTypingTarget", () => {
54
54
  expect(isTypingTarget(mount('<h1 contenteditable="false">Hi</h1>'))).toBe(false);
55
55
  });
56
56
 
57
+ it("keeps a modal dialog's buttons out of the global shortcuts", () => {
58
+ const host = mount('<div role="dialog" aria-modal="true"><button>OK</button></div>');
59
+ expect(isTypingTarget(host.querySelector("button"))).toBe(true);
60
+ expect(
61
+ isTypingTarget(mount('<div role="dialog"><button>OK</button></div>').querySelector("button")),
62
+ ).toBe(false);
63
+ });
64
+
57
65
  it("says no to nothing at all", () => {
58
66
  expect(isTypingTarget(null)).toBe(false);
59
67
  expect(isTypingTarget({} as EventTarget)).toBe(false);
@@ -45,6 +45,7 @@ const TYPING_SELECTOR = [
45
45
  // `input` and matched above. Without this row the shared Toggle would let a
46
46
  // global shortcut claim Space instead of flipping the control (KTD13).
47
47
  "[role='switch']",
48
+ "[role='dialog'][aria-modal='true']",
48
49
  ".cm-editor",
49
50
  ].join(",");
50
51