@hyperframes/studio 0.8.138 → 0.8.140
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-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
- package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.html +1 -1
- package/dist/index.js +7327 -6265
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +11 -7
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +2 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
useSafeGsapCommitMutation,
|
|
46
46
|
} from "./useSafeGsapCommitMutation";
|
|
47
47
|
import { requestGsapMutation } from "./gsapMutationClient";
|
|
48
|
+
import { whileScriptWrites } from "../player/previewReloading";
|
|
48
49
|
|
|
49
50
|
async function mutateGsapScript(
|
|
50
51
|
projectId: string,
|
|
@@ -373,7 +374,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
373
374
|
});
|
|
374
375
|
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
|
375
376
|
|
|
376
|
-
const runCommit = useCallback(
|
|
377
|
+
const runCommit = useCallback((pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => whileScriptWrites(async () => {
|
|
377
378
|
const result = await runMutationRequest([mutation], options, showToast, () =>
|
|
378
379
|
mutateGsapScript(pid, "gsap-mutations", targetPath, mutation),
|
|
379
380
|
);
|
|
@@ -381,9 +382,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
381
382
|
trackKeyframeCommit([mutation], result, options);
|
|
382
383
|
options.onResult?.(result);
|
|
383
384
|
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
|
384
|
-
}, [showToast, finalizeSuccessfulMutation]);
|
|
385
|
+
}), [showToast, finalizeSuccessfulMutation]);
|
|
385
386
|
|
|
386
|
-
const runBatchCommit = useCallback(
|
|
387
|
+
const runBatchCommit = useCallback((pid: string, compositionPath: string | null, targetPath: string, calls: CommitMutationCall[], options: CommitMutationOptions) => whileScriptWrites(async () => {
|
|
387
388
|
const first = calls[0];
|
|
388
389
|
const last = calls.at(-1);
|
|
389
390
|
if (!first || !last) return;
|
|
@@ -402,7 +403,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
402
403
|
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
403
404
|
const allPatched = instantPatches.length === calls.length;
|
|
404
405
|
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
|
|
405
|
-
}, [showToast, finalizeSuccessfulMutation]);
|
|
406
|
+
}), [showToast, finalizeSuccessfulMutation]);
|
|
406
407
|
|
|
407
408
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
408
409
|
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
3
3
|
import { resolveBeatSourceTrack } from "../utils/timelineInspector";
|
|
4
4
|
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
|
|
5
|
+
import { whenTimelineIdle } from "../player/components/timelineMotion";
|
|
5
6
|
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
|
|
6
7
|
import { mergeUserBeats } from "../utils/beatEditing";
|
|
7
8
|
import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
|
|
@@ -79,7 +80,7 @@ async function loadBeatAnalysis(
|
|
|
79
80
|
): Promise<{ analysis: MusicAnalysis; times: number[]; strengths: number[] } | null> {
|
|
80
81
|
let promise = analysisCache.get(musicSrc);
|
|
81
82
|
if (!promise) {
|
|
82
|
-
promise = analyzeMusicFromUrl(musicSrc);
|
|
83
|
+
promise = analyzeMusicFromUrl(musicSrc, { pause: whenTimelineIdle });
|
|
83
84
|
cacheAnalysis(musicSrc, promise);
|
|
84
85
|
}
|
|
85
86
|
try {
|
|
@@ -19,10 +19,11 @@ afterEach(() => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
describe("undo that reloads the preview", () => {
|
|
22
|
-
it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
|
|
22
|
+
it("keeps the timeline's clips and selection until the reloaded preview reports its own", async () => {
|
|
23
23
|
const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
|
|
24
24
|
usePlayerStore.getState().setElements(clips);
|
|
25
25
|
usePlayerStore.getState().setTimelineReady(true);
|
|
26
|
+
usePlayerStore.getState().setSelectedElementId("a");
|
|
26
27
|
const reloadPreview = vi.fn();
|
|
27
28
|
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
28
29
|
null;
|
|
@@ -46,7 +47,11 @@ describe("undo that reloads the preview", () => {
|
|
|
46
47
|
|
|
47
48
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
48
49
|
const player = usePlayerStore.getState();
|
|
49
|
-
expect([player.elements, player.timelineReady]).toEqual([
|
|
50
|
+
expect([player.elements, player.timelineReady, player.selectedElementId]).toEqual([
|
|
51
|
+
clips,
|
|
52
|
+
true,
|
|
53
|
+
"a",
|
|
54
|
+
]);
|
|
50
55
|
});
|
|
51
56
|
});
|
|
52
57
|
|
|
@@ -248,10 +248,7 @@ export function usePreviewPersistence({
|
|
|
248
248
|
reloadPreview,
|
|
249
249
|
nestedFiles,
|
|
250
250
|
);
|
|
251
|
-
if (strategy === "full")
|
|
252
|
-
usePlayerStore.getState().setSelectedElementId(null);
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
251
|
+
if (strategy === "full") return;
|
|
255
252
|
// A soft restore patched the reverted attributes onto the live preview, but the
|
|
256
253
|
// player store keeps its own copy and that copy is what the automation lanes
|
|
257
254
|
// draw — so without this an undone envelope edit stayed invisible until a
|
|
@@ -13,6 +13,7 @@ vi.mock("@hyperframes/sdk", () => ({
|
|
|
13
13
|
import type { Composition } from "@hyperframes/sdk";
|
|
14
14
|
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
|
15
15
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
|
+
import { notifyExternalFileReload } from "./externalFileReloadBus";
|
|
16
17
|
|
|
17
18
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
18
19
|
|
|
@@ -173,6 +174,25 @@ describe("useSdkSession ownership", () => {
|
|
|
173
174
|
await act(async () => root.unmount());
|
|
174
175
|
expect(published.dispose).toHaveBeenCalledOnce();
|
|
175
176
|
});
|
|
177
|
+
|
|
178
|
+
it("reopens on a project-wide external change whatever composition it holds", async () => {
|
|
179
|
+
vi.stubGlobal(
|
|
180
|
+
"fetch",
|
|
181
|
+
vi.fn(async () => response("INDEX")),
|
|
182
|
+
);
|
|
183
|
+
openComposition.mockImplementation(async () => fakeSession());
|
|
184
|
+
const root = createRoot(document.createElement("div"));
|
|
185
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
186
|
+
await flushAsyncEffects();
|
|
187
|
+
expect(openComposition).toHaveBeenCalledOnce();
|
|
188
|
+
await act(async () => notifyExternalFileReload("film.html"));
|
|
189
|
+
await flushAsyncEffects();
|
|
190
|
+
expect(openComposition).toHaveBeenCalledOnce();
|
|
191
|
+
await act(async () => notifyExternalFileReload("."));
|
|
192
|
+
await flushAsyncEffects();
|
|
193
|
+
expect(openComposition).toHaveBeenCalledTimes(2);
|
|
194
|
+
act(() => root.unmount());
|
|
195
|
+
});
|
|
176
196
|
});
|
|
177
197
|
|
|
178
198
|
describe("useSdkSession unreachable project", () => {
|
|
@@ -409,7 +409,10 @@ export function useSdkSession(
|
|
|
409
409
|
useEffect(
|
|
410
410
|
() =>
|
|
411
411
|
addExternalFileReloadListener((changedPath) => {
|
|
412
|
-
|
|
412
|
+
// "." is a project-wide change (a reconnect or a replaced folder): every session reloads.
|
|
413
|
+
if (changedPath === "." || changedPath === activeCompPathRef.current) {
|
|
414
|
+
setReloadToken((token) => token + 1);
|
|
415
|
+
}
|
|
413
416
|
}),
|
|
414
417
|
[],
|
|
415
418
|
);
|
|
@@ -7,6 +7,15 @@ import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils"
|
|
|
7
7
|
import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
|
|
8
8
|
import { useThumbnailStripSize } from "./useThumbnailStripSize";
|
|
9
9
|
|
|
10
|
+
const zoom = vi.hoisted(() => ({ previewing: false, listeners: new Set<() => void>() }));
|
|
11
|
+
vi.mock("../player/components/timelineZoomInput", () => ({
|
|
12
|
+
isTimelineZoomPreviewing: () => zoom.previewing,
|
|
13
|
+
subscribeTimelineZoomPreview: (listener: () => void) => {
|
|
14
|
+
zoom.listeners.add(listener);
|
|
15
|
+
return () => zoom.listeners.delete(listener);
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
10
19
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
11
20
|
|
|
12
21
|
it("does not re-render the strip when the observer reports the size it already holds", () => {
|
|
@@ -48,6 +57,62 @@ it("does not re-render the strip when the observer reports the size it already h
|
|
|
48
57
|
}
|
|
49
58
|
});
|
|
50
59
|
|
|
60
|
+
it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
|
|
61
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
62
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
63
|
+
const host = document.createElement("div");
|
|
64
|
+
document.body.append(host);
|
|
65
|
+
let width = 300;
|
|
66
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
|
|
67
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
68
|
+
const root = createRoot(host);
|
|
69
|
+
function Harness() {
|
|
70
|
+
const [size, ref] = useThumbnailStripSize();
|
|
71
|
+
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
act(() => root.render(<Harness />));
|
|
75
|
+
zoom.previewing = true;
|
|
76
|
+
width = 600;
|
|
77
|
+
act(() => reportResize(600, 40));
|
|
78
|
+
expect(host.textContent).toBe("300x40");
|
|
79
|
+
zoom.previewing = false;
|
|
80
|
+
act(() => zoom.listeners.forEach((listener) => listener()));
|
|
81
|
+
expect(host.textContent).toBe("600x40");
|
|
82
|
+
} finally {
|
|
83
|
+
zoom.previewing = false;
|
|
84
|
+
act(() => root.unmount());
|
|
85
|
+
host.remove();
|
|
86
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("measures again each time a preview is laid out, though nothing was read during it", () => {
|
|
91
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
92
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
93
|
+
const host = document.createElement("div");
|
|
94
|
+
document.body.append(host);
|
|
95
|
+
let width = 300;
|
|
96
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
|
|
97
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
98
|
+
const root = createRoot(host);
|
|
99
|
+
function Harness() {
|
|
100
|
+
const [size, ref] = useThumbnailStripSize();
|
|
101
|
+
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
act(() => root.render(<Harness />));
|
|
105
|
+
// A zoom-out lays its target out mid-ease; the resize is reported only later.
|
|
106
|
+
width = 600;
|
|
107
|
+
act(() => zoom.listeners.forEach((listener) => listener()));
|
|
108
|
+
expect(host.textContent).toBe("600x40");
|
|
109
|
+
} finally {
|
|
110
|
+
act(() => root.unmount());
|
|
111
|
+
host.remove();
|
|
112
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
51
116
|
describe("on a scroll", () => {
|
|
52
117
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
53
118
|
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback, useState } from "react";
|
|
2
2
|
import { flushSync } from "react-dom";
|
|
3
|
+
import {
|
|
4
|
+
isTimelineZoomPreviewing,
|
|
5
|
+
subscribeTimelineZoomPreview,
|
|
6
|
+
} from "../player/components/timelineZoomInput";
|
|
3
7
|
|
|
4
8
|
export interface StripSize {
|
|
5
9
|
width: number;
|
|
@@ -24,7 +28,6 @@ const GAP_WARNING: IntersectionObserverInit & { scrollMargin: string } = {
|
|
|
24
28
|
scrollMargin: `0px ${IN_VIEW_CHUNK_PX / 2}px`,
|
|
25
29
|
};
|
|
26
30
|
|
|
27
|
-
// A short strip keeps every tile, as before, so only long clips do work on a scroll.
|
|
28
31
|
const SHORT_STRIP_MAX_PX = 8 * IN_VIEW_CHUNK_PX;
|
|
29
32
|
const isShort = (width: number) => width <= SHORT_STRIP_MAX_PX;
|
|
30
33
|
|
|
@@ -62,6 +65,7 @@ let shared: {
|
|
|
62
65
|
resize: ResizeObserver;
|
|
63
66
|
presence: IntersectionObserver | null;
|
|
64
67
|
gaps: IntersectionObserver | null;
|
|
68
|
+
stopZoomWatch: () => void;
|
|
65
69
|
} | null = null;
|
|
66
70
|
|
|
67
71
|
const offsetOf = (scroller: Element | null) =>
|
|
@@ -97,8 +101,23 @@ const read = (target: Element, strip: Strip) => {
|
|
|
97
101
|
const commit = (updates: (readonly [Apply, Partial<StripSize>])[]) =>
|
|
98
102
|
flushSync(() => updates.forEach(([apply, patch]) => apply(patch)));
|
|
99
103
|
|
|
104
|
+
// A zoom preview scales the strips: reads wait, and each preview's layout re-measures them all.
|
|
105
|
+
const remeasureAfterPreview = () => {
|
|
106
|
+
if (isTimelineZoomPreviewing()) return;
|
|
107
|
+
commit(
|
|
108
|
+
[...strips].map(
|
|
109
|
+
([target, strip]) =>
|
|
110
|
+
[
|
|
111
|
+
strip.apply,
|
|
112
|
+
{ width: target.clientWidth, height: target.clientHeight, ...read(target, strip) },
|
|
113
|
+
] as const,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
100
118
|
const refresh = () => {
|
|
101
119
|
frame = 0;
|
|
120
|
+
if (isTimelineZoomPreviewing()) return;
|
|
102
121
|
const offsetsNow = new Map<Element | null, { x: number; y: number }>();
|
|
103
122
|
const updates: (readonly [Apply, Partial<StripSize>])[] = [];
|
|
104
123
|
for (const [target, strip] of strips) {
|
|
@@ -122,6 +141,7 @@ const scheduleRefresh = () => {
|
|
|
122
141
|
};
|
|
123
142
|
|
|
124
143
|
const measure = (entries: { target: Element; size?: { width: number; height: number } }[]) =>
|
|
144
|
+
isTimelineZoomPreviewing() ||
|
|
125
145
|
commit(
|
|
126
146
|
entries.flatMap(({ target, size }) => {
|
|
127
147
|
const strip = strips.get(target);
|
|
@@ -153,6 +173,7 @@ function acquire() {
|
|
|
153
173
|
gaps: observeIntersections(
|
|
154
174
|
(entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
|
|
155
175
|
),
|
|
176
|
+
stopZoomWatch: subscribeTimelineZoomPreview(remeasureAfterPreview),
|
|
156
177
|
};
|
|
157
178
|
window.addEventListener("scroll", scheduleRefresh, { capture: true, passive: true });
|
|
158
179
|
}
|
|
@@ -164,6 +185,7 @@ function release() {
|
|
|
164
185
|
shared?.resize.disconnect();
|
|
165
186
|
shared?.presence?.disconnect();
|
|
166
187
|
shared?.gaps?.disconnect();
|
|
188
|
+
shared?.stopZoomWatch();
|
|
167
189
|
shared = null;
|
|
168
190
|
window.removeEventListener("scroll", scheduleRefresh, { capture: true });
|
|
169
191
|
cancelAnimationFrame(frame);
|
|
@@ -275,6 +275,16 @@ describe("useTimelineDeleteOps: undo race", () => {
|
|
|
275
275
|
await flushStudioPendingEdits();
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
it("shows the shortened length while the last clip's delete is still saving", async () => {
|
|
280
|
+
const { gate, recordEdit, deleteSelection } = mountRaceHarness();
|
|
281
|
+
usePlayerStore.getState().setDuration(6);
|
|
282
|
+
const deletePromise = deleteSelection();
|
|
283
|
+
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
|
|
284
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
285
|
+
gate.resolve();
|
|
286
|
+
await deletePromise;
|
|
287
|
+
});
|
|
278
288
|
});
|
|
279
289
|
|
|
280
290
|
// Regression: the live preview kept a deleted clip until its reload landed, and composition
|
|
@@ -8,11 +8,13 @@ import { usePlayerStore } from "../player";
|
|
|
8
8
|
import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildPatchTarget,
|
|
13
|
+
removeIframeTimelineElements,
|
|
14
|
+
syncCompositionDurationWithEnd,
|
|
15
|
+
} from "./timelineEditingHelpers";
|
|
12
16
|
import { captureDurationRollback, timingGestureStep } from "./timelineTimingSync";
|
|
13
17
|
import { setLinkInSource } from "../components/editor/mediaLinkEdits";
|
|
14
|
-
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
15
|
-
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
16
18
|
import {
|
|
17
19
|
resolveMainTrackDeleteRippleShifts,
|
|
18
20
|
resolveShiftedElements,
|
|
@@ -143,16 +145,13 @@ export function useTimelineDeleteOps({
|
|
|
143
145
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
144
146
|
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
|
145
147
|
// store durations are runtime-truncated.
|
|
146
|
-
const
|
|
147
|
-
const patchedContent = unlinkInSource(
|
|
148
|
-
setCompositionDurationToContent(removedContent, deleteContentEnd),
|
|
149
|
-
alsoUnlink,
|
|
150
|
-
);
|
|
148
|
+
const synced = syncCompositionDurationWithEnd(removedContent);
|
|
149
|
+
const patchedContent = unlinkInSource(synced.source, alsoUnlink);
|
|
151
150
|
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
152
151
|
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
153
152
|
rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
154
|
-
if (
|
|
155
|
-
usePlayerStore.getState().setDuration(
|
|
153
|
+
if (synced.contentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
154
|
+
usePlayerStore.getState().setDuration(synced.contentEnd);
|
|
156
155
|
}
|
|
157
156
|
return { disk: removedContent, after: patchedContent };
|
|
158
157
|
},
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ export {
|
|
|
63
63
|
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
64
64
|
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
65
65
|
export { TRACK_H } from "./player/components/timelineLayout";
|
|
66
|
+
export { currentTimelineRange, zoomTimelineToRange } from "./player/components/timelineZoomInput";
|
|
66
67
|
export type { TimelineTrackPadding } from "./player/components/timelineLayout";
|
|
67
68
|
export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
|
|
68
69
|
export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
|
|
@@ -160,6 +161,7 @@ export type {
|
|
|
160
161
|
// can race a write still in flight (see useTrackPendingTimelineEdit.ts).
|
|
161
162
|
export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
|
|
162
163
|
export { revertNewestStudioPendingEdit } from "./utils/studioPendingEdits";
|
|
164
|
+
export { cancelNewestStudioWaitingPress } from "./utils/studioPendingEdits";
|
|
163
165
|
export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
|
|
164
166
|
export type {
|
|
165
167
|
CanEditTimelineElement,
|
|
@@ -11,15 +11,98 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
|
|
|
11
11
|
useThumbnailLease: leaseSpy,
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
+
// One observer serves every waveform; it reports each one near as it is watched, unless a test
|
|
15
|
+
// reports by hand.
|
|
16
|
+
const nearScreen = {
|
|
17
|
+
auto: true,
|
|
18
|
+
watched: [] as Element[],
|
|
19
|
+
report: (_target: Element, _near: boolean) => {},
|
|
20
|
+
};
|
|
21
|
+
globalThis.IntersectionObserver = class {
|
|
22
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
23
|
+
nearScreen.report = (target, near) =>
|
|
24
|
+
callback(
|
|
25
|
+
[{ target, isIntersecting: near } as IntersectionObserverEntry],
|
|
26
|
+
this as unknown as IntersectionObserver,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
observe(target: Element) {
|
|
30
|
+
nearScreen.watched.push(target);
|
|
31
|
+
if (nearScreen.auto) nearScreen.report(target, true);
|
|
32
|
+
}
|
|
33
|
+
unobserve() {}
|
|
34
|
+
disconnect() {}
|
|
35
|
+
} as unknown as typeof IntersectionObserver;
|
|
36
|
+
|
|
37
|
+
const EMPTY_STRIP = { width: 0, height: 0, inViewStart: 0, inViewEnd: 0 };
|
|
38
|
+
const watchGap = vi.hoisted(() => vi.fn());
|
|
39
|
+
const strip = vi.hoisted(() => ({
|
|
40
|
+
size: { width: 0, height: 0, inViewStart: 0, inViewEnd: 0 },
|
|
41
|
+
listeners: new Set<() => void>(),
|
|
42
|
+
}));
|
|
43
|
+
// The strip hook's own tests cover scrolls and moves; here it reports what the test sets.
|
|
44
|
+
vi.mock("../../hooks/useThumbnailStripSize", async () => {
|
|
45
|
+
const { useSyncExternalStore } = await import("react");
|
|
46
|
+
const subscribe = (listener: () => void) => {
|
|
47
|
+
strip.listeners.add(listener);
|
|
48
|
+
return () => strip.listeners.delete(listener);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
useThumbnailStripSize: () => [
|
|
52
|
+
useSyncExternalStore(subscribe, () => strip.size),
|
|
53
|
+
() => {},
|
|
54
|
+
watchGap,
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const setStrip = (size: typeof EMPTY_STRIP) =>
|
|
60
|
+
act(() => {
|
|
61
|
+
strip.size = size;
|
|
62
|
+
strip.listeners.forEach((listener) => listener());
|
|
63
|
+
});
|
|
64
|
+
|
|
14
65
|
import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
|
|
15
66
|
|
|
16
67
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
68
|
|
|
69
|
+
const mounted: Array<ReturnType<typeof createRoot>> = [];
|
|
70
|
+
|
|
18
71
|
afterEach(() => {
|
|
19
|
-
|
|
72
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
73
|
+
vi.restoreAllMocks();
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
leaseSpy.mockReset();
|
|
76
|
+
leaseSpy.mockImplementation(() => ({ status: "loading" as const }));
|
|
77
|
+
strip.size = EMPTY_STRIP;
|
|
78
|
+
nearScreen.auto = true;
|
|
79
|
+
watchGap.mockClear();
|
|
20
80
|
document.body.innerHTML = "";
|
|
21
81
|
});
|
|
22
82
|
|
|
83
|
+
/** Renders a ready waveform for a two-peak sound into `parent`. */
|
|
84
|
+
function renderReadyWaveform(parent: HTMLElement = document.body) {
|
|
85
|
+
// One snapshot, as the lease returns, so a re-render alone does not redraw.
|
|
86
|
+
const ready = { status: "ready", value: { kind: "waveform", peaks: [0.5, 1] } } as never;
|
|
87
|
+
leaseSpy.mockImplementation(() => ready);
|
|
88
|
+
const host = document.createElement("div");
|
|
89
|
+
parent.append(host);
|
|
90
|
+
const root = createRoot(host);
|
|
91
|
+
mounted.push(root);
|
|
92
|
+
act(() => {
|
|
93
|
+
root.render(
|
|
94
|
+
<AudioWaveform
|
|
95
|
+
audioUrl="/media/voice.wav"
|
|
96
|
+
label=""
|
|
97
|
+
labelColor="#fff"
|
|
98
|
+
projectId="project-a"
|
|
99
|
+
sessionEpoch={1}
|
|
100
|
+
priority="visible"
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
23
106
|
/** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
|
|
24
107
|
function recordingCanvas() {
|
|
25
108
|
const fills: Array<[string, number, number, number, number]> = [];
|
|
@@ -51,6 +134,75 @@ describe("AudioWaveform", () => {
|
|
|
51
134
|
]);
|
|
52
135
|
});
|
|
53
136
|
|
|
137
|
+
it("draws only the span of the clip it is given", () => {
|
|
138
|
+
const { canvas, fills } = recordingCanvas();
|
|
139
|
+
// The clip's second half holds the loud peak only.
|
|
140
|
+
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1, null, { from: 0.5, to: 1 });
|
|
141
|
+
const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
|
|
142
|
+
expect(bars.map(([, , y, , height]) => [y, height])).toEqual([
|
|
143
|
+
[0, 20],
|
|
144
|
+
[0, 20],
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("draws only the stretch of a long clip near the screen, and follows it as the clip moves", () => {
|
|
149
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
150
|
+
setStrip({ width: 10_000, height: 40, inViewStart: 2048, inViewEnd: 4096 });
|
|
151
|
+
renderReadyWaveform();
|
|
152
|
+
const canvas = document.querySelector("canvas")!;
|
|
153
|
+
const placed = () => [parseFloat(canvas.style.left), parseFloat(canvas.style.width)];
|
|
154
|
+
expect(placed()[0]).toBeCloseTo(20.48);
|
|
155
|
+
expect(placed()[1]).toBeCloseTo(20.48);
|
|
156
|
+
// A move or scroll carries the clip: the hook reports a new stretch and the bars follow.
|
|
157
|
+
setStrip({ width: 10_000, height: 40, inViewStart: 4096, inViewEnd: 6144 });
|
|
158
|
+
expect(placed()[0]).toBeCloseTo(40.96);
|
|
159
|
+
expect(placed()[1]).toBeCloseTo(20.48);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("watches no ends of a clip drawn whole", () => {
|
|
163
|
+
setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
|
|
164
|
+
renderReadyWaveform();
|
|
165
|
+
expect(watchGap.mock.calls.filter(([gap]) => gap)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("leaves a waveform off screen undrawn through a zoom, and draws it once it comes near", () => {
|
|
169
|
+
nearScreen.auto = false;
|
|
170
|
+
const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
171
|
+
setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
|
|
172
|
+
renderReadyWaveform();
|
|
173
|
+
const root = nearScreen.watched.at(-1)!;
|
|
174
|
+
expect(getContext).not.toHaveBeenCalled();
|
|
175
|
+
act(() => nearScreen.report(root, true));
|
|
176
|
+
const drawn = getContext.mock.calls.length;
|
|
177
|
+
expect(drawn).toBeGreaterThan(0);
|
|
178
|
+
act(() => nearScreen.report(root, false));
|
|
179
|
+
setStrip({ width: 2000, height: 40, inViewStart: 0, inViewEnd: 2000 });
|
|
180
|
+
expect(getContext.mock.calls.length).toBe(drawn);
|
|
181
|
+
act(() => nearScreen.report(root, true));
|
|
182
|
+
expect(getContext.mock.calls.length).toBe(drawn + 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("redraws a short clip, drawn whole, when a zoom changes its width", () => {
|
|
186
|
+
const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
187
|
+
setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
|
|
188
|
+
renderReadyWaveform();
|
|
189
|
+
const drawn = getContext.mock.calls.length;
|
|
190
|
+
setStrip({ width: 2000, height: 40, inViewStart: 0, inViewEnd: 2000 });
|
|
191
|
+
expect(getContext.mock.calls.length).toBe(drawn + 1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("watches both undrawn ends of a long clip, so a move without a scroll re-measures it", () => {
|
|
195
|
+
setStrip({ width: 10_000, height: 40, inViewStart: 2048, inViewEnd: 4096 });
|
|
196
|
+
renderReadyWaveform();
|
|
197
|
+
const gaps = [
|
|
198
|
+
...new Set(watchGap.mock.calls.map(([gap]) => gap).filter(Boolean)),
|
|
199
|
+
] as HTMLElement[];
|
|
200
|
+
expect(gaps.map((gap) => [gap.style.width, gap.style.left])).toEqual([
|
|
201
|
+
["20.48%", ""],
|
|
202
|
+
["", "40.96%"],
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
54
206
|
it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
|
|
55
207
|
const { canvas, fills } = recordingCanvas();
|
|
56
208
|
// A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.
|