@hyperframes/studio 0.8.80 → 0.8.82

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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.80",
3
+ "version": "0.8.82",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,12 +52,12 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.80",
56
- "@hyperframes/parsers": "0.8.80",
57
- "@hyperframes/player": "0.8.80",
58
- "@hyperframes/sdk": "0.8.80",
59
- "@hyperframes/shader-transitions": "0.8.80",
60
- "@hyperframes/studio-server": "0.8.80"
55
+ "@hyperframes/core": "0.8.82",
56
+ "@hyperframes/parsers": "0.8.82",
57
+ "@hyperframes/player": "0.8.82",
58
+ "@hyperframes/sdk": "0.8.82",
59
+ "@hyperframes/shader-transitions": "0.8.82",
60
+ "@hyperframes/studio-server": "0.8.82"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@tailwindcss/vite": "^4.3.3",
@@ -73,7 +73,7 @@
73
73
  "vite": "^6.4.2",
74
74
  "vitest": "^4.1.11",
75
75
  "zustand": "^5.0.0",
76
- "@hyperframes/producer": "0.8.80"
76
+ "@hyperframes/producer": "0.8.82"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "react": "19",
package/src/App.tsx CHANGED
@@ -344,8 +344,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
344
344
  effectiveTimelineDuration,
345
345
  });
346
346
  const compositionDimensions = useCompositionDimensions(previewIframeRef);
