@hyperframes/studio 0.8.47 → 0.8.49
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-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -93,9 +93,14 @@ export function useTimelineGeometry({
|
|
|
93
93
|
useEffect(() => {
|
|
94
94
|
if (zoomMode !== "manual" || isDragging.current) return;
|
|
95
95
|
const el = scrollRef.current;
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
if (!el) return;
|
|
97
|
+
// Read the ref inside the frame, not here: a sibling effect in the same
|
|
98
|
+
// commit (a fresh reveal) can still update lastScrollLeftRef before this
|
|
99
|
+
// frame runs, and a value captured now would fight that reveal with a
|
|
100
|
+
// stale target.
|
|
98
101
|
const raf = requestAnimationFrame(() => {
|
|
102
|
+
const target = lastScrollLeftRef.current;
|
|
103
|
+
if (target <= 0) return;
|
|
99
104
|
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
|
100
105
|
const next = Math.min(target, max);
|
|
101
106
|
if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useMemo, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
8
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
9
|
+
import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
|
|
10
|
+
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
|
|
11
|
+
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
12
|
+
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
14
|
+
|
|
15
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
16
|
+
|
|
17
|
+
const oldElement = { id: "old", tag: "div", start: 0, duration: 1, track: 1 };
|
|
18
|
+
const heroElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
|
|
19
|
+
const clipId = timelineClipFocusId("hero");
|
|
20
|
+
const rowId = timelineTrackRowId(1);
|
|
21
|
+
const beforeRows: readonly TimelineLogicalRow[] = [
|
|
22
|
+
{
|
|
23
|
+
id: rowId,
|
|
24
|
+
kind: "row",
|
|
25
|
+
physicalTrackKey: 1,
|
|
26
|
+
logicalIndex: 0,
|
|
27
|
+
level: 1,
|
|
28
|
+
parentId: null,
|
|
29
|
+
elementId: null,
|
|
30
|
+
expandable: false,
|
|
31
|
+
expanded: false,
|
|
32
|
+
items: [],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
const afterRows: readonly TimelineLogicalRow[] = [
|
|
36
|
+
{
|
|
37
|
+
id: rowId,
|
|
38
|
+
kind: "row",
|
|
39
|
+
physicalTrackKey: 1,
|
|
40
|
+
logicalIndex: 0,
|
|
41
|
+
level: 1,
|
|
42
|
+
parentId: null,
|
|
43
|
+
elementId: null,
|
|
44
|
+
expandable: false,
|
|
45
|
+
expanded: false,
|
|
46
|
+
items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Renders the two real production hooks side by side, in a caller-chosen call
|
|
51
|
+
// order, to test whether their interaction actually depends on that order —
|
|
52
|
+
// as opposed to simulating one hook's effect by hand.
|
|
53
|
+
function OrderHarness({
|
|
54
|
+
order,
|
|
55
|
+
expandedElements,
|
|
56
|
+
logicalRows,
|
|
57
|
+
lastScrollLeftRef,
|
|
58
|
+
}: {
|
|
59
|
+
order: "geometry-first" | "coordinator-first";
|
|
60
|
+
expandedElements: (typeof oldElement)[];
|
|
61
|
+
logicalRows: readonly TimelineLogicalRow[];
|
|
62
|
+
lastScrollLeftRef: React.RefObject<number>;
|
|
63
|
+
}) {
|
|
64
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const isDragging = useRef(false);
|
|
66
|
+
const ppsRef = useRef(0);
|
|
67
|
+
const fitPpsRef = useRef(0);
|
|
68
|
+
const rowGeometry = useMemo(() => createTimelineRowGeometry([1], [48]), []);
|
|
69
|
+
|
|
70
|
+
const runGeometry = () =>
|
|
71
|
+
useTimelineGeometry({
|
|
72
|
+
viewportWidth: 300,
|
|
73
|
+
effectiveDuration: 60,
|
|
74
|
+
zoomMode: "manual",
|
|
75
|
+
manualZoomPercent: 100,
|
|
76
|
+
ppsRef,
|
|
77
|
+
fitPpsRef,
|
|
78
|
+
draggedClip: null,
|
|
79
|
+
resizingClip: null,
|
|
80
|
+
expandedElements,
|
|
81
|
+
isDragging,
|
|
82
|
+
scrollRef,
|
|
83
|
+
lastScrollLeftRef,
|
|
84
|
+
contentOrigin: 32,
|
|
85
|
+
});
|
|
86
|
+
const runFocus = () =>
|
|
87
|
+
useTimelineFocusCoordinator({
|
|
88
|
+
scrollRef,
|
|
89
|
+
logicalRows,
|
|
90
|
+
elements: expandedElements,
|
|
91
|
+
rowGeometry,
|
|
92
|
+
pixelsPerSecond: 100,
|
|
93
|
+
contentOrigin: 32,
|
|
94
|
+
allowHorizontal: true,
|
|
95
|
+
viewportVersion: logicalRows.length,
|
|
96
|
+
projectId: "project-a",
|
|
97
|
+
sessionEpoch: 1,
|
|
98
|
+
syncScrollViewport: () => {},
|
|
99
|
+
lastScrollLeftRef,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// `order` is fixed for the lifetime of one mounted harness, so this call
|
|
103
|
+
// order is stable across that instance's renders.
|
|
104
|
+
if (order === "geometry-first") {
|
|
105
|
+
runGeometry();
|
|
106
|
+
runFocus();
|
|
107
|
+
} else {
|
|
108
|
+
runFocus();
|
|
109
|
+
runGeometry();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={(node) => {
|
|
115
|
+
scrollRef.current = node;
|
|
116
|
+
if (node) {
|
|
117
|
+
Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
|
|
118
|
+
Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
|
|
119
|
+
Object.defineProperty(node, "scrollWidth", { configurable: true, value: 20_000 });
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let host: HTMLDivElement;
|
|
127
|
+
let root: Root;
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
|
|
130
|
+
host = document.createElement("div");
|
|
131
|
+
document.body.append(host);
|
|
132
|
+
root = createRoot(host);
|
|
133
|
+
});
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
act(() => root.unmount());
|
|
136
|
+
usePlayerStore.getState().reset();
|
|
137
|
+
document.body.replaceChildren();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe.each(["geometry-first", "coordinator-first"] as const)(
|
|
141
|
+
"useTimelineGeometry + useTimelineFocusCoordinator, %s",
|
|
142
|
+
(order) => {
|
|
143
|
+
it("a same-commit reveal survives the geometry restore effect regardless of hook registration order", async () => {
|
|
144
|
+
const lastScrollLeftRef = { current: 132 };
|
|
145
|
+
await act(async () => {
|
|
146
|
+
root.render(
|
|
147
|
+
<OrderHarness
|
|
148
|
+
order={order}
|
|
149
|
+
expandedElements={[oldElement]}
|
|
150
|
+
logicalRows={beforeRows}
|
|
151
|
+
lastScrollLeftRef={lastScrollLeftRef}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
156
|
+
scroll.scrollLeft = 132;
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await nextFrame();
|
|
159
|
+
});
|
|
160
|
+
expect(scroll.scrollLeft).toBe(132);
|
|
161
|
+
|
|
162
|
+
// One commit: the drop both adds the element (expandedElements changes,
|
|
163
|
+
// arming the geometry restore effect) and resolves the focus request
|
|
164
|
+
// (arming the coordinator's reveal), exactly as Timeline.tsx renders them.
|
|
165
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
166
|
+
await act(async () => {
|
|
167
|
+
root.render(
|
|
168
|
+
<OrderHarness
|
|
169
|
+
order={order}
|
|
170
|
+
expandedElements={[oldElement, heroElement]}
|
|
171
|
+
logicalRows={afterRows}
|
|
172
|
+
lastScrollLeftRef={lastScrollLeftRef}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await nextFrame();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(scroll.scrollLeft).toBe(1_944);
|
|
181
|
+
expect(lastScrollLeftRef.current).toBe(1_944);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
);
|
|
@@ -63,6 +63,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
|
63
63
|
projectId,
|
|
64
64
|
sessionEpoch: input.sessionEpoch,
|
|
65
65
|
syncScrollViewport: input.syncScrollViewport,
|
|
66
|
+
lastScrollLeftRef: input.lastScrollLeftRef,
|
|
66
67
|
});
|
|
67
68
|
const rows = useTimelineRowVirtualization({
|
|
68
69
|
scrollRef: input.scrollRef,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo, useRef } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { animationLaneGroups } from "./TimelinePropertyLanes";
|
|
4
|
-
import {
|
|
4
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
5
5
|
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
|
|
6
6
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -44,14 +44,15 @@ export function trackShowsBeatStrip(
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Automation lanes on one clip
|
|
47
|
+
* Automation lanes on one clip.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* A clip can be worth expanding without carrying a single tween, so this counts
|
|
50
|
+
* toward whether a track has anything to disclose. Gated on
|
|
51
|
+
* `isAudioOrVideoTimelineElement`, matching `groupAutomationLanes`. A function
|
|
52
|
+
* rather than a map so every caller reads the same cached parse and none can drift.
|
|
52
53
|
*/
|
|
53
54
|
function automationLaneCountOf(element: TimelineElement): number {
|
|
54
|
-
return
|
|
55
|
+
return isAudioOrVideoTimelineElement(element) ? elementAutomationLanes(element).length : 0;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
|
|
3
4
|
import React, { act, useEffect } from "react";
|
|
4
5
|
import { createRoot } from "react-dom/client";
|
|
@@ -171,6 +172,39 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
|
|
171
172
|
|
|
172
173
|
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
173
174
|
});
|
|
175
|
+
|
|
176
|
+
it("'A' does not seek to the in-point while the razor tool is armed, so it's free for the razor's own return-to-select binding", () => {
|
|
177
|
+
const { dispatch, spies } = setupHook();
|
|
178
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
|
|
179
|
+
|
|
180
|
+
act(() => {
|
|
181
|
+
dispatch(keydown({ code: "KeyA", key: "a" }));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("'A' still seeks to the in-point when the razor tool isn't armed", () => {
|
|
188
|
+
const { dispatch, spies } = setupHook();
|
|
189
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "select" });
|
|
190
|
+
|
|
191
|
+
act(() => {
|
|
192
|
+
dispatch(keydown({ code: "KeyA", key: "a" }));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => {
|
|
199
|
+
const { dispatch, spies } = setupHook();
|
|
200
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
|
|
201
|
+
|
|
202
|
+
act(() => {
|
|
203
|
+
dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true }));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
|
207
|
+
});
|
|
174
208
|
});
|
|
175
209
|
|
|
176
210
|
describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
|
@@ -26,6 +26,14 @@ interface UsePlaybackKeyboardParams {
|
|
|
26
26
|
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The razor tool owns plain "a" (return to select) while it's armed; this
|
|
30
|
+
// can't rely on defaultPrevented since both listeners register on window and
|
|
31
|
+
// fire in an order this hook doesn't control. Shift+A and Alt+A are left for
|
|
32
|
+
// the in-point seek below, since only plain A means "exit the razor".
|
|
33
|
+
function shouldRazorClaimPlainA(e: KeyboardEvent): boolean {
|
|
34
|
+
return !e.shiftKey && !e.altKey && usePlayerStore.getState().activeTool === "razor";
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export function usePlaybackKeyboard({
|
|
30
38
|
iframeRef,
|
|
31
39
|
shuttleDirectionRef,
|
|
@@ -79,6 +87,10 @@ export function usePlaybackKeyboard({
|
|
|
79
87
|
}
|
|
80
88
|
}, [play, pause]);
|
|
81
89
|
|
|
90
|
+
// Pre-existing dispatcher, already over the complexity gate before this
|
|
91
|
+
// branch's one-line "a" fix touched it. A real fix is a dispatch-table
|
|
92
|
+
// rewrite, out of scope here; the branch this PR changed is its own function.
|
|
93
|
+
// fallow-ignore-next-line complexity
|
|
82
94
|
const handlePlaybackKeyDown = useCallback(
|
|
83
95
|
(e: KeyboardEvent) => {
|
|
84
96
|
if (e.defaultPrevented) return;
|
|
@@ -161,6 +173,7 @@ export function usePlaybackKeyboard({
|
|
|
161
173
|
return;
|
|
162
174
|
}
|
|
163
175
|
if (key === "a") {
|
|
176
|
+
if (shouldRazorClaimPlainA(e)) return;
|
|
164
177
|
e.preventDefault();
|
|
165
178
|
seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
|
|
166
179
|
return;
|
|
@@ -392,6 +392,14 @@ export function deriveTimelineStoreKey(params: {
|
|
|
392
392
|
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/**
|
|
396
|
+
* {@link deriveTimelineStoreKey} for a caller that already has a DOM id in
|
|
397
|
+
* hand (e.g. one it just minted), so the undefined branch never applies.
|
|
398
|
+
*/
|
|
399
|
+
export function deriveTimelineStoreKeyForDomId(domId: string, sourceFile?: string): string {
|
|
400
|
+
return deriveTimelineStoreKey({ domId, sourceFile })!;
|
|
401
|
+
}
|
|
402
|
+
|
|
395
403
|
// ---------------------------------------------------------------------------
|
|
396
404
|
// DOM node querying
|
|
397
405
|
// ---------------------------------------------------------------------------
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import {
|
|
4
5
|
applyPreviewAudioFlags,
|
|
@@ -54,6 +55,27 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
|
54
55
|
expect(entry).toBeDefined();
|
|
55
56
|
expect(entry?.hfId).toBeUndefined();
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
it("carries the resolved track onto authoredTrack, so splitting a recovered composition host can't drift to a new row", () => {
|
|
60
|
+
const doc = makeDoc(`
|
|
61
|
+
<div data-composition-id="root">
|
|
62
|
+
<div
|
|
63
|
+
data-composition-id="scene-c"
|
|
64
|
+
data-composition-src="scenes/c.html"
|
|
65
|
+
data-track-index="2"
|
|
66
|
+
data-start="0"
|
|
67
|
+
data-duration="5"
|
|
68
|
+
></div>
|
|
69
|
+
</div>
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
|
73
|
+
const entry = missing[0];
|
|
74
|
+
|
|
75
|
+
expect(entry).toBeDefined();
|
|
76
|
+
expect(entry?.track).toBe(2);
|
|
77
|
+
expect(entry?.authoredTrack).toBe(2);
|
|
78
|
+
});
|
|
57
79
|
});
|
|
58
80
|
|
|
59
81
|
describe("setPreviewMediaVolume", () => {
|
|
@@ -452,6 +452,7 @@ function buildMissingCompositionEntry(params: {
|
|
|
452
452
|
start: window.start,
|
|
453
453
|
duration: window.duration,
|
|
454
454
|
track: timing.trackIndex,
|
|
455
|
+
authoredTrack: timing.trackIndex,
|
|
455
456
|
domId: optionalNonEmpty(element.id),
|
|
456
457
|
hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
|
|
457
458
|
selector,
|
|
@@ -108,14 +108,14 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
108
108
|
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
109
109
|
timelineSnapEnabled: boolean;
|
|
110
110
|
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
111
|
+
/** Keeps the main track gapless on delete; distinct from the magnet above. */
|
|
112
|
+
rippleEditEnabled: boolean;
|
|
113
|
+
setRippleEditEnabled: (enabled: boolean) => void;
|
|
111
114
|
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
112
115
|
timeDisplayMode: TimelineTimeDisplayMode;
|
|
113
116
|
setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
|
|
114
|
-
/**
|
|
115
|
-
*
|
|
116
|
-
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
117
|
-
* rescaling every clip. No-op once already pinned (mode is "manual").
|
|
118
|
-
*/
|
|
117
|
+
/** Pin the timeline zoom to its current scale before a duration change, so
|
|
118
|
+
* it stops rescaling every clip. No-op once already pinned. */
|
|
119
119
|
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
|
120
120
|
/** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
|
|
121
121
|
timelinePps: number;
|
|
@@ -149,26 +149,16 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
149
149
|
/** Clears project data without creating a new hard-project session. */
|
|
150
150
|
reset: () => void;
|
|
151
151
|
|
|
152
|
-
/**
|
|
153
|
-
*
|
|
154
|
-
* useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify().
|
|
155
|
-
*/
|
|
152
|
+
/** Request a seek from outside the player loop (e.g. Layers panel);
|
|
153
|
+
* useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify(). */
|
|
156
154
|
requestedSeekTime: number | null;
|
|
157
155
|
requestSeek: (time: number) => void;
|
|
158
156
|
clearSeekRequest: () => void;
|
|
159
157
|
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* silent while the transport is paused — so hovering one has to start
|
|
165
|
-
* playback, and leaving has to put the playhead back where it was. Hovering is
|
|
166
|
-
* not an edit and must not cost the author their place.
|
|
167
|
-
*
|
|
168
|
-
* A nonce rather than a bare boolean: two hovers in a row both want play, and
|
|
169
|
-
* without it the second request is indistinguishable from the first having
|
|
170
|
-
* already been served.
|
|
171
|
-
*/
|
|
158
|
+
/** Request the transport start or stop from outside the player loop: the FX
|
|
159
|
+
* rack starts playback to audition a preset (silent while paused) and
|
|
160
|
+
* restores the playhead on leave, without costing the author their place.
|
|
161
|
+
* A nonce, not a bare boolean, so two hovers in a row both register. */
|
|
172
162
|
playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
|
|
173
163
|
requestPlayback: (playing: boolean, returnTo?: number | null) => void;
|
|
174
164
|
clearPlaybackRequest: () => void;
|
|
@@ -475,6 +465,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
475
465
|
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
476
466
|
set({ timelineSnapEnabled: enabled });
|
|
477
467
|
},
|
|
468
|
+
rippleEditEnabled: readStudioUiPreferences().rippleEditEnabled ?? true, // default on
|
|
469
|
+
setRippleEditEnabled: (enabled) => {
|
|
470
|
+
writeStudioUiPreferences({ rippleEditEnabled: enabled });
|
|
471
|
+
set({ rippleEditEnabled: enabled });
|
|
472
|
+
},
|
|
478
473
|
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
|
479
474
|
setTimeDisplayMode: (mode) => {
|
|
480
475
|
writeStudioUiPreferences({ timeDisplayMode: mode });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Gates `timelineReady` on the composition's declared readiness inputs
|
|
2
|
-
* (media,
|
|
3
|
-
* is module-scope, not store state: it
|
|
4
|
-
* something a component reads, so
|
|
2
|
+
* (media, compute, and the paint-and-idle default) instead of just a known
|
|
3
|
+
* duration. The generation counter is module-scope, not store state: it
|
|
4
|
+
* guards an in-flight settlement, not something a component reads, so
|
|
5
|
+
* bumping it shouldn't trigger a render. */
|
|
5
6
|
import type { StoreApi } from "zustand";
|
|
6
7
|
import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
|
|
7
8
|
|
|
@@ -63,5 +63,9 @@ describe("addBlockToProject", () => {
|
|
|
63
63
|
expect(source).toContain('data-start="2.5"');
|
|
64
64
|
expect(source).toContain('data-duration="4.25"');
|
|
65
65
|
expect(source).toContain('data-track-index="3"');
|
|
66
|
+
// hostKey addresses the new element the same way the timeline selects
|
|
67
|
+
// clips (sourceFile#domId), so the caller can select and reveal it.
|
|
68
|
+
expect(result?.hostKey).toBe("compositions/scene.html#camcorder-hud");
|
|
69
|
+
expect(source).toContain('id="camcorder-hud"');
|
|
66
70
|
});
|
|
67
71
|
});
|
|
@@ -11,6 +11,7 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
|
|
|
11
11
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
12
12
|
import type { EditHistoryKind } from "./editHistory";
|
|
13
13
|
import { extendRootDurationInSource } from "./rootDuration";
|
|
14
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
14
15
|
|
|
15
16
|
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
|
|
16
17
|
try {
|
|
@@ -174,7 +175,7 @@ function buildSubCompositionHtml({
|
|
|
174
175
|
|
|
175
176
|
export async function addBlockToProject(
|
|
176
177
|
opts: AddBlockOptions,
|
|
177
|
-
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
|
|
178
|
+
): Promise<{ block: RegistryItem; compositionPath: string; hostKey: string } | null> {
|
|
178
179
|
const {
|
|
179
180
|
projectId,
|
|
180
181
|
blockName,
|
|
@@ -213,8 +214,9 @@ export async function addBlockToProject(
|
|
|
213
214
|
currentTime: opts.currentTime ?? 0,
|
|
214
215
|
});
|
|
215
216
|
const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
|
|
217
|
+
const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
|
|
216
218
|
const subComposition = buildSubCompositionHtml({
|
|
217
|
-
id:
|
|
219
|
+
id: hostId,
|
|
218
220
|
compositionFile,
|
|
219
221
|
start,
|
|
220
222
|
duration,
|
|
@@ -242,7 +244,11 @@ export async function addBlockToProject(
|
|
|
242
244
|
await refreshFileTree();
|
|
243
245
|
reloadPreview();
|
|
244
246
|
|
|
245
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
block,
|
|
249
|
+
compositionPath: compositionFile,
|
|
250
|
+
hostKey: deriveTimelineStoreKeyForDomId(hostId, targetPath),
|
|
251
|
+
};
|
|
246
252
|
} catch (error) {
|
|
247
253
|
const message = error instanceof Error ? error.message : "Failed to add block";
|
|
248
254
|
showToast(message);
|
|
@@ -37,6 +37,16 @@ describe("buildAtomicCutIntents", () => {
|
|
|
37
37
|
expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("carries the element's authored track onto the cut target, so both split halves stay pinned to it server-side", () => {
|
|
41
|
+
const intents = buildAtomicCutIntents([element({ authoredTrack: 2 })], 2, "index.html");
|
|
42
|
+
expect(intents[0].targets[0].track).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to the resolved track when the element has no authoredTrack", () => {
|
|
46
|
+
const intents = buildAtomicCutIntents([element({ track: 3 })], 2, "index.html");
|
|
47
|
+
expect(intents[0].targets[0].track).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
40
50
|
it("rebases each nested target into its own source-file coordinates", () => {
|
|
41
51
|
const intents = buildAtomicCutIntents(
|
|
42
52
|
[element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
|
|
@@ -4,6 +4,7 @@ import { buildPatchTarget } from "./timelineElementSplit";
|
|
|
4
4
|
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
5
5
|
import { buildProjectApiPath } from "./projectRouting";
|
|
6
6
|
import { markStudioWriteToken } from "./studioFileVersion";
|
|
7
|
+
import { resolveElementTrack } from "./studioHelpers";
|
|
7
8
|
|
|
8
9
|
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
9
10
|
|
|
@@ -16,6 +17,7 @@ interface CutTarget {
|
|
|
16
17
|
playbackStart?: number;
|
|
17
18
|
playbackRate?: number;
|
|
18
19
|
isComposition?: boolean;
|
|
20
|
+
track?: number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface CutFileIntent {
|
|
@@ -69,6 +71,9 @@ function buildCutTarget(
|
|
|
69
71
|
...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
|
|
70
72
|
...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
|
|
71
73
|
...(element.kind === "composition" ? { isComposition: true } : {}),
|
|
74
|
+
// Pin both halves to the current track: unstamped, the runtime's positional
|
|
75
|
+
// fallback (parseAuthoredTrack) renumbers the new sibling onto a new row.
|
|
76
|
+
track: resolveElementTrack(element),
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MutableRefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RetryBudgetState<K> {
|
|
4
|
+
id: K;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A target still resolving (a just-dropped/pasted/duplicated element, or a
|
|
9
|
+
// preview still reloading) heals within a few retries; one that never will
|
|
10
|
+
// would otherwise retry forever. A new id resets the count.
|
|
11
|
+
export function recordRetryAttempt<K>(
|
|
12
|
+
ref: MutableRefObject<RetryBudgetState<K>>,
|
|
13
|
+
id: K,
|
|
14
|
+
maxRetries: number,
|
|
15
|
+
): boolean {
|
|
16
|
+
const previous = ref.current;
|
|
17
|
+
const count = previous.id === id ? previous.count + 1 : 1;
|
|
18
|
+
ref.current = { id, count };
|
|
19
|
+
return count <= maxRetries;
|
|
20
|
+
}
|
|
@@ -41,6 +41,10 @@ describe("SDK family gate mapping", () => {
|
|
|
41
41
|
{} as never,
|
|
42
42
|
deps,
|
|
43
43
|
),
|
|
44
|
-
|
|
44
|
+
// The dom family is off, so the gate declines on the FLAG, not on the op
|
|
45
|
+
// batch (which is a perfectly eligible inline-style edit). Before the
|
|
46
|
+
// reason split this reported `ineligible_operation`, which read as "the
|
|
47
|
+
// SDK cannot express this edit" when the truth was "this family is off".
|
|
48
|
+
).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
45
49
|
});
|
|
46
50
|
});
|
|
@@ -649,6 +649,57 @@ window.__timelines = { main: tl };</script></div>
|
|
|
649
649
|
for (const candidate of published) candidate.dispose();
|
|
650
650
|
});
|
|
651
651
|
|
|
652
|
+
it("does not resurrect an element a prior REST write already deleted, even with a stale live session", async () => {
|
|
653
|
+
// A delete persists via the server REST path, then a same-gesture ripple
|
|
654
|
+
// move reaches an SDK-eligible batch persist while `live` was never
|
|
655
|
+
// reloaded. Prove the candidate rebases on the post-delete disk bytes.
|
|
656
|
+
const twoElementHtml = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
657
|
+
<div data-hf-id="hf-stage" data-hf-root>
|
|
658
|
+
<div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
|
|
659
|
+
<div data-hf-id="hf-b" data-start="5" data-duration="2"></div>
|
|
660
|
+
</div>
|
|
661
|
+
</body></html>`;
|
|
662
|
+
const live = await openComposition(twoElementHtml, { history: false });
|
|
663
|
+
|
|
664
|
+
// Disk already reflects hf-b's deletion — a separate write that completed
|
|
665
|
+
// before this persist started, exactly like the delete's own REST call
|
|
666
|
+
// that `handleTimelineElementsDelete` awaits before the ripple begins.
|
|
667
|
+
const postDelete = await openComposition(twoElementHtml, { history: false });
|
|
668
|
+
postDelete.removeElement("hf-b");
|
|
669
|
+
let disk = postDelete.serialize();
|
|
670
|
+
postDelete.dispose();
|
|
671
|
+
expect(disk).not.toContain("hf-b");
|
|
672
|
+
|
|
673
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
674
|
+
disk = content;
|
|
675
|
+
});
|
|
676
|
+
const deps = {
|
|
677
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
678
|
+
writeProjectFile,
|
|
679
|
+
readProjectFile: vi.fn(async () => disk),
|
|
680
|
+
reloadPreview: vi.fn(),
|
|
681
|
+
publishSession: vi.fn().mockReturnValue("published"),
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// The ripple: only survivors move, mirroring resolveShiftedElements — this
|
|
685
|
+
// never touches hf-b, which `live` (unlike disk) still believes exists.
|
|
686
|
+
const result = await persistSdkCandidateMutation(
|
|
687
|
+
live,
|
|
688
|
+
"/comp.html",
|
|
689
|
+
twoElementHtml,
|
|
690
|
+
deps,
|
|
691
|
+
(candidate) => candidate.setTiming("hf-a", { start: 3 }),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
expect(result.status).toBe("committed");
|
|
695
|
+
expect(disk).not.toContain("hf-b");
|
|
696
|
+
const written = await openComposition(disk, { history: false });
|
|
697
|
+
expect(written.getElement("hf-a")?.start).toBe(3);
|
|
698
|
+
expect(written.getElement("hf-b")).toBeNull();
|
|
699
|
+
written.dispose();
|
|
700
|
+
live.dispose();
|
|
701
|
+
});
|
|
702
|
+
|
|
652
703
|
it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
|
|
653
704
|
const live = await openComposition(html, { history: false });
|
|
654
705
|
let disk = html;
|