@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
6
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
7
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
8
8
  import type { TimelineElement } from "../player/store/timelineElement";
9
+ import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
9
10
 
10
11
  /** Minimal valid fixture — TimelineElement only requires these five fields. */
11
12
  const bgmElement: TimelineElement = {
@@ -474,3 +475,36 @@ describe("hotkeys with the preview read-only", () => {
474
475
  expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
475
476
  });
476
477
  });
478
+
479
+ describe("dispatchPlainKey — G opens Audio Gain", () => {
480
+ const video: TimelineElement = {
481
+ id: "b-roll",
482
+ key: "b-roll",
483
+ tag: "video",
484
+ start: 0,
485
+ duration: 4,
486
+ track: 1,
487
+ };
488
+
489
+ afterEach(() => useAudioGainDialogStore.getState().close());
490
+
491
+ it("opens the dialog for the selected clips with sound and owns the key", () => {
492
+ usePlayerStore.setState({
493
+ elements: [bgmElement, video],
494
+ selectedElementId: "bgm",
495
+ selectedElementIds: new Set(["bgm", "b-roll"]),
496
+ });
497
+ const event = press("g");
498
+ dispatchPlainKey(event, "g", callbacks());
499
+ expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["bgm"]);
500
+ expect(event.defaultPrevented).toBe(true);
501
+ });
502
+
503
+ it("leaves G to the grid toggle when nothing selected has sound", () => {
504
+ usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" });
505
+ const event = press("g");
506
+ dispatchPlainKey(event, "g", callbacks());
507
+ expect(useAudioGainDialogStore.getState().targetKeys).toBeNull();
508
+ expect(event.defaultPrevented).toBe(false);
509
+ });
510
+ });
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { isTypingTarget } from "../utils/typingTarget";
6
+ import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
5
7
  import { useCaptionStore } from "../captions/store";
