@hyperframes/studio 0.8.78 → 0.8.80
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 +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../player";
|
|
7
|
+
import { useFrameCapture } from "./useFrameCapture";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
let renders = 0;
|
|
12
|
+
let capture: ReturnType<typeof useFrameCapture>;
|
|
13
|
+
|
|
14
|
+
function Harness() {
|
|
15
|
+
renders += 1;
|
|
16
|
+
capture = useFrameCapture({
|
|
17
|
+
projectId: "p",
|
|
18
|
+
activeCompPath: null,
|
|
19
|
+
showToast: () => {},
|
|
20
|
+
waitForPendingDomEditSaves: async () => {},
|
|
21
|
+
});
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shownTime = () => new URL(capture.captureFrameHref).searchParams.get("t");
|
|
26
|
+
|
|
27
|
+
let host: HTMLDivElement;
|
|
28
|
+
let root: Root;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 2 });
|
|
32
|
+
renders = 0;
|
|
33
|
+
host = document.createElement("div");
|
|
34
|
+
document.body.append(host);
|
|
35
|
+
root = createRoot(host);
|
|
36
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await act(async () => root.unmount());
|
|
41
|
+
host.remove();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("useFrameCapture", () => {
|
|
45
|
+
it("does not re-render on playback frames", () => {
|
|
46
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
47
|
+
const before = renders;
|
|
48
|
+
act(() => {
|
|
49
|
+
for (let i = 1; i <= 30; i++) liveTime.notify(2 + i / 60);
|
|
50
|
+
});
|
|
51
|
+
expect(renders).toBe(before);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("points the capture at the live playhead while playing", () => {
|
|
55
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
56
|
+
act(() => liveTime.notify(7.5));
|
|
57
|
+
act(() => capture.refreshCaptureFrameTime());
|
|
58
|
+
expect(shownTime()).toBe("7.500");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("points the capture at the store time while paused", () => {
|
|
62
|
+
act(() => liveTime.notify(7.5));
|
|
63
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 3 });
|
|
64
|
+
act(() => capture.refreshCaptureFrameTime());
|
|
65
|
+
expect(shownTime()).toBe("3.000");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -16,19 +16,28 @@ export function useFrameCapture({
|
|
|
16
16
|
showToast,
|
|
17
17
|
waitForPendingDomEditSaves,
|
|
18
18
|
}: UseFrameCaptureParams) {
|
|
19
|
-
const [captureFrameTime, setCaptureFrameTime] = useState(
|
|
19
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(
|
|
20
|
+
() => usePlayerStore.getState().currentTime,
|
|
21
|
+
);
|
|
20
22
|
const [capturing, setCapturing] = useState(false);
|
|
21
23
|
const capturingRef = useRef(false);
|
|
24
|
+
const livePlayheadRef = useRef(captureFrameTime);
|
|
22
25
|
|
|
23
|
-
useMountEffect(() =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
useMountEffect(() =>
|
|
27
|
+
liveTime.subscribe((time) => {
|
|
28
|
+
livePlayheadRef.current = time;
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
27
31
|
|
|
28
|
-
const
|
|
29
|
-
|
|
32
|
+
const playheadTime = useCallback(() => {
|
|
33
|
+
const { isPlaying, currentTime } = usePlayerStore.getState();
|
|
34
|
+
return isPlaying ? livePlayheadRef.current : currentTime;
|
|
30
35
|
}, []);
|
|
31
36
|
|
|
37
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
38
|
+
setCaptureFrameTime(playheadTime());
|
|
39
|
+
}, [playheadTime]);
|
|
40
|
+
|
|
32
41
|
const handleCaptureFrameClick = useCallback(
|
|
33
42
|
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
34
43
|
if (!projectId) return;
|
|
@@ -39,7 +48,7 @@ export function useFrameCapture({
|
|
|
39
48
|
capturingRef.current = true;
|
|
40
49
|
setCapturing(true);
|
|
41
50
|
try {
|
|
42
|
-
const time =
|
|
51
|
+
const time = playheadTime();
|
|
43
52
|
setCaptureFrameTime(time);
|
|
44
53
|
await Promise.race([
|
|
45
54
|
waitForPendingDomEditSaves(),
|
|
@@ -91,7 +100,7 @@ export function useFrameCapture({
|
|
|
91
100
|
setCapturing(false);
|
|
92
101
|
}
|
|
93
102
|
},
|
|
94
|
-
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
103
|
+
[activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
|
|
95
104
|
);
|
|
96
105
|
|
|
97
106
|
const captureFrameHref = projectId
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
import { act, createElement } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
4
5
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -103,8 +104,7 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
|
|
|
103
104
|
}),
|
|
104
105
|
);
|
|
105
106
|
}
|
|
106
|
-
|
|
107
|
-
expect(hook().undoLabel).toBeUndefined();
|
|
107
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Dragged Title"));
|
|
108
108
|
const undone = await act(() => hook().undo({ readFile }));
|
|
109
109
|
expect(undone).toMatchObject({
|
|
110
110
|
ok: true,
|
|
@@ -120,11 +120,11 @@ function redoneAt(view: HistoryView): number | null {
|
|
|
120
120
|
return view.entries.find((entry) => entry.id === undo?.undoes)?.endedAt ?? null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/** Studio's undo over the server's project history: an edit claims what it wrote; Cmd+Z steps
|
|
123
|
+
/** Studio's undo over the server's project history: an edit claims what it wrote; Cmd+Z steps the person's. */
|
|
124
124
|
export function usePersistentEditHistory({ projectId }: UsePersistentEditHistoryOptions) {
|
|
125
125
|
const [view, setView] = useState<HistoryView>(EMPTY);
|
|
126
126
|
const [loaded, setLoaded] = useState(false);
|
|
127
|
-
// A coalescing claim
|
|
127
|
+
// A coalescing claim the server just took, until `refresh()` brings its entry into `view`.
|
|
128
128
|
const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null);
|
|
129
129
|
const projectIdRef = useRef(projectId);
|
|
130
130
|
projectIdRef.current = projectId;
|
|
@@ -131,6 +131,7 @@ function renderTimelineEditingHook(input: {
|
|
|
131
131
|
elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
|
|
132
132
|
handleAutoGroupCarveSources: ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"];
|
|
133
133
|
setAudioGroupAttribute: ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"];
|
|
134
|
+
setElementFxAttribute: ReturnType<typeof useTimelineEditing>["setElementFxAttribute"];
|
|
134
135
|
unmount: () => void;
|
|
135
136
|
} {
|
|
136
137
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
|
@@ -146,6 +147,7 @@ function renderTimelineEditingHook(input: {
|
|
|
146
147
|
let setAudioGroupAttribute:
|
|
147
148
|
| ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"]
|
|
148
149
|
| null = null;
|
|
150
|
+
let latest: ReturnType<typeof useTimelineEditing> | null = null;
|
|
149
151
|
|
|
150
152
|
function Harness() {
|
|
151
153
|
const commitRef = useRef(input.onZIndexCommit);
|
|
@@ -176,6 +178,7 @@ function renderTimelineEditingHook(input: {
|
|
|
176
178
|
elementsDelete = hook.handleTimelineElementsDelete;
|
|
177
179
|
handleAutoGroupCarveSources = hook.handleAutoGroupCarveSources;
|
|
178
180
|
setAudioGroupAttribute = hook.setAudioGroupAttribute;
|
|
181
|
+
latest = hook;
|
|
179
182
|
return null;
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -197,6 +200,8 @@ function renderTimelineEditingHook(input: {
|
|
|
197
200
|
elementsDelete,
|
|
198
201
|
handleAutoGroupCarveSources,
|
|
199
202
|
setAudioGroupAttribute,
|
|
203
|
+
setElementFxAttribute: (latest as unknown as ReturnType<typeof useTimelineEditing>)
|
|
204
|
+
.setElementFxAttribute,
|
|
200
205
|
unmount,
|
|
201
206
|
};
|
|
202
207
|
}
|
|
@@ -1910,3 +1915,57 @@ describe("useTimelineEditing: handler identity is stable across renders", () =>
|
|
|
1910
1915
|
unmount();
|
|
1911
1916
|
});
|
|
1912
1917
|
});
|
|
1918
|
+
|
|
1919
|
+
describe("useTimelineEditing effect saves report what happened", () => {
|
|
1920
|
+
const LOCKED = { blocked: true as const, reason: "Reserved by an agent" };
|
|
1921
|
+
|
|
1922
|
+
it("resolves a saved clip effect as saved", async () => {
|
|
1923
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness();
|
|
1924
|
+
let outcome: unknown;
|
|
1925
|
+
await act(async () => {
|
|
1926
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1927
|
+
});
|
|
1928
|
+
expect(outcome).toEqual({ status: "saved" });
|
|
1929
|
+
expect(writeProjectFile).toHaveBeenCalled();
|
|
1930
|
+
unmount();
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
it("resolves a clip effect on a locked clip as refused, with the host's reason", async () => {
|
|
1934
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness({
|
|
1935
|
+
canEdit: () => LOCKED,
|
|
1936
|
+
});
|
|
1937
|
+
let outcome: unknown;
|
|
1938
|
+
await act(async () => {
|
|
1939
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1940
|
+
});
|
|
1941
|
+
expect(outcome).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
1942
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1943
|
+
unmount();
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
it("resolves a clip effect whose write fails as failed", async () => {
|
|
1947
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness();
|
|
1948
|
+
writeProjectFile.mockRejectedValue(new Error("disk full"));
|
|
1949
|
+
let outcome: unknown;
|
|
1950
|
+
await act(async () => {
|
|
1951
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1952
|
+
});
|
|
1953
|
+
expect(outcome).toEqual({ status: "failed", reason: expect.stringContaining("disk full") });
|
|
1954
|
+
unmount();
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it("resolves a group audio effect on a locked group as refused", async () => {
|
|
1958
|
+
const member = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1959
|
+
usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
|
|
1960
|
+
const { setAudioGroupAttribute, writeProjectFile, unmount } = setupSingleClipHarness({
|
|
1961
|
+
canEdit: () => LOCKED,
|
|
1962
|
+
});
|
|
1963
|
+
let outcome: unknown;
|
|
1964
|
+
await act(async () => {
|
|
1965
|
+
outcome = await setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Volume");
|
|
1966
|
+
});
|
|
1967
|
+
expect(outcome).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
1968
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1969
|
+
unmount();
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
playbackStartAttributeForElement,
|
|
12
12
|
persistTimelineEdit,
|
|
13
13
|
formatTimelineAttributeNumber,
|
|
14
|
+
formatTimelineMediaOffset,
|
|
14
15
|
extendRootDurationIfNeeded,
|
|
15
16
|
buildTimelineMoveTimingPatch,
|
|
16
17
|
buildTimelineResizeTimingPatch,
|
|
@@ -33,15 +34,19 @@ import {
|
|
|
33
34
|
} from "./timelineTrackVisibility";
|
|
34
35
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
35
36
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
useTimelineEditGate,
|
|
39
|
+
useTimelineEditRefusal,
|
|
40
|
+
type TimelineEditOutcome,
|
|
41
|
+
} from "./timelineEditPermission";
|
|
37
42
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
38
43
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
39
44
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
40
45
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
41
46
|
|
|
42
|
-
type GuardedTimelineHandler = (...args: never[]) => Promise<
|
|
47
|
+
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
43
48
|
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
44
|
-
type GuardedTimelineRefusal = (...args: never[]) =>
|
|
49
|
+
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
45
50
|
|
|
46
51
|
interface GuardedTimelineEntry {
|
|
47
52
|
resolveTargets: GuardedTimelineResolver;
|
|
@@ -74,17 +79,18 @@ export function useTimelineEditing({
|
|
|
74
79
|
const editQueueRef = useRef(Promise.resolve());
|
|
75
80
|
const track = useTrackPendingTimelineEdit();
|
|
76
81
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
77
|
-
const
|
|
78
|
-
|
|
82
|
+
const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
|
|
83
|
+
const refuseEditRef = useRef(refuseEdit);
|
|
84
|
+
refuseEditRef.current = refuseEdit;
|
|
79
85
|
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
80
86
|
// Refuses (no call, no write, no history entry) when any target is
|
|
81
87
|
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
82
88
|
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
83
89
|
const guard = useCallback(
|
|
84
|
-
<H extends (...args: never[]) => Promise<
|
|
90
|
+
<H extends (...args: never[]) => Promise<unknown>>(
|
|
85
91
|
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
86
92
|
fn: H,
|
|
87
|
-
onRefused?: (...args: Parameters<H>) =>
|
|
93
|
+
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
88
94
|
): H => {
|
|
89
95
|
const key = fn as unknown as GuardedTimelineHandler;
|
|
90
96
|
const cached = guardedRef.current.get(key);
|
|
@@ -97,10 +103,9 @@ export function useTimelineEditing({
|
|
|
97
103
|
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
98
104
|
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
99
105
|
entry.wrapped = ((...args: Parameters<H>) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return Promise.resolve();
|
|
103
|
-
}
|
|
106
|
+
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
107
|
+
if (reason !== null)
|
|
108
|
+
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
104
109
|
return fn(...args);
|
|
105
110
|
}) as H as unknown as GuardedTimelineHandler;
|
|
106
111
|
guardedRef.current.set(key, entry);
|
|
@@ -309,7 +314,7 @@ export function useTimelineEditing({
|
|
|
309
314
|
];
|
|
310
315
|
if (updates.playbackStart != null) {
|
|
311
316
|
const liveAttr = playbackStartAttributeForElement(element);
|
|
312
|
-
liveAttrs.push([liveAttr,
|
|
317
|
+
liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
|
|
313
318
|
}
|
|
314
319
|
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
315
320
|
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
@@ -432,7 +437,7 @@ export function useTimelineEditing({
|
|
|
432
437
|
checkEditable,
|
|
433
438
|
});
|
|
434
439
|
|
|
435
|
-
const
|
|
440
|
+
const setElementFxAttribute = useSetElementAttribute({
|
|
436
441
|
projectIdRef,
|
|
437
442
|
activeCompPath,
|
|
438
443
|
showToast,
|
|
@@ -443,18 +448,16 @@ export function useTimelineEditing({
|
|
|
443
448
|
isRecordingRef,
|
|
444
449
|
});
|
|
445
450
|
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
},
|
|
457
|
-
);
|
|
451
|
+
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
|
452
|
+
projectIdRef,
|
|
453
|
+
activeCompPath,
|
|
454
|
+
showToast,
|
|
455
|
+
writeProjectFile,
|
|
456
|
+
recordEdit,
|
|
457
|
+
previewIframeRef,
|
|
458
|
+
pendingTimelineEditPathRef,
|
|
459
|
+
isRecordingRef,
|
|
460
|
+
});
|
|
458
461
|
|
|
459
462
|
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
|
460
463
|
projectIdRef,
|
|
@@ -500,6 +503,25 @@ export function useTimelineEditing({
|
|
|
500
503
|
forceReloadSdkSession,
|
|
501
504
|
});
|
|
502
505
|
|
|
506
|
+
const refused = (reason: string): TimelineEditOutcome => ({ status: "refused", reason });
|
|
507
|
+
const audioGroupMembers = (groupId: string): TimelineElement[] => {
|
|
508
|
+
const state = usePlayerStore.getState();
|
|
509
|
+
const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
|
|
510
|
+
const domMembers = state.domClipChildren
|
|
511
|
+
.filter((child) => child.audioGroup === groupId)
|
|
512
|
+
.map(
|
|
513
|
+
(child): TimelineElement => ({
|
|
514
|
+
id: child.id,
|
|
515
|
+
domId: child.id,
|
|
516
|
+
tag: "div",
|
|
517
|
+
start: 0,
|
|
518
|
+
duration: 0,
|
|
519
|
+
track: -1,
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
return [...flatMembers, ...domMembers];
|
|
523
|
+
};
|
|
524
|
+
|
|
503
525
|
// Every write-handler is tracked here, the one place all hand edits
|
|
504
526
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
505
527
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
@@ -526,27 +548,10 @@ export function useTimelineEditing({
|
|
|
526
548
|
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
|
527
549
|
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
528
550
|
setQuiet: track(
|
|
529
|
-
guard(
|
|
530
|
-
(groupId)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const domMembers = state.domClipChildren
|
|
534
|
-
.filter((child) => child.audioGroup === groupId)
|
|
535
|
-
.map(
|
|
536
|
-
(child): TimelineElement => ({
|
|
537
|
-
id: child.id,
|
|
538
|
-
domId: child.id,
|
|
539
|
-
tag: "div",
|
|
540
|
-
start: 0,
|
|
541
|
-
duration: 0,
|
|
542
|
-
track: -1,
|
|
543
|
-
}),
|
|
544
|
-
);
|
|
545
|
-
return [...flatMembers, ...domMembers];
|
|
546
|
-
},
|
|
547
|
-
setAudioGroupAttribute.setQuiet,
|
|
548
|
-
(groupId, attr) => revertAudioGroupLive(groupId, attr),
|
|
549
|
-
),
|
|
551
|
+
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
|
552
|
+
setAudioGroupAttribute.revertLive(groupId, attr);
|
|
553
|
+
return refused(reason);
|
|
554
|
+
}),
|
|
550
555
|
),
|
|
551
556
|
},
|
|
552
557
|
setElementFxAttribute: {
|
|
@@ -555,7 +560,10 @@ export function useTimelineEditing({
|
|
|
555
560
|
guard(
|
|
556
561
|
(element) => [element],
|
|
557
562
|
setElementFxAttribute.setQuiet,
|
|
558
|
-
(element, attr) =>
|
|
563
|
+
(reason, element, attr) => {
|
|
564
|
+
setElementFxAttribute.revertLive(element, attr);
|
|
565
|
+
return refused(reason);
|
|
566
|
+
},
|
|
559
567
|
),
|
|
560
568
|
),
|
|
561
569
|
},
|
|
@@ -582,5 +590,9 @@ export function useTimelineEditing({
|
|
|
582
590
|
handleTimelineGroupResize: track(
|
|
583
591
|
guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
|
|
584
592
|
),
|
|
593
|
+
restoreLiveLanes: (restore: Parameters<typeof setElementFxAttribute.restoreLive>[0]) => {
|
|
594
|
+
setElementFxAttribute.restoreLive(restore);
|
|
595
|
+
setAudioGroupAttribute.restoreLive(restore);
|
|
596
|
+
},
|
|
585
597
|
};
|
|
586
598
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildTimelineResizeTimingPatch,
|
|
14
14
|
extendRootDurationIfNeeded,
|
|
15
15
|
formatTimelineAttributeNumber,
|
|
16
|
+
formatTimelineMediaOffset,
|
|
16
17
|
patchIframeDomTiming,
|
|
17
18
|
playbackStartAttributeForElement,
|
|
18
19
|
persistTimelineBatchEdit,
|
|
@@ -377,7 +378,7 @@ export function useTimelineGroupEditing({
|
|
|
377
378
|
];
|
|
378
379
|
if (change.playbackStart != null) {
|
|
379
380
|
const liveAttr = playbackStartAttributeForElement(change.element);
|
|
380
|
-
liveAttrs.push([liveAttr,
|
|
381
|
+
liveAttrs.push([liveAttr, formatTimelineMediaOffset(change.playbackStart)]);
|
|
381
382
|
}
|
|
382
383
|
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
|
|
383
384
|
}
|
package/src/hooks/useToast.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function useToast() {
|
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
const showToast = useCallback(
|
|
53
|
-
(message: string, tone: AppToast["tone"] = "error") => {
|
|
53
|
+
(message: string, tone: AppToast["tone"] = "error"): number => {
|
|
54
54
|
const id = nextToastId++;
|
|
55
55
|
setToasts((prev) => {
|
|
56
56
|
const next = [...prev, { id, message, tone }];
|
|
@@ -65,6 +65,7 @@ export function useToast() {
|
|
|
65
65
|
const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
|
|
66
66
|
timersRef.current.set(id, timer);
|
|
67
67
|
}
|
|
68
|
+
return id;
|
|
68
69
|
},
|
|
69
70
|
[clearTimer, dismissToast],
|
|
70
71
|
);
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,7 @@ export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
|
|
|
162
162
|
export type {
|
|
163
163
|
CanEditTimelineElement,
|
|
164
164
|
TimelineEditPermission,
|
|
165
|
+
TimelineEditOutcome,
|
|
165
166
|
} from "./hooks/timelineEditPermission";
|
|
166
167
|
export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
|
|
167
168
|
export type {
|
|
@@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
|
-
import {
|
|
6
|
-
import { computeThumbnailStrip
|
|
5
|
+
import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
|
|
6
|
+
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
7
7
|
|
|
8
8
|
export interface ImageThumbnailProps {
|
|
9
9
|
imageSrc: string;
|
|
@@ -35,16 +35,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
35
35
|
kind: "image" as const,
|
|
36
36
|
priority,
|
|
37
37
|
rich,
|
|
38
|
-
load:
|
|
39
|
-
const aspect = await probeImageAspect(imageSrc, signal, true);
|
|
40
|
-
return {
|
|
41
|
-
value: { kind: "image" as const, url: imageSrc, aspect },
|
|
42
|
-
weight:
|
|
43
|
-
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
|
|
44
|
-
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
|
|
45
|
-
4,
|
|
46
|
-
};
|
|
47
|
-
},
|
|
38
|
+
load: (signal: AbortSignal) => decodeImageThumbnail(imageSrc, signal),
|
|
48
39
|
}),
|
|
49
40
|
[imageSrc, priority, projectId, rich, sessionEpoch],
|
|
50
41
|
);
|
|
@@ -127,6 +127,11 @@ describe("preview errors", () => {
|
|
|
127
127
|
root = null;
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("puts the preview on the player's once-a-second paused heartbeat", async () => {
|
|
131
|
+
const { player } = await mountPlayer();
|
|
132
|
+
expect(player.hasAttribute("low-power-idle")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
130
135
|
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
131
136
|
await mountPlayer();
|
|
132
137
|
const srcIndex = lifecycleLog.indexOf("src");
|
|
@@ -272,6 +277,29 @@ describe("ready to show", () => {
|
|
|
272
277
|
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
273
278
|
});
|
|
274
279
|
|
|
280
|
+
it("can show a document whose player was ready and painted before its load event", async () => {
|
|
281
|
+
const onReadyToShowChange = vi.fn();
|
|
282
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
283
|
+
const el = Object.assign(player as TestHyperframesPlayer, { ready: true, painted: true });
|
|
284
|
+
|
|
285
|
+
act(() => {
|
|
286
|
+
el.dispatchEvent(new Event("ready"));
|
|
287
|
+
el.dispatchEvent(new Event("painted"));
|
|
288
|
+
el.iframeElement.dispatchEvent(new Event("load"));
|
|
289
|
+
});
|
|
290
|
+
await twoFrames();
|
|
291
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
292
|
+
|
|
293
|
+
for (const state of [
|
|
294
|
+
{ ready: false, painted: true },
|
|
295
|
+
{ ready: true, painted: false },
|
|
296
|
+
]) {
|
|
297
|
+
Object.assign(el, state);
|
|
298
|
+
act(() => void el.iframeElement.dispatchEvent(new Event("load")));
|
|
299
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
275
303
|
it("marks the preview booted when it can show and play, not at ready", async () => {
|
|
276
304
|
usePlayerStore.setState({ previewBooted: false });
|
|
277
305
|
const { player } = await mountPlayer({});
|
|
@@ -39,6 +39,8 @@ interface PlayerProps {
|
|
|
39
39
|
|
|
40
40
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
41
41
|
iframeElement: HTMLIFrameElement;
|
|
42
|
+
ready?: boolean;
|
|
43
|
+
painted?: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const MEDIA_HAVE_FUTURE_DATA = 3;
|
|
@@ -245,8 +247,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
245
247
|
setLoaded(true);
|
|
246
248
|
setPreviewError(null);
|
|
247
249
|
setShaderTransitionLoading(false);
|
|
248
|
-
setCompositionLoading(
|
|
249
|
-
setPainted(
|
|
250
|
+
setCompositionLoading(!player.ready);
|
|
251
|
+
setPainted(Boolean(player.painted));
|
|
250
252
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
251
253
|
if (loadCountRef.current > 1) {
|
|
252
254
|
container.classList.remove("preview-revealing");
|
|
@@ -317,6 +319,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
317
319
|
|
|
318
320
|
player.setAttribute("shader-capture-scale", "1");
|
|
319
321
|
player.setAttribute("shader-loading", "player");
|
|
322
|
+
player.setAttribute("low-power-idle", "");
|
|
320
323
|
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
321
324
|
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
322
325
|
player.style.width = "100%";
|
|
@@ -338,6 +338,24 @@ describe("TimelineAutomationLane", () => {
|
|
|
338
338
|
);
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
+
it("drops the dragged position when the save does not land", async () => {
|
|
342
|
+
const onCommit = vi.fn(async () => ({ status: "refused" as const, reason: "Locked" }));
|
|
343
|
+
const { container } = render(
|
|
344
|
+
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
|
345
|
+
);
|
|
346
|
+
const svg = container.querySelector("svg")!;
|
|
347
|
+
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
|
348
|
+
const before = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
|
|
349
|
+
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
|
350
|
+
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
|
351
|
+
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
|
352
|
+
await act(async () => {});
|
|
353
|
+
expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
|
|
354
|
+
before,
|
|
355
|
+
5,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
341
359
|
it("follows the prop again once the store catches up", () => {
|
|
342
360
|
const { container, rerender } = renderRerenderable(
|
|
343
361
|
<TimelineAutomationLane {...laneProps({ automation: ramp })} />,
|
|
@@ -359,6 +377,25 @@ describe("TimelineAutomationLane", () => {
|
|
|
359
377
|
expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
|
|
360
378
|
});
|
|
361
379
|
|
|
380
|
+
it("commits the dragged points when an older save lands mid-drag", () => {
|
|
381
|
+
const onCommit = vi.fn(async (_next: HfAutomation) => ({ status: "saved" as const }));
|
|
382
|
+
const { container, rerender } = renderRerenderable(
|
|
383
|
+
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
|
384
|
+
);
|
|
385
|
+
const svg = container.querySelector("svg")!;
|
|
386
|
+
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
|
387
|
+
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
|
388
|
+
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
|
389
|
+
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
|
390
|
+
const older = onCommit.mock.calls[0]![0];
|
|
391
|
+
fire(svg, "pointerdown", { clientX: 160, clientY: 40 });
|
|
392
|
+
fire(svg, "pointermove", { clientX: 320, clientY: 40 });
|
|
393
|
+
rerender(<TimelineAutomationLane {...laneProps({ automation: older, onCommit })} />);
|
|
394
|
+
fire(svg, "pointerup", { clientX: 320, clientY: 40 });
|
|
395
|
+
const released = onCommit.mock.calls[1]![0];
|
|
396
|
+
expect(released.lanes[0]!.points[0]!.t).toBeGreaterThan(older.lanes[0]!.points[0]!.t + 1);
|
|
397
|
+
});
|
|
398
|
+
|
|
362
399
|
it("keeps lane order when editing, so the view does not switch parameters", () => {
|
|
363
400
|
// The displayed lane defaults to the first one. Moving the edited lane to
|
|
364
401
|
// the end of the list swapped the lane out from under the pointer on the
|
|
@@ -1627,6 +1664,7 @@ describe("TimelineAutomationLane stretch", () => {
|
|
|
1627
1664
|
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
|
|
1628
1665
|
?.points;
|
|
1629
1666
|
expect(reverted).toEqual(stretchable.lanes[0]?.points);
|
|
1667
|
+
expect(props.onPreview.mock.calls.at(-1)?.[1]).toBe(true);
|
|
1630
1668
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
|
|
1631
1669
|
});
|
|
1632
1670
|
|