@hyperframes/studio 0.8.81 → 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.
- package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
- package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2414 -2172
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +36 -37
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +8 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- 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.
|
|
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.
|
|
56
|
-
"@hyperframes/parsers": "0.8.
|
|
57
|
-
"@hyperframes/player": "0.8.
|
|
58
|
-
"@hyperframes/sdk": "0.8.
|
|
59
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
60
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
76
|
+
"@hyperframes/producer": "0.8.82"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"react": "19",
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
label={editHistory.undoLabel}
|
|
44
|
-
onClick={
|
|
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
|
-
|
|
49
|
-
label={editHistory.redoLabel}
|
|
50
|
-
onClick={
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Puts Studio's committed edits back after a timeline seek has rendered over them.
|
|
2
|
+
import {
|
|
3
|
+
STUDIO_BOX_SIZE_ATTR,
|
|
4
|
+
STUDIO_HEIGHT_PROP,
|
|
5
|
+
STUDIO_OFFSET_X_PROP,
|
|
6
|
+
STUDIO_OFFSET_Y_PROP,
|
|
7
|
+
STUDIO_PATH_OFFSET_ATTR,
|
|
8
|
+
STUDIO_ROTATION_ATTR,
|
|
9
|
+
STUDIO_ROTATION_PROP,
|
|
10
|
+
STUDIO_WIDTH_PROP,
|
|
11
|
+
} from "./manualEditsTypes";
|
|
12
|
+
import { applyStudioBoxSize, applyStudioPathOffset, applyStudioRotation } from "./manualEditsDom";
|
|
13
|
+
import { applyStudioMotionFromDom } from "./studioMotion";
|
|
14
|
+
import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
|
|
15
|
+
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
16
|
+
|
|
17
|
+
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
18
|
+
const ctor = doc.defaultView?.HTMLElement;
|
|
19
|
+
if (!ctor) return [];
|
|
20
|
+
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
|
|
21
|
+
(el): el is HTMLElement => el instanceof ctor,
|
|
22
|
+
);
|
|
23
|
+
// Handle legacy HTML files where attributes were persisted with a double data- prefix
|
|
24
|
+
const legacyAttr = `data-${attr}`;
|
|
25
|
+
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
|
|
26
|
+
if (el instanceof ctor && !el.hasAttribute(attr)) {
|
|
27
|
+
el.setAttribute(attr, "true");
|
|
28
|
+
el.removeAttribute(legacyAttr);
|
|
29
|
+
elements.push(el);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return elements;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reapplyPathOffsets(doc: Document): void {
|
|
36
|
+
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
|
37
|
+
// Unlike size below, the offset channels add up: applying both doubles the move.
|
|
38
|
+
if (gsapAnimatesProperty(el, "x", "y")) continue;
|
|
39
|
+
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
|
40
|
+
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
|
41
|
+
if (!x && !y) continue;
|
|
42
|
+
const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
|
|
43
|
+
applyStudioPathOffset(el, offset, { updateBase: false });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Put the committed size back after a seek, GSAP-sized elements included: both write width and height,
|
|
49
|
+
* so the later write wins. Standing aside let a soft reload revert to the stylesheet size for a frame.
|
|
50
|
+
*/
|
|
51
|
+
function reapplyBoxSizes(doc: Document): void {
|
|
52
|
+
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
|
|
53
|
+
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
|
54
|
+
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
|
55
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
56
|
+
applyStudioBoxSize(el, { width: w, height: h });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function reapplyRotations(doc: Document): void {
|
|
61
|
+
for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
|
|
62
|
+
const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
|
|
63
|
+
if (Number.isFinite(angle)) {
|
|
64
|
+
applyStudioRotation(el, { angle });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Every mark a reapply below acts on, the legacy double-prefixed ones included.
|
|
70
|
+
const STUDIO_EDIT_ATTRS = [STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, STUDIO_ROTATION_ATTR]
|
|
71
|
+
.flatMap((attr) => [attr, `data-${attr}`])
|
|
72
|
+
.concat(STUDIO_MOTION_ATTR);
|
|
73
|
+
// One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
|
|
74
|
+
const STUDIO_EDIT_SELECTORS = STUDIO_EDIT_ATTRS.map((attr) =>
|
|
75
|
+
attr === STUDIO_MOTION_ATTR ? `[${attr}]` : `[${attr}="true"]`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
interface EditMarkCache {
|
|
79
|
+
observer: MutationObserver;
|
|
80
|
+
stale: boolean;
|
|
81
|
+
has: boolean;
|
|
82
|
+
}
|
|
83
|
+
const editMarkCaches = new WeakMap<Document, EditMarkCache>();
|
|
84
|
+
|
|
85
|
+
function scanForEditMarks(doc: Document): boolean {
|
|
86
|
+
return STUDIO_EDIT_SELECTORS.some((selector) => doc.querySelector(selector) !== null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Rescans only after a mark attribute or the tree changed, so a seek on a steady film does no lookup.
|
|
90
|
+
function hasEditMarks(doc: Document): boolean {
|
|
91
|
+
const Observer = doc.defaultView?.MutationObserver;
|
|
92
|
+
if (!Observer) return scanForEditMarks(doc);
|
|
93
|
+
let cache = editMarkCaches.get(doc);
|
|
94
|
+
if (!cache) {
|
|
95
|
+
const entry: EditMarkCache = {
|
|
96
|
+
observer: new Observer(() => {
|
|
97
|
+
entry.stale = true;
|
|
98
|
+
}),
|
|
99
|
+
stale: true,
|
|
100
|
+
has: false,
|
|
101
|
+
};
|
|
102
|
+
entry.observer.observe(doc, {
|
|
103
|
+
subtree: true,
|
|
104
|
+
childList: true,
|
|
105
|
+
attributes: true,
|
|
106
|
+
attributeFilter: STUDIO_EDIT_ATTRS,
|
|
107
|
+
});
|
|
108
|
+
editMarkCaches.set(doc, entry);
|
|
109
|
+
cache = entry;
|
|
110
|
+
}
|
|
111
|
+
// A mark set earlier in this task has not reached the observer callback yet.
|
|
112
|
+
if (cache.observer.takeRecords().length > 0) cache.stale = true;
|
|
113
|
+
if (cache.stale) {
|
|
114
|
+
cache.has = scanForEditMarks(doc);
|
|
115
|
+
cache.stale = false;
|
|
116
|
+
}
|
|
117
|
+
return cache.has;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hasStudioEdits(doc: Document): boolean {
|
|
121
|
+
const timelines = (doc.defaultView as { __timelines?: Record<string, unknown> } | null)
|
|
122
|
+
?.__timelines;
|
|
123
|
+
// A motion timeline outlives its marks until the next reapply kills it.
|
|
124
|
+
return Boolean(timelines?.[STUDIO_MOTION_TIMELINE_ID]) || hasEditMarks(doc);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function reapplyPositionEditsAfterSeek(doc: Document): void {
|
|
128
|
+
// Runs after every seek, so several times per playback frame.
|
|
129
|
+
if (!hasStudioEdits(doc)) return;
|
|
130
|
+
reapplyPathOffsets(doc);
|
|
131
|
+
reapplyBoxSizes(doc);
|
|
132
|
+
reapplyRotations(doc);
|
|
133
|
+
applyStudioMotionFromDom(doc);
|
|
134
|
+
// The reapply rewrites the marks it read; the film still has them, so no rescan is due.
|
|
135
|
+
editMarkCaches.get(doc)?.observer.takeRecords();
|
|
136
|
+
}
|