@hyperframes/studio 0.8.119 → 0.8.120

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 (29) hide show
  1. package/dist/assets/{hyperframes-player-7Icl2uVN.js → hyperframes-player-i4gqkhjz.js} +1 -1
  2. package/dist/assets/{index-Dc8FEIEU.js → index-C7LHDM18.js} +1 -1
  3. package/dist/assets/{index-DOO2cYhl.js → index-Ce3Lr-Gt.js} +174 -174
  4. package/dist/assets/{index-7YcUqkoh.js → index-uWOhRqxe.js} +1 -1
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +72 -87
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/EditorShell.tsx +6 -0
  11. package/src/components/nle/TimelinePane.tsx +3 -0
  12. package/src/hooks/fetchStubTestUtils.ts +3 -5
  13. package/src/hooks/gsapMutationClient.ts +17 -8
  14. package/src/hooks/gsapScriptCommitHelpers.ts +2 -64
  15. package/src/hooks/newTweenTarget.test.ts +3 -0
  16. package/src/hooks/useDomEditCommitsHelpers.test.ts +116 -0
  17. package/src/hooks/useDomEditCommitsHelpers.ts +44 -0
  18. package/src/hooks/useGsapAnimationOps.test.tsx +181 -1
  19. package/src/hooks/useGsapAnimationOps.ts +13 -12
  20. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  21. package/src/hooks/useGsapScriptCommits.ts +6 -32
  22. package/src/player/components/ClipContextMenu.test.tsx +31 -0
  23. package/src/player/components/ClipContextMenu.tsx +4 -1
  24. package/src/player/components/TimelineOverlays.test.ts +18 -0
  25. package/src/player/components/TimelineOverlays.tsx +1 -0
  26. package/src/player/components/TimelineProvider.tsx +1 -0
  27. package/src/player/components/TimelineTypes.ts +1 -0
  28. package/src/player/components/studioShortcuts.ts +3 -1
  29. package/src/player/components/useTimelineProviderState.tsx +2 -0
@@ -14,10 +14,9 @@ import {
14
14
  cutoverCommittedOrThrow,
15
15
  type CutoverDeps,
16
16
  } from "../utils/sdkCutover";
17
- import {
18
- assignGsapTargetAutoIdIfNeeded,
19
- ensureElementAddressable,
20
- } from "./gsapScriptCommitHelpers";
17
+ import { ensureElementAddressable } from "./gsapScriptCommitHelpers";
18
+ import { idSelector } from "./gsapShared";
19
+ import { assignGsapTargetAutoIdIfNeeded } from "./useDomEditCommitsHelpers";
21
20
  import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
22
21
 
