@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.
- package/dist/assets/{hyperframes-player-7Icl2uVN.js → hyperframes-player-i4gqkhjz.js} +1 -1
- package/dist/assets/{index-Dc8FEIEU.js → index-C7LHDM18.js} +1 -1
- package/dist/assets/{index-DOO2cYhl.js → index-Ce3Lr-Gt.js} +174 -174
- package/dist/assets/{index-7YcUqkoh.js → index-uWOhRqxe.js} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.html +1 -1
- package/dist/index.js +72 -87
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/nle/TimelinePane.tsx +3 -0
- package/src/hooks/fetchStubTestUtils.ts +3 -5
- package/src/hooks/gsapMutationClient.ts +17 -8
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -64
- package/src/hooks/newTweenTarget.test.ts +3 -0
- package/src/hooks/useDomEditCommitsHelpers.test.ts +116 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +44 -0
- package/src/hooks/useGsapAnimationOps.test.tsx +181 -1
- package/src/hooks/useGsapAnimationOps.ts +13 -12
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +6 -32
- package/src/player/components/ClipContextMenu.test.tsx +31 -0
- package/src/player/components/ClipContextMenu.tsx +4 -1
- package/src/player/components/TimelineOverlays.test.ts +18 -0
- package/src/player/components/TimelineOverlays.tsx +1 -0
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/studioShortcuts.ts +3 -1
- 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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
140
|
+
const savedId = await (writeProjectFile
|
|
140
141
|
? serializeStudioFileMutation(writeProjectFile, targetPath, assign)
|
|
141
142
|
: assign());
|
|
142
|
-
if (!
|
|
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
|
-
//
|
|
157
|
-
//
|
|
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 {
|
|
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
|
-
|
|
53
|
+
body: unknown,
|
|
54
54
|
): Promise<MutationResult> {
|
|
55
|
-
const res = await
|
|
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
|
-
|
|
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">
|
|
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
|
});
|
|
@@ -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:
|
|
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,
|