6
8
  import {
7
9
  applyCaptionModelToIframe,
@@ -71,6 +73,8 @@ function tryApplyBeatHistory(
71
73
 
72
74
  interface UseAppHotkeysParams {
73
75
  handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
76
+ handleLinkEdit?: LinkShortcutCallbacks["handleLinkEdit"];
77
+ handleTimelineElementDeleteOnly?: LinkShortcutCallbacks["handleTimelineElementDeleteOnly"];
74
78
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
75
79
  handleDomEditElementDelete: (
76
80
  selection: DomEditSelection,
@@ -114,6 +118,8 @@ interface UseAppHotkeysParams {
114
118
 
115
119
  export function useAppHotkeys({
116
120
  handleTimelineElementsDelete,
121
+ handleLinkEdit,
122
+ handleTimelineElementDeleteOnly,
117
123
  handleTimelineElementSplit,
118
124
  handleDomEditElementDelete,
119
125
  domEditSelectionRef,
@@ -192,6 +198,8 @@ export function useAppHotkeys({
192
198
  const cbRef = useRef<HotkeyCallbacks>(null!);
193
199
  cbRef.current = {
194
200
  handleTimelineElementsDelete,
201
+ handleLinkEdit,
202
+ handleTimelineElementDeleteOnly,
195
203
  handleTimelineElementSplit,
196
204
  handleDomEditElementDelete,
197
205
  handleUndo,
@@ -215,6 +223,7 @@ export function useAppHotkeys({
215
223
  const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
216
224
  const cb = cbRef.current;
217
225
  const key = event.key.toLowerCase();
226
+ if (!isTypingTarget(event.target) && dispatchLinkShortcut(event, cb)) return;
218
227
  if (event.metaKey || event.ctrlKey) {
219
228
  dispatchModifierKey(event, key, cb);
220
229
  return;
@@ -443,7 +443,14 @@ describe("useDomGeometryCommits resize of a cropped element", () => {
443
443
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
444
444
  const press = (type: string, clientX: number) =>
445
445
  act(() =>
446
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })),
446
+ handle.dispatchEvent(
447
+ new PointerEvent(type, {
448
+ bubbles: true,
449
+ pointerId: 3,
450
+ buttons: type === "pointerup" ? 0 : 1,
451
+ clientX,
452
+ }),
453
+ ),
447
454
  );
448
455
  press("pointerdown", 100);
449
456
  press("pointermove", 100 - by);
@@ -10,13 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
10
  const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
11
11
  const applySoftReload = vi.fn<(...args: unknown[]) => string>();
12
12
  const trackStudioEvent = vi.fn();
13
+ const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
13
14
 
14
15
  vi.mock("./gsapRuntimePatch", () => ({
15
16
  patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
16
17
  }));
17
- vi.mock("../utils/gsapSoftReload", () => ({
18
+ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
19
+ ...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
18
20
  applySoftReload: (...args: unknown[]) => applySoftReload(...args),
19
21
  extractGsapScriptText: () => "",
22
+ readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
20
23
  }));
21
24
  vi.mock("../utils/studioTelemetry", () => ({
22
25
  trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
@@ -364,6 +367,7 @@ let cleanup: (() => void) | null = null;
364
367
  function renderCommitHook(
365
368
  options: {
366
369
  writeProjectFile?: (path: string, content: string) => Promise<void>;
370
+ iframe?: HTMLIFrameElement;
367
371
  } = {},
368
372
  ) {
369
373
  const reloadPreview = vi.fn();
@@ -378,7 +382,7 @@ function renderCommitHook(
378
382
  captured.api = useGsapScriptCommits({
379
383
  projectIdRef: { current: "proj-1" },
380
384
  activeCompPath: "index.html",
381
- previewIframeRef: { current: FAKE_IFRAME },
385
+ previewIframeRef: { current: options.iframe ?? FAKE_IFRAME },
382
386
  editHistory: { recordEdit },
383
387
  reloadPreview,
384
388
  onCacheInvalidate,
@@ -578,6 +582,7 @@ describe("runCommit — instantPatch wiring", () => {
578
582
  patchRuntimeTweenInPlace.mockReset();
579
583
  applySoftReload.mockReset();
580
584
  trackStudioEvent.mockReset();
585
+ readNestedFiles.mockReset().mockReturnValue(null);
581
586
  });
582
587
  afterEach(() => {
583
588
  cleanup?.();
@@ -585,6 +590,83 @@ describe("runCommit — instantPatch wiring", () => {
585
590
  vi.unstubAllGlobals();
586
591
  });
587
592
 
593
+ const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
594
+ const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
595
+
596
+ // A preview whose top-level timeline tweens an element written in a nested composition file.
597
+ function nestedPreview(): HTMLIFrameElement {
598
+ const doc = document.implementation.createHTMLDocument("");
599
+ doc.body.innerHTML = `<div data-composition-id="root"><div data-composition-file="compositions/sub.html"><div id="nwid"></div></div></div>`;
600
+ const nwid = doc.getElementById("nwid")!;
601
+ const tween = { targets: () => [nwid], vars: { width: 400 } };
602
+ return {
603
+ contentDocument: doc,
604
+ contentWindow: { __timelines: { root: { getChildren: () => [tween] } } },
605
+ } as unknown as HTMLIFrameElement;
606
+ }
607
+
608
+ // The mutation endpoint answers the commit; the files endpoint serves the nested file, or fails.
609
+ function mockServer(fileOk: boolean): ReturnType<typeof vi.fn> {
610
+ const body = {
611
+ ok: true,
612
+ changed: true,
613
+ before: "BEFORE",
614
+ after: "AFTER",
615
+ scriptText: NESTED_SCRIPT,
616
+ };
617
+ const fetch = vi.fn(async (url: string) =>
618
+ url.includes("/files/")
619
+ ? ({ ok: fileOk, json: async () => ({ content: SUB }) } as unknown as Response)
620
+ : ({ ok: true, json: async () => body } as unknown as Response),
621
+ );
622
+ vi.stubGlobal("fetch", fetch);
623
+ return fetch;
624
+ }
625
+
626
+ async function useRealNestedRead() {
627
+ const actual =
628
+ await vi.importActual<typeof import("../utils/gsapSoftReload")>("../utils/gsapSoftReload");
629
+ readNestedFiles.mockImplementation((...args) =>
630
+ actual.readNestedFiles(...(args as Parameters<typeof actual.readNestedFiles>)),
631
+ );
632
+ }
633
+
634
+ it("hands the soft reload the nested composition file it read for a reset element", async () => {
635
+ await useRealNestedRead();
636
+ applySoftReload.mockReturnValue("applied");
637
+ const fetch = mockServer(true);
638
+ const deps = renderCommitHook({ iframe: nestedPreview() });
639
+
640
+ await act(async () => {
641
+ await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
642
+ });
643
+
644
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
645
+ expect(applySoftReload).toHaveBeenCalledWith(
646
+ expect.anything(),
647
+ NESTED_SCRIPT,
648
+ expect.objectContaining({ nestedFiles: new Map([["compositions/sub.html", SUB]]) }),
649
+ );
650
+ });
651
+
652
+ it("reloads the preview in full when that nested file cannot be read", async () => {
653
+ await useRealNestedRead();
654
+ applySoftReload.mockReturnValue("cannot-soft-reload");
655
+ mockServer(false);
656
+ const deps = renderCommitHook({ iframe: nestedPreview() });
657
+
658
+ await act(async () => {
659
+ await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
660
+ });
661
+
662
+ expect(applySoftReload).toHaveBeenCalledWith(
663
+ expect.anything(),
664
+ NESTED_SCRIPT,
665
+ expect.objectContaining({ nestedFiles: null }),
666
+ );
667
+ expect(deps.reloadPreview).toHaveBeenCalledTimes(1);
668
+ });
669
+
588
670
  it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
589
671
  patchRuntimeTweenInPlace.mockReturnValue(true);
590
672
  mockFetchResult();
@@ -3,7 +3,12 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu
3
3
  import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
- import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
6
+ import {
7
+ applySoftReload,
8
+ extractGsapScriptText,
9
+ readNestedFiles,
10
+ settleNestedReads,
11
+ } from "../utils/gsapSoftReload";
7
12
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
14
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
@@ -142,9 +147,10 @@ function refreshMutationPreview(
142
147
  options: CommitMutationOptions,
143
148
  reloadPreview: () => void,
144
149
  onCacheInvalidate: () => void,
150
+ nestedFiles?: Map<string, string> | null,
145
151
  ): void {
146
152
  options.beforeReload?.();
147
- applyPreviewSync(iframe, result, options, reloadPreview);
153
+ applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
148
154
  onCacheInvalidate();
149
155
  }
150
156
 
@@ -168,6 +174,7 @@ function syncCommittedGsapMutation({
168
174
  forceReloadSdkSession,
169
175
  reloadPreview,
170
176
  onCacheInvalidate,
177
+ nestedFiles,
171
178
  }: {
172
179
  iframe: HTMLIFrameElement | null;
173
180
  selection: DomEditSelection;
@@ -179,6 +186,7 @@ function syncCommittedGsapMutation({
179
186
  forceReloadSdkSession?: () => void;
180
187
  reloadPreview: () => void;
181
188
  onCacheInvalidate: () => void;
189
+ nestedFiles?: Map<string, string> | null;
182
190
  }): void {
183
191
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
184
192
  // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
@@ -196,7 +204,7 @@ function syncCommittedGsapMutation({
196
204
  iframe?.contentDocument,
197
205
  );
198
206
  }
199
- refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
207
+ refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate, nestedFiles);
200
208
  }
201
209
 
202
210
  /**
@@ -219,6 +227,7 @@ function softReloadOrEscalate(
219
227
  reloadPreview: () => void,
220
228
  origin: "preview_sync" | "sdk_refresh",
221
229
  authoredHtml?: string,
230
+ nestedFiles?: Map<string, string> | null,
222
231
  ): void {
223
232
  // Seek the rebuilt timeline to the studio's own authoritative scrub position,
224
233
  // not the iframe's raw `__player.getTime()` — see the comment in
@@ -228,6 +237,7 @@ function softReloadOrEscalate(
228
237
  onAsyncFailure: reloadPreview,
229
238
  currentTimeOverride: currentTime,
230
239
  authoredHtml,
240
+ nestedFiles,
231
241
  });
232
242
  if (result === "applied") return;
233
243
  trackStudioEvent("gsap_soft_reload_outcome", {
@@ -252,6 +262,7 @@ export function applyPreviewSync(
252
262
  result: MutationResult,
253
263
  options: CommitMutationOptions,
254
264
  reloadPreview: () => void,
265
+ nestedFiles?: Map<string, string> | null,
255
266
  ): void {
256
267
  const patches = instantPatchesFor(options);
257
268
  let needsFallback = options.previewFallbackLatch?.pending === true;
@@ -297,6 +308,7 @@ export function applyPreviewSync(
297
308
  reloadPreview,
298
309
  "preview_sync",
299
310
  result.after ?? undefined,
311
+ nestedFiles,
300
312
  );
301
313
  } else {
302
314
  reloadPreview();
@@ -349,6 +361,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
349
361
  return;
350
362
  }
351
363
  await recordMutationEdit(targetPath, result, options);
364
+ const nestedFiles = await settleNestedReads(
365
+ result.scriptText
366
+ ? readNestedFiles(previewIframeRef.current, result.scriptText, (path) =>
367
+ readSharedProjectFileContent(projectId, path),
368
+ )
369
+ : null,
370
+ );
352
371
  // The durable mutation belongs to the project captured when it was queued.
353
372
  // A later project must never receive its file state or preview refresh.
354
373
  if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
@@ -363,6 +382,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
363
382
  forceReloadSdkSession,
364
383
  reloadPreview,
365
384
  onCacheInvalidate,
385
+ nestedFiles,
366
386
  });
367
387
  }, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
368
388
 
@@ -45,3 +45,79 @@ describe("undo that reloads the preview", () => {
45
45
  expect([player.elements, player.timelineReady]).toEqual([clips, true]);
46
46
  });
47
47
  });
48
+
49
+ describe("undo that re-runs the top-level script over an element of a nested composition", () => {
50
+ const script = (width: number) =>
51
+ `var tl = gsap.timeline({ paused: true }); tl.to("#nwid", { width: ${width} }); window.__timelines["root"] = tl;`;
52
+ const page = (width: number) =>
53
+ `<html><body><div id="root" data-composition-id="root"></div><script>${script(width)}</script></body></html>`;
54
+ const SUB = `<template><div data-composition-id="sub"><div id="nwid" data-hf-id="hf-n" style="left: 40px"></div></div></template>`;
55
+
56
+ // The live preview: the top-level timeline tweened the nested element's width inline.
57
+ function nestedPreview() {
58
+ const doc = document.implementation.createHTMLDocument("");
59
+ doc.body.innerHTML =
60
+ `<div id="root" data-composition-id="root"><div data-composition-file="compositions/sub.html">` +
61
+ `<div id="nwid" data-hf-id="hf-n" style="left: 40px; width: 337px"></div></div></div>` +
62
+ `<script>${script(400)}</script>`;
63
+ const nwid = doc.getElementById("nwid")!;
64
+ const contentWindow = {
65
+ gsap: { timeline: vi.fn(), set: vi.fn() },
66
+ __hfForceTimelineRebind: vi.fn(),
67
+ __timelines: {
68
+ root: {
69
+ kill: vi.fn(),
70
+ getChildren: () => [{ targets: () => [nwid], vars: { width: 400 } }],
71
+ },
72
+ } as Record<string, unknown>,
73
+ __player: { getTime: () => 1, seek: vi.fn() },
74
+ __hfStudioManualEditsApply: vi.fn(),
75
+ };
76
+ return {
77
+ iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
78
+ nwid,
79
+ };
80
+ }
81
+
82
+ async function undo(fileOk: boolean) {
83
+ const { iframe, nwid } = nestedPreview();
84
+ const reloadPreview = vi.fn();
85
+ const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
86
+ vi.stubGlobal("fetch", fetch);
87
+ usePlayerStore.getState().beginTimelineSession("p1");
88
+ let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
89
+ null;
90
+ function Harness() {
91
+ sync = usePreviewPersistence({
92
+ showToast: () => {},
93
+ readOptionalProjectFile: async () => "",
94
+ writeProjectFile: async () => {},
95
+ recordEdit: async () => {},
96
+ previewIframeRef: { current: iframe },
97
+ activeCompPathRef: { current: "index.html" },
98
+ reloadPreview,
99
+ }).syncHistoryPreviewAfterApply;
100
+ return null;
101
+ }
102
+ mountReactHarness(<Harness />);
103
+ const files = { "index.html": { previous: page(400), restored: page(450) } };
104
+ await act(async () => sync!({ paths: Object.keys(files), files }));
105
+ vi.unstubAllGlobals();
106
+ return { nwid, reloadPreview, fetch };
107
+ }
108
+
109
+ it("restores the element from its own composition file", async () => {
110
+ const { nwid, reloadPreview, fetch } = await undo(true);
111
+
112
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
113
+ expect(reloadPreview).not.toHaveBeenCalled();
114
+ expect(nwid.getAttribute("style")).toBe("left: 40px;");
115
+ });
116
+
117
+ it("reloads the preview in full when that file cannot be read", async () => {
118
+ const { nwid, reloadPreview } = await undo(false);
119
+
120
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
121
+ expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
122
+ });
123
+ });
@@ -14,9 +14,12 @@ import {
14
14
  import { trackStudioEvent } from "../utils/studioTelemetry";
15
15
  import {
16
16
  applyUndoRestoreToPreview,
17
+ readUndoNestedFiles,
17
18
  showRestoreInPlace,
18
19
  type RestoreFiles,
19
20
  } from "../utils/gsapUndoRestore";
21
+ import { settleNestedReads } from "../utils/gsapSoftReload";
22
+ import { readProjectFileContent } from "../utils/studioFileHistory";
20
23
  import { usePlayerStore } from "../player";
21
24
  import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
22
25
 
@@ -204,12 +207,24 @@ export function usePreviewPersistence({
204
207
  // attributes onto the live DOM and re-runs the timeline at the SAME playhead,
205
208
  // falling back to reloadPreview for anything structural (split/delete undo),
206
209
  // multi-file, sub-comp, or a permanent soft-reload failure.
210
+ const projectId = usePlayerStore.getState().timelineProjectId;
211
+ const nestedFiles = await settleNestedReads(
212
+ projectId
213
+ ? readUndoNestedFiles(
214
+ previewIframeRef.current,
215
+ activeCompPathRef.current,
216
+ restore.files,
217
+ (path) => readProjectFileContent(projectId, path),
218
+ )
219
+ : null,
220
+ );
207
221
  const strategy = applyUndoRestoreToPreview(
208
222
  previewIframeRef.current,
209
223
  activeCompPathRef.current,
210
224
  restore.files,
211
225
  usePlayerStore.getState().currentTime,
212
226
  reloadPreview,
227
+ nestedFiles,
213
228
  );
214
229
  if (strategy === "full") {
215
230
  usePlayerStore.getState().setSelectedElementId(null);
@@ -95,3 +95,51 @@ describe("useRazorSplit mutation versions", () => {
95
95
  container.remove();
96
96
  });
97
97
  });
98
+
99
+ describe("useRazorSplit linked clips", () => {
100
+ afterEach(() => vi.restoreAllMocks());
101
+
102
+ it("splits every member of a linked pair in one cut", async () => {
103
+ const { usePlayerStore } = await import("../player");
104
+ const clip = (id: string, tag: string): TimelineElement => ({
105
+ id,
106
+ domId: id,
107
+ tag,
108
+ start: 0,
109
+ duration: 4,
110
+ track: tag === "audio" ? 1 : 0,
111
+ link: "lk-1",
112
+ });
113
+ const video = clip("talk", "video");
114
+ usePlayerStore.getState().setElements([video, clip("talk-audio", "audio")]);
115
+ const bodies: string[] = [];
116
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
117
+ const url = String(input);
118
+ if (url.includes("/files/")) return jsonResponse({ content: "", version: '"v0"' });
119
+ bodies.push(String(init?.body ?? ""));
120
+ return jsonResponse({ error: "stop" }, 409);
121
+ });
122
+ let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
123
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
124
+ function Harness() {
125
+ split = useRazorSplit({
126
+ projectId: "p1",
127
+ activeCompPath: "index.html",
128
+ showToast: vi.fn(),
129
+ writeProjectFile: vi.fn(),
130
+ recordEdit: vi.fn(),
131
+ reloadPreview: vi.fn(),
132
+ }).handleRazorSplit;
133
+ return null;
134
+ }
135
+ await act(async () => root.render(React.createElement(Harness)));
136
+ await act(async () => {
137
+ await split?.(video, 2);
138
+ });
139
+ const targets = JSON.parse(bodies[0] ?? "{}").files[0].targets.map(
140
+ (cut: { originalId: string }) => cut.originalId,
141
+ );
142
+ expect(targets).toEqual(["talk", "talk-audio"]);
143
+ act(() => root.unmount());
144
+ });
145
+ });
@@ -4,6 +4,8 @@ import { usePlayerStore } from "../player";
4
4
  import { getTimelineElementLabel } from "../utils/studioHelpers";
5
5
  import { trackStudioRazorSplit } from "../telemetry/events";
6
6
  import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
7
+ import { linkedMembersOf } from "../player/components/audioClipLink";
8
+ import { isLinkedSelectionOn } from "../utils/linkedClipPreferences";
7
9
  import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
8
10
  import type { RecordEditInput } from "./timelineEditingHelpers";
9
11
  import { useFreezeFrame } from "./useFreezeFrame";
@@ -103,7 +105,16 @@ export function useRazorSplit({
103
105
  }
104
106
  if (!canSplitElementAt(element, splitTime)) return;
105
107
  try {
106
- const result = await runCut([element], splitTime, "single");
108
+ const members = linkedMembersOf(
109
+ element,
110
+ usePlayerStore.getState().elements,
111
+ isLinkedSelectionOn(),
112
+ );
113
+ const result = await runCut(
114
+ members.filter((member) => canSplitElementAt(member, splitTime)),
115
+ splitTime,
116
+ "single",
117
+ );
107
118
  if (!result) return;
108
119
  if (result.syncFailed) return;
109
120
  showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
@@ -196,7 +196,7 @@ describe("useRenderClipContent", () => {
196
196
  }
197
197
  });
198
198
 
199
- it("marks audio linked when a video clip uses the same file, and muted when hidden", () => {
199
+ it("marks hidden audio muted and leaves the link to the chain badge", () => {
200
200
  usePlayerStore.setState({
201
201
  thumbnailMode: "hidden",
202
202
  elements: [
@@ -217,11 +217,12 @@ describe("useRenderClipContent", () => {
217
217
  duration: 4,
218
218
  track: 1,
219
219
  src: "assets/clip.mp4",
220
+ link: "lk-1",
220
221
  hidden: true,
221
222
  });
222
- expect(isValidElement<{ linked: boolean; muted: boolean }>(linked)).toBe(true);
223
- if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) {
224
- expect(linked.props.linked).toBe(true);
223
+ expect(isValidElement<{ linked?: boolean; muted: boolean }>(linked)).toBe(true);
224
+ if (isValidElement<{ linked?: boolean; muted: boolean }>(linked)) {
225
+ expect(linked.props.linked).toBeUndefined();
225
226
  expect(linked.props.muted).toBe(true);
226
227
  }
227
228
  });
@@ -249,6 +250,7 @@ describe("useRenderClipContent", () => {
249
250
  expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
250
251
  "/api/projects/my-project/waveform/talk.mp4",
251
252
  );
253
+ expect(isValidElement<{ labelInset?: number }>(waveform) && waveform.props.labelInset).toBe(0);
252
254
  });
253
255
 
254
256
  it("keeps a muted or silent video to thumbnails only", () => {
@@ -14,6 +14,7 @@ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWavefo
14
14
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
15
15
  import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
16
16
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
17
+ import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
17
18
  import { clipHasSound } from "../player/components/clipMenuNormalize";
18
19
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
19
20
  import { usePlayerStore } from "../player/store/playerStore";
@@ -63,6 +64,7 @@ function renderAudioClip(
63
64
  labelColor: string,
64
65
  context: TimelineClipRenderContext,
65
66
  elements: readonly TimelineElement[],
67
+ labelInset?: number,
66
68
  ): ReactNode {
67
69
  const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
68
70
  const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
@@ -84,16 +86,14 @@ function renderAudioClip(
84
86
  projectId: pid,
85
87
  sessionEpoch,
86
88
  priority: context.priority,
89
+ labelInset,
87
90
  ...audioPillFlags(el, elements),
88
91
  });
89
92
  return createElement(
90
93
  ClipPeakMarks,
91
94
  {
92
- peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
93
- sourceWindow: {
94
- mediaStart: el.playbackStart ?? 0,
95
- sourceSpan: el.duration * (el.playbackRate ?? 1),
96
- },
95
+ peaksUrl: clipPeaksUrl(el.src, pid),
96
+ sourceWindow: clipSourceWindow(el),
97
97
  gain: el.volume ?? 1,
98
98
  },
99
99
  waveform,
@@ -103,10 +103,10 @@ function renderAudioClip(
103
103
  function withSoundStrip(
104
104
  el: TimelineElement,
105
105
  thumbnail: ReactNode,
106
- waveform: () => ReactNode,
106
+ waveform: (labelInset: number) => ReactNode,
107
107
  ): ReactNode {
108
108
  if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
109
- return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
109
+ return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform(0) });
110
110
  }
111
111
 
112
112
  export interface UseRenderClipContentOptions {
@@ -141,7 +141,8 @@ export function useRenderClipContent({
141
141
 
142
142
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
143
143
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
144
- const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
144
+ const waveform = (labelInset?: number) =>
145
+ renderAudioClip(el, pid, sessionEpoch, style.label, context, elements, labelInset);
145
146
  if (effectiveMode === "hidden") {
146
147
  return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
147
148
  }
@@ -10,6 +10,7 @@ import { studioWriteHeaders } from "../utils/studioFileVersion";
10
10
  import { getTimelineElementLabel } from "../utils/studioHelpers";
11
11
  import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
12
12
  import { captureDurationRollback } from "./timelineTimingSync";
13
+ import { setLinkInSource } from "../components/editor/mediaLinkEdits";
13
14
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
15
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
15
16
  import {
@@ -60,6 +61,11 @@ interface UseTimelineDeleteOpsOptions {
60
61
  // timelineGapCommit.ts.
61
62
  let deleteGestureSeq = 0;
62
63
 
64
+ function unlinkInSource(source: string, survivors: readonly TimelineElement[]): string {
65
+ const targets = survivors.map(buildPatchTarget).filter((target) => target !== null);
66
+ return targets.length > 0 ? setLinkInSource(source, targets, null) : source;
67
+ }
68
+
63
69
  export function useTimelineDeleteOps({
64
70
  projectIdRef,
65
71
  activeCompPath,
@@ -80,7 +86,7 @@ export function useTimelineDeleteOps({
80
86
  // fallow-ignore-next-line complexity
81
87
  const handleTimelineElementsDelete = useCallback(
82
88
  // fallow-ignore-next-line complexity
83
- async (selection: TimelineElement[]) => {
89
+ async (selection: TimelineElement[], alsoUnlink: readonly TimelineElement[] = []) => {
84
90
  if (isRecordingRef?.current) {
85
91
  showToast("Cannot edit timeline while recording", "error");
86
92
  return;
@@ -150,9 +156,9 @@ export function useTimelineDeleteOps({
150
156
  // Shrink to the furthest remaining clip end, read from the post-removal source:
151
157
  // store durations are runtime-truncated.
152
158
  const deleteContentEnd = furthestClipEndFromSource(removedContent);
153
- const patchedContent = setCompositionDurationToContent(
154
- removedContent,
155
- deleteContentEnd,
159
+ const patchedContent = unlinkInSource(
160
+ setCompositionDurationToContent(removedContent, deleteContentEnd),
161
+ alsoUnlink,
156
162
  );
157
163
  // Optimistically reflect the shrunk length in the readout/seek bar,
158
164
  // rolling it back if the persist below fails (see captureDurationRollback).