23
22
  interface SdkAnimationDeps {
@@ -122,12 +121,14 @@ export function useGsapAnimationOps({
122
121
  method: "to" | "from" | "set" | "fromTo",
123
122
  _currentTime?: number,
124
123
  ) => {
125
- const { selector, autoId } = ensureElementAddressable(selection);
124
+ const address = ensureElementAddressable(selection);
125
+ const { autoId } = address;
126
+ let selector = address.selector;
126
127
 
127
128
  if (autoId) {
128
129
  const pid = projectIdRef.current;
129
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
130
130
  if (!pid) return;
131
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
131
132
  const assign = () =>
132
133
  assignGsapTargetAutoIdIfNeeded({
133
134
  projectId: pid,
@@ -136,10 +137,12 @@ export function useGsapAnimationOps({
136
137
  autoId,
137
138
  showToast,
138
139
  });
139
- const assigned = await (writeProjectFile
140
+ const savedId = await (writeProjectFile
140
141
  ? serializeStudioFileMutation(writeProjectFile, targetPath, assign)
141
142
  : assign());
142
- if (!assigned) return;
143
+ if (!savedId) return;
144
+ selection.element.setAttribute("id", savedId);
145
+ selector = idSelector(savedId);
143
146
  }
144
147
 
145
148
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
@@ -153,10 +156,8 @@ export function useGsapAnimationOps({
153
156
  fromTo: { x: 0, y: 0, opacity: 1 },
154
157
  };
155
158
 
156
- // Skip SDK path when an id was just assigned server-side (autoId): the
157
- // SDK session hasn't reloaded that write yet, so persisting its
158
- // serialization would clobber the new id — let the server add the tween
159
- // atomically with the id it wrote.
159
+ // After an id write (autoId) the SDK session has not reloaded the file yet,
160
+ // so persisting its serialization would drop the new id.
160
161
  if (!autoId && selection.hfId && sdkSession && sdkDeps) {
161
162
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
162
163
  const spec = {
@@ -36,6 +36,7 @@ import type {
36
36
  MutationResult,
37
37
  } from "./gsapScriptCommitTypes";
38
38
  import { persistSdkSerialize } from "../utils/sdkCutover";
39
+ import { jsonResponse } from "./fetchStubTestUtils";
39
40
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
40
41
  import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
41
42
 
@@ -715,6 +716,22 @@ describe("runCommit — instantPatch wiring", () => {
715
716
  expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
716
717
  });
717
718
 
719
+ it("rejects a refused write with the server's reason in a toast", async () => {
720
+ vi.stubGlobal(
721
+ "fetch",
722
+ vi.fn(async () => jsonResponse({ error: "file changed on disk" }, 409)),
723
+ );
724
+ const deps = renderCommitHook();
725
+
726
+ await expect(
727
+ deps.api.commitMutation(selection, { type: "add-keyframe" }, { label: "Add" }),
728
+ ).rejects.toThrow();
729
+ expect(deps.showToast).toHaveBeenCalledWith(
730
+ expect.stringContaining("file changed on disk"),
731
+ "error",
732
+ );
733
+ });
734
+
718
735
  const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
719
736
  const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
720
737
 
@@ -867,6 +884,8 @@ describe("runCommit — instantPatch wiring", () => {
867
884
  body: JSON.stringify({ mutations: [firstMutation, lastMutation] }),
868
885
  }),
869
886
  );
887
+ const [, init] = vi.mocked(fetch).mock.calls[0]!;
888
+ expect(new Headers(init?.headers).get("X-Hyperframes-Write-Token")).toBeTruthy();
870
889
  expect(deps.recordEdit).toHaveBeenCalledTimes(1);
871
890
  expect(deps.recordEdit).toHaveBeenCalledWith(
872
891
  expect.objectContaining({ label: "Resize", coalesceKey: "tx:resize:1" }),
@@ -44,41 +44,15 @@ import {
44
44
  useGsapSaveFailureTelemetry,
45
45
  useSafeGsapCommitMutation,
46
46
  } from "./useSafeGsapCommitMutation";
47
- import { studioWriteHeaders } from "../utils/studioFileVersion";
48
- import { studioApiFetch } from "../utils/studioApiFetch";
47
+ import { requestGsapMutation } from "./gsapMutationClient";
49
48
 
50
49
  async function mutateGsapScript(
51
50
  projectId: string,
51
+ route: "gsap-mutations" | "gsap-mutations-batch",
52
52
  sourceFile: string,
53
- mutation: Record<string, unknown>,
53
+ body: unknown,
54
54
  ): Promise<MutationResult> {
55
- const res = await studioApiFetch(
56
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
57
- {
58
- method: "POST",
59
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
60
- body: JSON.stringify(mutation),
61
- },
62
- );
63
- if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res));
64
- const result = (await res.json()) as MutationResult;
65
- if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`);
66
- return result;
67
- }
68
-
69
- async function mutateGsapScriptBatch(
70
- projectId: string,
71
- sourceFile: string,
72
- mutations: Record<string, unknown>[],
73
- ): Promise<MutationResult> {
74
- const res = await studioApiFetch(
75
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
76
- {
77
- method: "POST",
78
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
79
- body: JSON.stringify({ mutations }),
80
- },
81
- );
55
+ const res = await requestGsapMutation(projectId, route, sourceFile, body);
82
56
  if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res));
83
57
  const result = (await res.json()) as MutationResult;
84
58
  if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`);
@@ -392,7 +366,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
392
366
 
393
367
  const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
394
368
  const result = await runMutationRequest([mutation], options, showToast, () =>
395
- mutateGsapScript(pid, targetPath, mutation),
369
+ mutateGsapScript(pid, "gsap-mutations", targetPath, mutation),
396
370
  );
397
371
  if (!result) return;
398
372
  trackKeyframeCommit([mutation], result, options);
@@ -406,7 +380,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
406
380
  if (!first || !last) return;
407
381
  const mutations = calls.map(({ mutation }) => mutation);
408
382
  const result = await runMutationRequest(mutations, options, showToast, () =>
409
- mutateGsapScriptBatch(pid, targetPath, mutations),
383
+ mutateGsapScript(pid, "gsap-mutations-batch", targetPath, { mutations }),
410
384
  );
411
385
  if (!result) return;
412
386
  trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
@@ -160,6 +160,37 @@ describe("ClipContextMenu tools", () => {
160
160
  return { labels, unmount: () => act(() => root.unmount()) };
161
161
  }
162
162
 
163
+ function splitRow(splitShortcut?: string) {
164
+ const host = document.body.appendChild(document.createElement("div"));
165
+ const root = createRoot(host);
166
+ act(() =>
167
+ root.render(
168
+ <ClipContextMenu
169
+ x={10}
170
+ y={10}
171
+ element={video}
172
+ currentTime={1}
173
+ onClose={vi.fn()}
174
+ onSplit={vi.fn()}
175
+ onDelete={vi.fn()}
176
+ splitShortcut={splitShortcut}
177
+ />,
178
+ ),
179
+ );
180
+ const row = Array.from(document.body.querySelectorAll("[role=menuitem]")).find((item) =>
181
+ item.textContent?.startsWith("Split"),
182
+ );
183
+ const text = row?.textContent;
184
+ act(() => root.unmount());
185
+ host.remove();
186
+ return text;
187
+ }
188
+
189
+ it("hints Split with Studio's own S, or with the key the host binds Split to", () => {
190
+ expect(splitRow()).toBe("Split at 1.00sS");
191
+ expect(splitRow("⌥S")).toBe("Split at 1.00s⌥S");
192
+ });
193
+
163
194
  it("offers Freeze frame and the attribute tools by default", () => {
164
195
  const { labels, unmount } = menuWith(undefined);
165
196
  expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(true);
@@ -10,6 +10,7 @@ import { ClipMenuAudioItems } from "./clipMenuAudioItems";
10
10
  import { ClipMenuLinkItems } from "./clipMenuLinkItems";
11
11
  import { menuClasses } from "../../components/ui/menuStyle";
12
12
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
13
+ import { SPLIT_SHORTCUT_HINT } from "./studioShortcuts";
13
14
 
14
15
  const MENU_MARGIN = 8;
15
16
 
@@ -40,6 +41,7 @@ interface ClipContextMenuProps {
40
41
  onDuplicate?: () => Promise<boolean>;
41
42
  canPaste?: boolean;
42
43
  hostItems?: readonly TimelineClipMenuItem[] | undefined;
44
+ splitShortcut?: string | undefined;
43
45
  }
44
46
 
45
47
  const itemClass = (enabled: boolean) =>
@@ -95,6 +97,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
95
97
  onDuplicate,
96
98
  canPaste,
97
99
  hostItems = [],
100
+ splitShortcut = SPLIT_SHORTCUT_HINT,
98
101
  }: ClipContextMenuProps) {
99
102
  const menuRef = useContextMenuDismiss(onClose);
100
103
  useMenuKeyboardNav(menuRef);
@@ -157,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
157
160
  }}
158
161
  >
159
162
  <span>{splitLabel}</span>
160
- <span className="text-neutral-500 text-[10px] ml-3">S</span>
163
+ <span className="text-neutral-500 text-[10px] ml-3">{splitShortcut}</span>
161
164
  </button>
162
165
  )}
163
166
  {tools && splitLabel && (
@@ -216,4 +216,22 @@ describe("TimelineOverlays context lifecycle", () => {
216
216
  expect(clipMenuItems).toHaveBeenCalledExactlyOnceWith(current);
217
217
  expect(document.body.querySelector('[role="menuitem"]')?.textContent).toBe("Ask");
218
218
  });
219
+
220
+ it("hints Split in the clip menu with the host's key", () => {
221
+ const video = { ...captured, domId: "child", tag: "video" };
222
+ renderOverlay({
223
+ capturedElement: video,
224
+ currentElement: video,
225
+ overlays: {
226
+ kfContextMenu: null,
227
+ clipContextMenu: { x: 10, y: 10, sessionEpoch: 2, element: video },
228
+ currentTime: 2,
229
+ splitShortcut: "⌥S",
230
+ },
231
+ });
232
+ const rows = [...document.body.querySelectorAll('[role="menuitem"]')].map(
233
+ (row) => row.textContent,
234
+ );
235
+ expect(rows).toContain("Split at 2.00s⌥S");
236
+ });
219
237
  });
@@ -190,6 +190,7 @@ export function TimelineClipMenuOverlay() {
190
190
  onDuplicate={overlay.onDuplicateClip}
191
191
  canPaste={overlay.canPasteClip?.() ?? false}
192
192
  hostItems={hostItems}
193
+ splitShortcut={overlay.splitShortcut}
193
194
  />
194
195
  );
195
196
  }
@@ -112,6 +112,7 @@ export interface TimelineOverlaysState {
112
112
  onDuplicateClip?: () => Promise<boolean>;
113
113
  canPasteClip?: () => boolean;
114
114
  clipMenuItems?: TimelineProps["clipMenuItems"];
115
+ splitShortcut?: TimelineProps["splitShortcut"];
115
116
  gapContextMenu: TrackGapContextMenuState | null;
116
117
  onDismissGapContextMenu: () => void;
117
118
  onCloseTrackGap: () => void;
@@ -43,6 +43,7 @@ export interface TimelineProps
43
43
  onDuplicateClip?: () => Promise<boolean>;
44
44
  canPasteClip?: () => boolean;
45
45
  clipMenuItems?: (element: TimelineElement) => readonly TimelineClipMenuItem[];
46
+ splitShortcut?: string;
46
47
  theme?: Partial<TimelineTheme>;
47
48
  showAudioEffects?: boolean;
48
49
  showKeyframes?: boolean;
@@ -19,6 +19,8 @@ export interface ShortcutSection {
19
19
 
20
20
  const hintKey = (key: string) => key.toUpperCase();
21
21
 
22
+ export const SPLIT_SHORTCUT_HINT = hintKey(STUDIO_PLAIN_KEYS.split);
23
+
22
24
  /** What PlayerControls' shortcuts panel lists unless an embedder passes its own sections. */
23
25
  export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
24
26
  {
@@ -51,7 +53,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
51
53
  { key: "⌘C", label: "Copy element" },
52
54
  { key: "⌘V", label: "Paste element" },
53
55
  { key: "⌘X", label: "Cut element" },
54
- { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
56
+ { key: SPLIT_SHORTCUT_HINT, label: "Split clip at playhead" },
55
57
  { key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" },
56
58
  { key: "⇧Click", label: "Razor tool: split all tracks" },
57
59
  { key: "[", label: "Select clips starting before the playhead" },
@@ -72,6 +72,7 @@ export function useTimelineProviderState({
72
72
  onDuplicateClip,
73
73
  canPasteClip,
74
74
  clipMenuItems,
75
+ splitShortcut,
75
76
  theme: themeOverrides,
76
77
  showAudioEffects = true,
77
78
  showKeyframes = true,
@@ -387,6 +388,7 @@ export function useTimelineProviderState({
387
388
  onDuplicateClip,
388
389
  canPasteClip,
389
390
  clipMenuItems,
391
+ splitShortcut,
390
392
  onSelectElement,
391
393
  selectedElementId,
392
394
  setRangeSelectionRef,