@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.
- package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2567 -2314
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -9
- package/src/components/StudioRightPanels.tsx +4 -48
- 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/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +16 -42
- 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/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- 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/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- 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",
|
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
|
|
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={
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}
|