@hyperframes/studio 0.8.85 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
- package/dist/chunk-7PLTC6SV.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +207 -161
- package/dist/index.html +2 -2
- package/dist/index.js +2084 -1705
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +53 -2
- package/src/components/TimelineToolbar.tsx +119 -101
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +179 -0
- package/src/components/nle/useCompositionStack.ts +26 -20
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
- package/src/player/components/Timeline.test.ts +32 -19
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineLayout.ts +16 -23
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineGeometry.test.tsx +105 -0
- package/src/player/components/useTimelineGeometry.ts +18 -10
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelinePlayhead.ts +1 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/liveTime.ts +6 -1
- package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7VDQIGOU.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -115,3 +115,108 @@ describe("useTimelineGeometry restore-scroll effect", () => {
|
|
|
115
115
|
expect(scroll.scrollLeft).toBe(300);
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
function ScaleProbe({
|
|
120
|
+
duration,
|
|
121
|
+
width = 1200,
|
|
122
|
+
clips = duration > 0,
|
|
123
|
+
seen,
|
|
124
|
+
}: {
|
|
125
|
+
duration: number;
|
|
126
|
+
width?: number;
|
|
127
|
+
clips?: boolean;
|
|
128
|
+
seen: { pps: number; fitPps: number };
|
|
129
|
+
}) {
|
|
130
|
+
const ppsRef = useRef(0);
|
|
131
|
+
const fitPpsRef = useRef(0);
|
|
132
|
+
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
133
|
+
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
134
|
+
const { pps, fitPps } = useTimelineGeometry({
|
|
135
|
+
viewportWidth: width,
|
|
136
|
+
effectiveDuration: duration,
|
|
137
|
+
zoomMode,
|
|
138
|
+
manualZoomPercent,
|
|
139
|
+
ppsRef,
|
|
140
|
+
fitPpsRef,
|
|
141
|
+
draggedClip: null,
|
|
142
|
+
resizingClip: null,
|
|
143
|
+
expandedElements: clips ? [{ ...element, start: 0, duration }] : [],
|
|
144
|
+
isDragging: useRef(false),
|
|
145
|
+
scrollRef: useRef<HTMLDivElement>(null),
|
|
146
|
+
lastScrollLeftRef: useRef(0),
|
|
147
|
+
contentOrigin: 32,
|
|
148
|
+
});
|
|
149
|
+
seen.pps = pps;
|
|
150
|
+
seen.fitPps = fitPps;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe("useTimelineGeometry keeps the scale when only the length changes", () => {
|
|
155
|
+
const seen = { pps: 0, fitPps: 0 };
|
|
156
|
+
const renderAt = (duration: number, width?: number, clips?: boolean) =>
|
|
157
|
+
act(() =>
|
|
158
|
+
root.render(<ScaleProbe duration={duration} width={width} clips={clips} seen={seen} />),
|
|
159
|
+
);
|
|
160
|
+
const within = (pps: number) => Math.abs(seen.pps - pps) < seen.fitPps * 0.005;
|
|
161
|
+
|
|
162
|
+
it("keeps a short film's clips the same size when the first edit lengthens it", () => {
|
|
163
|
+
usePlayerStore.setState({ zoomMode: "fit", manualZoomPercent: 100 });
|
|
164
|
+
renderAt(17);
|
|
165
|
+
const before = seen.pps;
|
|
166
|
+
act(() => usePlayerStore.getState().pinTimelineZoom(seen.pps, seen.fitPps));
|
|
167
|
+
renderAt(25);
|
|
168
|
+
expect(seen.fitPps).toBeLessThan(before);
|
|
169
|
+
expect(within(before)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps the scale in manual zoom when a later edit shortens the film", () => {
|
|
173
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
|
|
174
|
+
renderAt(17);
|
|
175
|
+
const before = seen.pps;
|
|
176
|
+
renderAt(12);
|
|
177
|
+
expect(seen.fitPps).toBeGreaterThan(before / 1.5);
|
|
178
|
+
expect(within(before)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("still scales with the fit width when the timeline is resized, and when the length first loads", () => {
|
|
182
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
|
|
183
|
+
renderAt(0);
|
|
184
|
+
renderAt(17);
|
|
185
|
+
expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
|
|
186
|
+
renderAt(17, 900);
|
|
187
|
+
expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("refits when another composition opens, which empties the clips first", () => {
|
|
191
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
|
|
192
|
+
renderAt(120);
|
|
193
|
+
renderAt(120, undefined, false);
|
|
194
|
+
renderAt(5);
|
|
195
|
+
expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("refits after a project reset, which empties the clips and the length", () => {
|
|
199
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
|
|
200
|
+
renderAt(120);
|
|
201
|
+
renderAt(0);
|
|
202
|
+
renderAt(17);
|
|
203
|
+
expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps a lengthened film's scale near the maximum zoom", () => {
|
|
207
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 100_000 });
|
|
208
|
+
renderAt(17);
|
|
209
|
+
const before = seen.pps;
|
|
210
|
+
expect(before).toBeLessThan(seen.fitPps * 1000);
|
|
211
|
+
renderAt(25);
|
|
212
|
+
expect(within(before)).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("leaves the manual zoom alone in fit mode", () => {
|
|
216
|
+
usePlayerStore.setState({ zoomMode: "fit", manualZoomPercent: 100 });
|
|
217
|
+
renderAt(17);
|
|
218
|
+
renderAt(25);
|
|
219
|
+
expect(seen.pps).toBe(seen.fitPps);
|
|
220
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(100);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useEffect, useRef, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
|
|
3
|
-
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
3
|
+
import { computePinnedZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
4
4
|
import {
|
|
5
5
|
DRAG_EXTEND_MARGIN_PX,
|
|
6
6
|
getTimelineDisplayContentWidth,
|
|
@@ -43,12 +43,9 @@ export function useTimelineGeometry({
|
|
|
43
43
|
lastScrollLeftRef,
|
|
44
44
|
contentOrigin,
|
|
45
45
|
}: UseTimelineGeometryInput) {
|
|
46
|
-
// Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
|
|
47
|
-
// comps show a 60s ruler with usable empty space (see getTimelineFitPps).
|
|
48
46
|
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
|
|
49
47
|
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
|
50
48
|
ppsRef.current = pps;
|
|
51
|
-
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
52
49
|
// Drag-to-extend: while a clip is dragged, keep the rendered extent a margin
|
|
53
50
|
// past the ghost's end. Holding the pointer in the right edge zone then keeps
|
|
54
51
|
// auto-scroll stepping (scrollWidth grows with the ghost), so the timeline
|
|
@@ -65,12 +62,12 @@ export function useTimelineGeometry({
|
|
|
65
62
|
const resizeGhostEndPx = resizingClip?.started
|
|
66
63
|
? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX
|
|
67
64
|
: 0;
|
|
68
|
-
// The timeline canvas always fills at least the viewport width AND the
|
|
69
|
-
//
|
|
65
|
+
// The timeline canvas always fills at least the viewport width AND the fit
|
|
66
|
+
// span at this zoom: the ruler + empty track lanes keep going into
|
|
70
67
|
// the space instead of leaving dead black — CapCut-style. Only the RENDERED
|
|
71
68
|
// extent grows; clip positions/durations are untouched.
|
|
72
69
|
const displayContentWidth = getTimelineDisplayContentWidth({
|
|
73
|
-
|
|
70
|
+
effectiveDuration,
|
|
74
71
|
viewportWidth,
|
|
75
72
|
contentOrigin,
|
|
76
73
|
pps,
|
|
@@ -108,8 +105,7 @@ export function useTimelineGeometry({
|
|
|
108
105
|
return () => cancelAnimationFrame(raf);
|
|
109
106
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
107
|
}, [expandedElements, zoomMode]);
|
|
111
|
-
// Publish the live scale
|
|
112
|
-
// path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
|
|
108
|
+
// Publish the live scale for readers outside <Timeline> (the toolbar's zoom controls).
|
|
113
109
|
// In a useEffect (not the render body) so React-18 concurrent replay — Suspense
|
|
114
110
|
// retry, transitions, StrictMode double-invoke — can't double-publish. The write is
|
|
115
111
|
// idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the
|
|
@@ -117,6 +113,18 @@ export function useTimelineGeometry({
|
|
|
117
113
|
useEffect(() => {
|
|
118
114
|
usePlayerStore.getState().setTimelineScale(pps, fitPps);
|
|
119
115
|
}, [pps, fitPps]);
|
|
116
|
+
// In manual zoom a length change keeps the on-screen scale instead of rescaling every
|
|
117
|
+
// clip, whichever edit caused it. A composition switch empties the clips first, so it refits.
|
|
118
|
+
const hasClips = expandedElements.length > 0;
|
|
119
|
+
const lastScale = useRef({ effectiveDuration, pps, hasClips });
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
const last = lastScale.current;
|
|
122
|
+
lastScale.current = { effectiveDuration, pps, hasClips };
|
|
123
|
+
if (zoomMode !== "manual" || !last.hasClips || !hasClips) return;
|
|
124
|
+
if (last.effectiveDuration === effectiveDuration) return;
|
|
125
|
+
// Before paint; the percent is clamped against the new fit, not the store's last published one.
|
|
126
|
+
usePlayerStore.setState({ manualZoomPercent: computePinnedZoomPercent(last.pps, fitPps) });
|
|
127
|
+
});
|
|
120
128
|
|
|
121
129
|
return {
|
|
122
130
|
pps,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
|
5
|
+
import { timelineNeedsLabelColumn } from "./timelineViewModel";
|
|
6
|
+
|
|
7
|
+
export function useTimelineLabelColumn(
|
|
8
|
+
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
|
9
|
+
elements: readonly TimelineElement[],
|
|
10
|
+
) {
|
|
11
|
+
const labelMode = useMemo(
|
|
12
|
+
() => timelineNeedsLabelColumn(animationsByElement, elements),
|
|
13
|
+
[animationsByElement, elements],
|
|
14
|
+
);
|
|
15
|
+
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
16
|
+
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
17
|
+
return { labelMode, contentOrigin };
|
|
18
|
+
}
|
|
@@ -34,5 +34,10 @@ export function useTimelineLaneRowIndexes(
|
|
|
34
34
|
[groups],
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const groupMemberTracks = useMemo(
|
|
38
|
+
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
39
|
+
[groups],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return { logicalRowsByTrack, groupByAnchor, groupMemberTracks };
|
|
38
43
|
}
|
|
@@ -104,7 +104,7 @@ export function useTimelinePlayhead({
|
|
|
104
104
|
|
|
105
105
|
useEffect(() => {
|
|
106
106
|
syncPlayheadPosition(currentTime);
|
|
107
|
-
}, [currentTime, pps, syncPlayheadPosition]);
|
|
107
|
+
}, [currentTime, pps, syncPlayheadPosition, timelineReady, elementsLength]);
|
|
108
108
|
|
|
109
109
|
useLayoutEffect(() => {
|
|
110
110
|
const scroll = scrollRef.current;
|
|
@@ -19,7 +19,8 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
|
|
19
19
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
|
20
20
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
21
21
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
22
|
-
import { GUTTER, LABEL_COL_W
|
|
22
|
+
import { GUTTER, LABEL_COL_W } from "./timelineLayout";
|
|
23
|
+
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
|
|
23
24
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
24
25
|
import { ClipContentOnceShown } from "./timelineClipChildren";
|
|
25
26
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
|
@@ -33,11 +34,7 @@ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
|
33
34
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
34
35
|
import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
35
36
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
36
|
-
import {
|
|
37
|
-
getEffectiveTimelineDuration,
|
|
38
|
-
getTimelinePreviewElement,
|
|
39
|
-
timelineNeedsLabelColumn,
|
|
40
|
-
} from "./timelineViewModel";
|
|
37
|
+
import { getEffectiveTimelineDuration, getTimelinePreviewElement } from "./timelineViewModel";
|
|
41
38
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
42
39
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
43
40
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -70,6 +67,7 @@ export function useTimelineProviderState({
|
|
|
70
67
|
onDuplicateClip,
|
|
71
68
|
canPasteClip,
|
|
72
69
|
theme: themeOverrides,
|
|
70
|
+
showAudioEffects = true,
|
|
73
71
|
sessionEpoch = 0,
|
|
74
72
|
previewIframeRef,
|
|
75
73
|
onZIndexReorder,
|
|
@@ -107,12 +105,7 @@ export function useTimelineProviderState({
|
|
|
107
105
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
108
106
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
109
107
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
110
|
-
const labelMode =
|
|
111
|
-
() => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
|
|
112
|
-
[gsapAnimations, timelineElements],
|
|
113
|
-
);
|
|
114
|
-
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
115
|
-
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
108
|
+
const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
|
|
116
109
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
117
110
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
118
111
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -448,6 +441,7 @@ export function useTimelineProviderState({
|
|
|
448
441
|
laneGapStrips,
|
|
449
442
|
dropPreview: assetDrop.dropPreview,
|
|
450
443
|
theme,
|
|
444
|
+
showAudioEffects,
|
|
451
445
|
displayTrackOrder: displayLayout.displayTrackOrder,
|
|
452
446
|
rowHeights: displayLayout.displayRowHeights,
|
|
453
447
|
rowGeometry: displayLayout.rowGeometry,
|
|
@@ -546,29 +546,30 @@ export function useTimelineRangeSelection({
|
|
|
546
546
|
[cancelActiveGesture, isGestureSessionCurrent],
|
|
547
547
|
);
|
|
548
548
|
|
|
549
|
-
// Escape
|
|
550
|
-
// otherwise
|
|
549
|
+
// Escape cancels an in-flight band, captured so a host's own Escape never also fires;
|
|
550
|
+
// otherwise it clears any lingering multi-selection.
|
|
551
551
|
useEffect(() => {
|
|
552
|
+
const onBandEscape = (e: KeyboardEvent) => {
|
|
553
|
+
if (e.key !== "Escape" || !(marqueeRef.current || isRangeSelecting.current)) return;
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
cancelActiveGesture(true, true);
|
|
557
|
+
};
|
|
552
558
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
553
559
|
if (e.key !== "Escape") return;
|
|
554
560
|
const store = usePlayerStore.getState();
|
|
555
|
-
|
|
556
|
-
if (marquee) {
|
|
557
|
-
cancelActiveGesture(true, true);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (isRangeSelecting.current || isDragging.current) {
|
|
561
|
+
if (isDragging.current) {
|
|
561
562
|
cancelActiveGesture(true, true);
|
|
562
563
|
return;
|
|
563
564
|
}
|
|
564
|
-
// Escape with no marquee clears the whole selection — primary AND set.
|
|
565
|
-
// setSelectedElementId(null) also collapses the multi-select set.
|
|
566
565
|
if (store.selectedElementId || store.selectedElementIds.size > 0) {
|
|
567
566
|
store.setSelectedElementId(null);
|
|
568
567
|
}
|
|
569
568
|
};
|
|
570
|
-
|
|
571
|
-
|
|
569
|
+
const listeners = new AbortController();
|
|
570
|
+
window.addEventListener("keydown", onBandEscape, { capture: true, signal: listeners.signal });
|
|
571
|
+
window.addEventListener("keydown", onKeyDown, { signal: listeners.signal });
|
|
572
|
+
return () => listeners.abort();
|
|
572
573
|
}, [cancelActiveGesture, isDragging]);
|
|
573
574
|
|
|
574
575
|
const previousSessionEpochRef = useRef(sessionEpoch);
|
|
@@ -140,6 +140,7 @@ export function useShadowPreviewReload({
|
|
|
140
140
|
|
|
141
141
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
142
142
|
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
143
|
+
const previewGeneration = useCallback(() => shadowGenRef.current, []);
|
|
143
144
|
const markAdapterReady = useCallback(
|
|
144
145
|
(iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
|
145
146
|
if (gen == null || gen !== shadowGenRef.current) return;
|
|
@@ -261,5 +262,6 @@ export function useShadowPreviewReload({
|
|
|
261
262
|
setShadowIframeNode,
|
|
262
263
|
beginShadowReload,
|
|
263
264
|
resetPreviewSlots,
|
|
265
|
+
previewGeneration,
|
|
264
266
|
};
|
|
265
267
|
}
|
|
@@ -649,3 +649,194 @@ function stubVisibility(initial: DocumentVisibilityState) {
|
|
|
649
649
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
650
650
|
};
|
|
651
651
|
}
|
|
652
|
+
|
|
653
|
+
describe("useTimelinePlayer scene swap", () => {
|
|
654
|
+
function liveFilm(swap?: (html: string) => Promise<void>) {
|
|
655
|
+
const { adapter, win } = makeAdapterWindow();
|
|
656
|
+
const iframe = makeFakeIframe(swap ? { ...win, __hfSwapScenes: swap } : win);
|
|
657
|
+
iframe.src = "http://localhost/api/projects/demo/preview";
|
|
658
|
+
const harness = renderTimelinePlayerHarness();
|
|
659
|
+
act(() => {
|
|
660
|
+
harness.getApi().iframeRef.current = iframe;
|
|
661
|
+
harness.getApi().onIframeLoad();
|
|
662
|
+
});
|
|
663
|
+
return { ...harness, adapter };
|
|
664
|
+
}
|
|
665
|
+
function playingFilm(swap: (html: string) => Promise<void>) {
|
|
666
|
+
const film = liveFilm(swap);
|
|
667
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
668
|
+
act(() => film.getApi().play());
|
|
669
|
+
film.adapter.pause.mockClear();
|
|
670
|
+
return film;
|
|
671
|
+
}
|
|
672
|
+
const roles = (api: ReturnType<ReturnType<typeof liveFilm>["getApi"]>) =>
|
|
673
|
+
api.previewSlots.map((slot) => slot.role);
|
|
674
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
675
|
+
|
|
676
|
+
it("swaps the rebuilt preview's scenes into the live preview with no shadow reload", async () => {
|
|
677
|
+
const swap = vi.fn(async () => {});
|
|
678
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
679
|
+
const { getApi } = liveFilm(swap);
|
|
680
|
+
act(() => getApi().refreshPlayer());
|
|
681
|
+
await settle();
|
|
682
|
+
expect(swap).toHaveBeenCalledWith("<html>v2</html>", expect.anything());
|
|
683
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("falls back to the full reload when the preview refuses the swap", async () => {
|
|
687
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
688
|
+
const { getApi } = liveFilm(async () => {
|
|
689
|
+
throw new Error("the film changed outside its scenes");
|
|
690
|
+
});
|
|
691
|
+
act(() => getApi().refreshPlayer());
|
|
692
|
+
await settle();
|
|
693
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("reloads the whole film at once when the preview cannot swap", () => {
|
|
697
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
698
|
+
const { getApi } = liveFilm();
|
|
699
|
+
act(() => getApi().refreshPlayer());
|
|
700
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
701
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("never swaps in an older document that arrives after a newer reload started", async () => {
|
|
705
|
+
const swap = vi.fn(async () => {});
|
|
706
|
+
const replies: Array<(r: Response) => void> = [];
|
|
707
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
708
|
+
() => new Promise<Response>((resolve) => replies.push(resolve)),
|
|
709
|
+
);
|
|
710
|
+
const { getApi } = liveFilm(swap);
|
|
711
|
+
act(() => getApi().refreshPlayer());
|
|
712
|
+
act(() => getApi().refreshPlayer());
|
|
713
|
+
replies[1]!(new Response("<html>v3</html>"));
|
|
714
|
+
await settle();
|
|
715
|
+
replies[0]!(new Response("<html>v2</html>"));
|
|
716
|
+
await settle();
|
|
717
|
+
expect(swap.mock.calls).toEqual([["<html>v3</html>", expect.anything()]]);
|
|
718
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("cancels an older swap still waiting when a newer edit starts, so it cannot land after that edit's reload", async () => {
|
|
722
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html>v</html>"));
|
|
723
|
+
let captionsArrive = () => {};
|
|
724
|
+
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
|
725
|
+
const landed: string[] = [];
|
|
726
|
+
let calls = 0;
|
|
727
|
+
// As the runtime does: the first waits for caption overrides, then refuses if cancelled; the second is refused.
|
|
728
|
+
const swap = async (_html: string, signal?: AbortSignal) => {
|
|
729
|
+
if (++calls > 1) throw new Error("the film changed outside its scenes");
|
|
730
|
+
await captions;
|
|
731
|
+
if (signal?.aborted) throw new Error("the swap was cancelled");
|
|
732
|
+
landed.push("edit 1");
|
|
733
|
+
};
|
|
734
|
+
const { getApi } = liveFilm(swap);
|
|
735
|
+
act(() => getApi().refreshPlayer());
|
|
736
|
+
await settle();
|
|
737
|
+
act(() => getApi().refreshPlayer());
|
|
738
|
+
await settle();
|
|
739
|
+
expect(roles(getApi())).toContain("shadow");
|
|
740
|
+
captionsArrive();
|
|
741
|
+
await settle();
|
|
742
|
+
expect(landed).toEqual([]);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("drops a pending swap's fallback once the preview was replaced, e.g. by a composition switch", async () => {
|
|
746
|
+
let refuse!: (e: Error) => void;
|
|
747
|
+
const swap = vi.fn(() => new Promise<void>((_, reject) => (refuse = reject)));
|
|
748
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
749
|
+
const { getApi } = liveFilm(swap);
|
|
750
|
+
act(() => getApi().refreshPlayer());
|
|
751
|
+
await settle();
|
|
752
|
+
expect(swap).toHaveBeenCalledTimes(1);
|
|
753
|
+
act(() => getApi().resetPreviewSlots());
|
|
754
|
+
const next = makeFakeIframe(makeAdapterWindow().win);
|
|
755
|
+
next.src = "http://localhost/api/projects/demo/preview/comp/compositions/intro.html";
|
|
756
|
+
act(() => {
|
|
757
|
+
getApi().iframeRef.current = next;
|
|
758
|
+
getApi().onIframeLoad();
|
|
759
|
+
});
|
|
760
|
+
refuse(new Error("the preview was torn down during the swap"));
|
|
761
|
+
await settle();
|
|
762
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("skips the swap for an edit made while a full reload is in flight", async () => {
|
|
766
|
+
const fetchSpy = vi
|
|
767
|
+
.spyOn(globalThis, "fetch")
|
|
768
|
+
.mockResolvedValue(new Response("<html>v2</html>"));
|
|
769
|
+
const { getApi } = liveFilm(async () => {
|
|
770
|
+
throw new Error("the film changed outside its scenes");
|
|
771
|
+
});
|
|
772
|
+
act(() => getApi().refreshPlayer());
|
|
773
|
+
await settle();
|
|
774
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
775
|
+
const firstShadow = getApi().previewSlots[1]!.gen;
|
|
776
|
+
fetchSpy.mockClear();
|
|
777
|
+
act(() => getApi().refreshPlayer());
|
|
778
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
779
|
+
expect(getApi().previewSlots[1]!.gen).toBeGreaterThan(firstShadow);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("keeps a playing film playing through a swap, ready to swap the next edit", async () => {
|
|
783
|
+
const fetchSpy = vi
|
|
784
|
+
.spyOn(globalThis, "fetch")
|
|
785
|
+
.mockImplementation(async () => new Response("<html>v2</html>"));
|
|
786
|
+
const swap = vi.fn(async () => {});
|
|
787
|
+
const { getApi, adapter } = playingFilm(swap);
|
|
788
|
+
act(() => getApi().refreshPlayer());
|
|
789
|
+
await settle();
|
|
790
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
791
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
792
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
793
|
+
act(() => getApi().refreshPlayer());
|
|
794
|
+
await settle();
|
|
795
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
796
|
+
expect(swap).toHaveBeenCalledTimes(2);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("shows a playing film stopped at its new end when a swap cuts it short of the playhead", async () => {
|
|
800
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
801
|
+
let length = 6;
|
|
802
|
+
// As the runtime's swap does when the edit ends the film at or before the playhead.
|
|
803
|
+
const cutShort = async () => {
|
|
804
|
+
length = 5;
|
|
805
|
+
adapter.pause();
|
|
806
|
+
adapter.seek(5);
|
|
807
|
+
};
|
|
808
|
+
const { getApi, adapter } = playingFilm(cutShort);
|
|
809
|
+
adapter.getDuration = () => length;
|
|
810
|
+
adapter.seek(5.5);
|
|
811
|
+
act(() => getApi().refreshPlayer());
|
|
812
|
+
await settle();
|
|
813
|
+
await act(async () => void (await new Promise((r) => setTimeout(r, 50))));
|
|
814
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
815
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
816
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
817
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("keeps a playing film playing through a refused swap, from where the live frame had reached", async () => {
|
|
821
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
822
|
+
const { getApi, adapter } = playingFilm(async () => {
|
|
823
|
+
throw new Error("the film changed outside its scenes");
|
|
824
|
+
});
|
|
825
|
+
adapter.seek(3);
|
|
826
|
+
act(() => getApi().refreshPlayer());
|
|
827
|
+
// The live frame plays on while the swap is tried; the fallback reload starts from here.
|
|
828
|
+
adapter.seek(4.5);
|
|
829
|
+
await settle();
|
|
830
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
831
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
832
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
833
|
+
const shadow = makeShadowWithSpies();
|
|
834
|
+
act(() => {
|
|
835
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
836
|
+
getApi().onShadowIframeLoad(gen);
|
|
837
|
+
getApi().onShadowReadyChange(gen, true);
|
|
838
|
+
});
|
|
839
|
+
expect(shadow.adapter.getTime()).toBe(4.5);
|
|
840
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
841
|
+
});
|
|
842
|
+
});
|
|
@@ -38,6 +38,7 @@ import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
|
38
38
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
39
39
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
40
40
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
41
|
+
import { sceneSwapFor } from "../sceneSwap";
|
|
41
42
|
|
|
42
43
|
export interface UseTimelinePlayerOptions {
|
|
43
44
|
/** Runs right after a reloaded preview becomes the live iframe. */
|
|
@@ -391,6 +392,7 @@ export function useTimelinePlayer({
|
|
|
391
392
|
setShadowIframeNode,
|
|
392
393
|
beginShadowReload,
|
|
393
394
|
resetPreviewSlots,
|
|
395
|
+
previewGeneration,
|
|
394
396
|
} = useShadowPreviewReload({
|
|
395
397
|
iframeRef,
|
|
396
398
|
getAdapter,
|
|
@@ -443,19 +445,39 @@ export function useTimelinePlayer({
|
|
|
443
445
|
setIsPlaying(false);
|
|
444
446
|
return false;
|
|
445
447
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
448
|
+
const reloadWholeFilm = useCallback(
|
|
449
|
+
(url: string) => {
|
|
450
|
+
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
451
|
+
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
452
|
+
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
453
|
+
beginShadowReload(url);
|
|
454
|
+
},
|
|
455
|
+
[saveSeekPosition, getAdapter, beginShadowReload],
|
|
456
|
+
);
|
|
457
|
+
const refreshGenRef = useRef(0);
|
|
458
|
+
const swapCancelRef = useRef<AbortController | null>(null);
|
|
446
459
|
const refreshPlayer = useCallback(() => {
|
|
447
460
|
const iframe = iframeRef.current;
|
|
448
461
|
if (!iframe) return;
|
|
462
|
+
swapCancelRef.current?.abort(new Error("superseded by a newer edit"));
|
|
463
|
+
const cancel = new AbortController();
|
|
464
|
+
swapCancelRef.current = cancel;
|
|
449
465
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
450
|
-
|
|
451
|
-
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
452
|
-
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
453
|
-
const src = iframe.src;
|
|
454
|
-
const url = new URL(src, window.location.origin);
|
|
466
|
+
const url = new URL(iframe.src, window.location.origin);
|
|
455
467
|
url.searchParams.set("_t", String(Date.now()));
|
|
456
468
|
applyPreviewVariablesToUrl(url);
|
|
457
|
-
|
|
458
|
-
|
|
469
|
+
const gen = ++refreshGenRef.current;
|
|
470
|
+
const slot = previewGeneration();
|
|
471
|
+
// A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
|
|
472
|
+
const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
|
|
473
|
+
const swap = sceneSwapFor(iframe);
|
|
474
|
+
if (!swap || isRefreshingRef.current) return reloadWholeFilm(url.toString());
|
|
475
|
+
swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
|
|
476
|
+
if (!isCurrent()) return;
|
|
477
|
+
logReload("scene-swap-refused", { reason: String(error) });
|
|
478
|
+
reloadWholeFilm(url.toString());
|
|
479
|
+
});
|
|
480
|
+
}, [reloadWholeFilm, previewGeneration]);
|
|
459
481
|
const pauseRef = useRef(pause);
|
|
460
482
|
pauseRef.current = pause;
|
|
461
483
|
|