347
- const { lintModal, linting, handleLint, closeLintModal, findingsByFile, hasLintError } =
348
- useLintModal(projectId, refreshKey);
347
+ const lint = useLintModal(projectId, refreshKey);
349
348
  const frameCapture = useFrameCapture({
350
349
  projectId,
351
350
  activeCompPath,
@@ -500,11 +499,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
500
499
  onSelectComposition={handleSelectComposition}
501
500
  onAddBlock={handleAddBlock}
502
501
  onPreviewBlock={setBlockPreview}
503
- onLint={handleLint}
504
- linting={linting}
505
- lintFindingCount={lintModal?.length ?? findingsByFile.size}
506
- lintFindingsByFile={findingsByFile}
507
- lintHasError={hasLintError}
502
+ onLint={lint.handleLint}
503
+ linting={lint.linting}
504
+ lintFindingCount={lint.lintFindingCount}
505
+ lintFindingsByFile={lint.findingsByFile}
506
+ lintHasError={lint.hasLintError}
508
507
  onAddAssetToTimeline={handleAddAssetAtPlayhead}
509
508
  onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
510
509
  />
@@ -577,8 +576,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
577
576
  <StudioOverlays
578
577
  projectId={projectId}
579
578
  projectDir={fileManager.projectDir}
580
- lintModal={lintModal}
581
- closeLintModal={closeLintModal}
579
+ lintModal={lint.lintModal}
580
+ closeLintModal={lint.closeLintModal}
582
581
  consoleErrors={consoleErrors}
583
582
  clearConsoleErrors={() => setConsoleErrors(null)}
584
583
  domEditSession={domEditSession}
@@ -23,14 +23,10 @@ import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/Stu
23
23
  import { useFileManagerContext } from "../contexts/FileManagerContext";
24
24
  import { useDomEditContext } from "../contexts/DomEditContext";
25
25
  import { usePlayerStore } from "../player";
26
- import {
27
- applyColorGradingScopeUpdate,
28
- EMPTY_COLOR_GRADING_SCOPE_RESULT,
29
- type ColorGradingScope,
30
- } from "./studioColorGradingScope";
31
26
  import { timelineKeysForSelections } from "../utils/studioHelpers";
32
27
  import { canHideSelections } from "../utils/timelineInspector";
33
28
  import { useRemoveBackground } from "../hooks/useRemoveBackground";
29
+ import { useApplyColorGradingScope } from "../hooks/useApplyColorGradingScope";
34
30
 
35
31
  // fallow-ignore-next-line complexity
36
32
  export function StudioRightPanels({
@@ -49,14 +45,8 @@ export function StudioRightPanels({
49
45
  onAutoGroupCarveSources,
50
46
  onAddMediaOverlay,
51
47
  }: StudioRightPanelsProps) {
52
- const {
53
- previewIframeRef,
54
- projectId,
55
- activeCompPath,
56
- showToast,
57
- waitForPendingDomEditSaves,
58
- renderQueue,
59
- } = useStudioShellContext();
48
+ const { previewIframeRef, projectId, activeCompPath, showToast, renderQueue } =
49
+ useStudioShellContext();
60
50
  const { captionEditMode, refreshKey } = useStudioPlaybackContext();
61
51
 
62
52
  const {
@@ -114,7 +104,6 @@ export function StudioRightPanels({
114
104
  refreshFileTree,
115
105
  readProjectFile,
116
106
  writeProjectFile,
117
- fileTree,
118
107
  editingFile,
119
108
  } = useFileManagerContext();
120
109
 
@@ -158,40 +147,7 @@ export function StudioRightPanels({
158
147
  });
159
148
  useCaptionDesignFocus(captionEditMode);
160
149
 
161
- const handleApplyColorGradingScope = useCallback(
162
- async (scope: ColorGradingScope, value: string | null) =>
163
- applyColorGradingScopeUpdate({
164
- scope,
165
- value,
166
- selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
167
- fileTree,
168
- projectId,
169
- waitForPendingDomEditSaves,
170
- readProjectFile,
171
- writeProjectFile,
172
- recordEdit,
173
- reloadPreview,
174
- showToast,
175
- }).catch((error) => {
176
- showToast(
177
- `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
178
- "error",
179
- );
180
- return EMPTY_COLOR_GRADING_SCOPE_RESULT;
181
- }),
182
- [
183
- activeCompPath,
184
- domEditSelection?.sourceFile,
185
- fileTree,
186
- projectId,
187
- readProjectFile,
188
- recordEdit,
189
- reloadPreview,
190
- showToast,
191
- waitForPendingDomEditSaves,
192
- writeProjectFile,
193
- ],
194
- );
150
+ const handleApplyColorGradingScope = useApplyColorGradingScope(recordEdit, reloadPreview);
195
151
 
196
152
  const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
197
153
 
@@ -16,8 +16,10 @@ const handleUndo = vi.fn();
16
16
  const handleRedo = vi.fn();
17
17
  const trackStudioEvent = vi.fn();
18
18
 
19
+ const studioShell = { editHistory, handleUndo, handleRedo };
20
+ let shell: typeof studioShell | null = studioShell;
19
21
  vi.mock("../contexts/StudioContext", () => ({
20
- useStudioShellContext: () => ({ editHistory, handleUndo, handleRedo }),
22
+ useStudioShellContextOptional: () => shell,
21
23
  }));
22
24
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent }));
23
25
 
@@ -31,6 +33,7 @@ beforeEach(() => {
31
33
  undoLabel: undefined,
32
34
  redoLabel: undefined,
33
35
  });
36
+ shell = studioShell;
34
37
  vi.clearAllMocks();
35
38
  });
36
39
 
@@ -115,3 +118,37 @@ it("classifies Undo and Redo for the hotkey filters at their new location (KTD13
115
118
  expect(shouldIgnorePlaybackShortcutTarget(el), label).toBe(true);
116
119
  }
117
120
  });
121
+
122
+ it("takes a host's history props over the shell's", async () => {
123
+ editHistory.canUndo = true;
124
+ editHistory.redoLabel = "shell move";
125
+ const onUndo = vi.fn();
126
+ const onRedo = vi.fn();
127
+ const host = mount(
128
+ <TimelineHistoryButtons
129
+ canUndo={false}
130
+ canRedo
131
+ redoLabel="host trim"
132
+ onUndo={onUndo}
133
+ onRedo={onRedo}
134
+ />,
135
+ );
136
+
137
+ expect(button(host, "Undo").disabled).toBe(true);
138
+ act(() => button(host, "Redo").focus());
139
+ await act(async () => {
140
+ await new Promise((resolve) => setTimeout(resolve, 0));
141
+ });
142
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toContain("Redo host trim");
143
+ click(button(host, "Redo"));
144
+
145
+ expect(onRedo).toHaveBeenCalledTimes(1);
146
+ expect(handleRedo).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("keeps a button disabled when it has no handler, whatever canUndo/canRedo say", () => {
150
+ shell = null;
151
+ const host = mount(<TimelineHistoryButtons canUndo canRedo />);
152
+
153
+ for (const label of ["Undo", "Redo"]) expect(button(host, label).disabled).toBe(true);
154
+ });
@@ -1,4 +1,4 @@
1
- import { useStudioShellContext } from "../contexts/StudioContext";
1
+ import { useStudioShellContextOptional } from "../contexts/StudioContext";
2
2
  import { RotateCcw, RotateCw } from "../icons/SystemIcons";
3
3
  import { trackStudioEvent } from "../utils/studioTelemetry";
4
4
  import { historyTooltipLabel } from "../utils/studioHelpers";
@@ -7,13 +7,14 @@ import { Tooltip } from "./ui";
7
7
 
8
8
  interface HistoryButtonProps {
9
9
  action: "undo" | "redo";
10
- enabled: boolean;
10
+ can: boolean;
11
11
  label: string | undefined;
12
- onClick: () => Promise<void> | void;
12
+ onClick: (() => Promise<void> | void) | undefined;
13
13
  }
14
14
 
15
- function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps) {
15
+ function HistoryButton({ action, can, label, onClick }: HistoryButtonProps) {
16
16
  const Icon = action === "undo" ? RotateCcw : RotateCw;
17
+ const enabled = Boolean(onClick) && can;
17
18
  return (
18
19
  <Tooltip label={historyTooltipLabel(action, label)}>
19
20
  <button
@@ -23,7 +24,7 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
23
24
  className={enabled ? flatIdle : flatDisabled}
24
25
  onClick={() => {
25
26
  trackStudioEvent("toolbar_action", { action });
26
- void onClick();
27
+ void onClick?.();
27
28
  }}
28
29
  >
29
30
  <Icon size={16} />
@@ -32,22 +33,33 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
32
33
  );
33
34
  }
34
35
 
35
- /** Undo and Redo, on the one edit-history path the shell already owns. */
36
- export function TimelineHistoryButtons() {
37
- const { editHistory, handleUndo, handleRedo } = useStudioShellContext();
36
+ export interface TimelineHistoryButtonsProps {
37
+ canUndo?: boolean;
38
+ canRedo?: boolean;
39
+ undoLabel?: string;
40
+ redoLabel?: string;
41
+ onUndo?: () => Promise<void> | void;
42
+ onRedo?: () => Promise<void> | void;
43
+ }
44
+
45
+ /** Undo and Redo: a host's props win, else the shell's edit history. */
46
+ export function TimelineHistoryButtons(props: TimelineHistoryButtonsProps) {
47
+ const shell = useStudioShellContextOptional();
48
+ const onUndo = props.onUndo ?? shell?.handleUndo;
49
+ const onRedo = props.onRedo ?? shell?.handleRedo;
38
50
  return (
39
51
  <>
40
52
  <HistoryButton
41
53
  action="undo"
42
- enabled={editHistory.canUndo}
43
- label={editHistory.undoLabel}
44
- onClick={handleUndo}
54
+ can={props.canUndo ?? shell?.editHistory.canUndo ?? false}
55
+ label={props.undoLabel ?? shell?.editHistory.undoLabel}
56
+ onClick={onUndo}
45
57
  />
46
58
  <HistoryButton
47
59
  action="redo"
48
- enabled={editHistory.canRedo}
49
- label={editHistory.redoLabel}
50
- onClick={handleRedo}
60
+ can={props.canRedo ?? shell?.editHistory.canRedo ?? false}
61
+ label={props.redoLabel ?? shell?.editHistory.redoLabel}
62
+ onClick={onRedo}
51
63
  />
52
64
  </>
53
65
  );
@@ -12,7 +12,7 @@ import { AudioMeterStrip } from "./nle/AudioMeterStrip";
12
12
  import { TimelineToolbar } from "./TimelineToolbar";
13
13
 
14
14
  vi.mock("../contexts/StudioContext", () => ({
15
- useStudioShellContext: () => ({
15
+ useStudioShellContextOptional: () => ({
16
16
  previewIframeRef: { current: null },
17
17
  editHistory: { canUndo: false, canRedo: false },
18
18
  handleUndo: vi.fn(),
@@ -214,3 +214,11 @@ describe("TimelineToolbar audio meters", () => {
214
214
  }
215
215
  });
216
216
  });
217
+
218
+ describe("TimelineToolbar add beat", () => {
219
+ it("shows Add beat by default, as Studio does", () => {
220
+ const { host, root } = renderToolbar();
221
+ expect(host.querySelector('button[aria-label="Add beat at playhead"]')).not.toBeNull();
222
+ act(() => root.unmount());
223
+ });
224
+ });
@@ -19,7 +19,7 @@ import { Tooltip } from "./ui";
19
19
  import { AudioMetersIcon } from "./icons/AudioMetersIcon";
20
20
  import { RippleEditIcon } from "./icons/RippleEditIcon";
21
21
  import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
22
- import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
22
+ import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
23
23
  import { Scissors } from "../icons/SystemIcons";
24
24
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
25
25
  import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -33,9 +33,11 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
33
33
  selectedGsapAnimations: GsapAnimation[];
34
34
  }
35
35
 
36
- interface TimelineToolbarProps {
36
+ export interface TimelineToolbarProps {
37
37
  domEditSession?: DomEditSessionSlice;
38
38
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
39
+ history?: TimelineHistoryButtonsProps;
40
+ showAddBeat?: boolean;
39
41
  }
40
42
 
41
43
  interface KeyframeToggleState {
@@ -127,7 +129,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
127
129
  }
128
130
 
129
131
  // fallow-ignore-next-line complexity
130
- export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
132
+ export function TimelineToolbar({
133
+ domEditSession,
134
+ onSplitElement,
135
+ history,
136
+ showAddBeat = true,
137
+ }: TimelineToolbarProps) {
131
138
  const activeTool = usePlayerStore((s) => s.activeTool);
132
139
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
133
140
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
@@ -195,7 +202,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
195
202
  <div className="border-b border-neutral-800/60">
196
203
  <div className="flex items-center justify-between px-2 py-0.5">
197
204
  <div className="flex items-center gap-0.5">
198
- <TimelineHistoryButtons />
205
+ <TimelineHistoryButtons {...history} />
199
206
  <Tooltip label="Selection tool (V)">
200
207
  <button
201
208
  type="button"
@@ -416,7 +423,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
416
423
  );
417
424
  })()}
418
425
  {(() => {
419
- // Always rendered (CapCut-style): before beat analysis loads (or when
426
+ if (!showAddBeat) return null;
427
+ // Rendered whenever shown (CapCut-style): before beat analysis loads (or when
420
428
  // the project has no analyzed music) the button fades to a disabled
421
429
  // state instead of unmounting, so the toolbar layout never shifts.
422
430
  const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
@@ -3,6 +3,7 @@ import type { BlockParam } from "@hyperframes/core/registry";
3
3
  import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
4
4
  import { useStudioPlaybackContext } from "../../contexts/StudioContext";
5
5
  import { trackBlockParamCommit } from "../../telemetry/events";
6
+ import { serializeStudioFileMutation } from "../../utils/studioFileMutationCoordinator";
6
7
 
7
8
  interface BlockParamsPanelProps {
8
9
  blockName: string;
@@ -77,7 +78,11 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
77
78
  trackBlockParamCommit({ tone: "error", blockName, key });
78
79
  return;
79
80
  }
80
- await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
81
+ await fileManager.writeProjectFile(
82
+ compositionPath,
83
+ content.replace(matcher, nextValue),
84
+ content,
85
+ );
81
86
  appliedRef.current[key] = nextValue;
82
87
  setCommitState({ tone: "saved" });
83
88
  trackBlockParamCommit({ tone: "saved", blockName, key });
@@ -90,16 +95,15 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
90
95
  [fileManager, compositionPath, setRefreshKey, blockName],
91
96
  );
92
97
 
93
- // Commits are serialized: two params committing concurrently would each
94
- // read-modify-write the same file and the second write would drop the first.
95
- const commitChainRef = useRef<Promise<void>>(Promise.resolve());
98
+ // Commits join the file's queue: two read-modify-writes of one file must not interleave.
96
99
  const commitParam = useCallback(
97
- (key: string, nextValue: string) => {
98
- const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
99
- commitChainRef.current = run.catch(() => undefined);
100
- return run;
101
- },
102
- [commitParamNow],
100
+ (key: string, nextValue: string) =>
101
+ fileManager
102
+ ? serializeStudioFileMutation(fileManager.writeProjectFile, compositionPath, () =>
103
+ commitParamNow(key, nextValue),
104
+ )
105
+ : Promise.resolve(),
106
+ [commitParamNow, compositionPath, fileManager],
103
107
  );
104
108
 
105
109
  const handleChange = useCallback(
@@ -1,11 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import {
4
- applyStudioBoxSize,
5
- applyStudioPathOffset,
6
- readStudioBoxSize,
7
- reapplyPositionEditsAfterSeek,
8
- } from "./manualEditsDom";
3
+ import { applyStudioBoxSize, applyStudioPathOffset, readStudioBoxSize } from "./manualEditsDom";
4
+ import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
9
5
  import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10
6
  import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11
7
  import { computeNextResizeAnchor } from "./domEditResizeLocal";
@@ -432,6 +432,30 @@ describe("studio manual edits", () => {
432
432
  expect(applied).toBe(11);
433
433
  });
434
434
 
435
+ it("reapplies once after a seek that applied, and again once a held seek lands", async () => {
436
+ const previewWindow = new Window() as unknown as Parameters<
437
+ typeof installStudioManualEditSeekReapply
438
+ >[0] & { __player: Record<string, unknown> };
439
+ let landed = () => {};
440
+ const held = new Promise<void>((resolve) => (landed = resolve));
441
+ previewWindow.__player = { seek: (time: number) => (time > 5 ? held : undefined) };
442
+ let applied = 0;
443
+ installStudioManualEditSeekReapply(previewWindow, () => {
444
+ applied += 1;
445
+ });
446
+ const seek = previewWindow.__player.seek as (time: number) => unknown;
447
+
448
+ seek(1);
449
+ await Promise.resolve();
450
+ expect(applied).toBe(1);
451
+ expect(seek(5.5)).toBe(held);
452
+ expect(applied).toBe(2);
453
+ landed();
454
+ await held;
455
+ await Promise.resolve();
456
+ expect(applied).toBe(3);
457
+ });
458
+
435
459
  it("reapplies manual edits while fresh playback is active", () => {
436
460
  const window = new Window();
437
461
  const frames: FrameRequestCallback[] = [];
@@ -30,9 +30,10 @@ export {
30
30
  applyStudioBoxSizeDraft,
31
31
  applyStudioRotation,
32
32
  applyStudioRotationDraft,
33
- reapplyPositionEditsAfterSeek,
34
33
  } from "./manualEditsDom";
35
34
 
35
+ export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
36
+
36
37
  export {
37
38
  captureStudioBoxSize,
38
39
  captureStudioRotation,
@@ -78,6 +79,7 @@ function wrapSeekReapplyFunction(
78
79
  win: StudioManualEditSeekWindow,
79
80
  owner: Record<string, unknown> | undefined,
80
81
  key: string,
82
+ reapplyWhenHeldSeekLands = false,
81
83
  ): boolean {
82
84
  const fn = owner?.[key];
83
85
  if (!owner || typeof fn !== "function") return false;
@@ -87,6 +89,8 @@ function wrapSeekReapplyFunction(
87
89
  const wrappedSeek = function (this: unknown, ...args: unknown[]): unknown {
88
90
  const result = seek.apply(this, args);
89
91
  win.__hfStudioManualEditsApply?.();
92
+ const held = reapplyWhenHeldSeekLands ? (result as PromiseLike<void> | undefined) : undefined;
93
+ if (typeof held?.then === "function") void held.then(() => win.__hfStudioManualEditsApply?.());
90
94
  return result;
91
95
  };
92
96
  markWrapped(wrappedSeek);
@@ -256,7 +260,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
256
260
  studioWin[STUDIO_MANUAL_EDITS_APPLY_PROP] = apply;
257
261
 
258
262
  const wrappedHfSeek = wrapSeekReapplyFunction(studioWin, studioWin.__hf, "seek");
259
- const wrappedPlayerSeek = wrapSeekReapplyFunction(studioWin, studioWin.__player, "seek");
263
+ const wrappedPlayerSeek = wrapSeekReapplyFunction(studioWin, studioWin.__player, "seek", true);
260
264
  const wrappedPlayerRenderSeek = wrapSeekReapplyFunction(
261
265
  studioWin,
262
266
  studioWin.__player,
@@ -33,7 +33,6 @@ import {
33
33
  STUDIO_ROTATION_TRANSFORM_ORIGIN,
34
34
  } from "./manualEditsTypes";
35
35
  import { roundRotationAngle } from "./manualEditsParsing";
36
- import { applyStudioMotionFromDom } from "./studioMotion";
37
36
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
38
37
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
39
38
 
@@ -533,67 +532,3 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
533
532
  composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`),
534
533
  );
535
534
  }
536
-
537
- /* ── Seek reapply (position + motion) ────────────────────────────── */
538
- function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
539
- const ctor = doc.defaultView?.HTMLElement;
540
- if (!ctor) return [];
541
- const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
542
- (el): el is HTMLElement => el instanceof ctor,
543
- );
544
- // Handle legacy HTML files where attributes were persisted with a double data- prefix
545
- const legacyAttr = `data-${attr}`;
546
- for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
547
- if (el instanceof ctor && !el.hasAttribute(attr)) {
548
- el.setAttribute(attr, "true");
549
- el.removeAttribute(legacyAttr);
550
- elements.push(el);
551
- }
552
- }
553
- return elements;
554
- }
555
-
556
- function reapplyPathOffsets(doc: Document): void {
557
- for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
558
- // Unlike size below, the offset channels COMPOSE — applying both doubles the move.
559
- if (gsapAnimatesProperty(el, "x", "y")) continue;
560
- const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
561
- const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
562
- if (!x && !y) continue;
563
- const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
564
- applyStudioPathOffset(el, offset, { updateBase: false });
565
- }
566
- }
567
-
568
- /**
569
- * Put the studio's committed size back after a seek, GSAP-sized elements included.
570
- * Size does not compose the way the offset above does: both channels write width
571
- * and height, so the later write wins on the same number. Standing aside meant
572
- * nothing held the size while a soft reload reverted the old timeline (GSAP hands
573
- * back each tween's recorded starting width), so the element sat at its stylesheet
574
- * size until the new one rendered — the jump after a resize.
575
- */
576
- function reapplyBoxSizes(doc: Document): void {
577
- for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
578
- const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
579
- const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
580
- if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
581
- applyStudioBoxSize(el, { width: w, height: h });
582
- }
583
- }
584
- }
585
- function reapplyRotations(doc: Document): void {
586
- for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
587
- const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
588
- if (Number.isFinite(angle)) {
589
- applyStudioRotation(el, { angle });
590
- }
591
- }
592
- }
593
-
594
- export function reapplyPositionEditsAfterSeek(doc: Document): void {
595
- reapplyPathOffsets(doc);
596
- reapplyBoxSizes(doc);
597
- reapplyRotations(doc);
598
- applyStudioMotionFromDom(doc);
599
- }