@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
|
|
1
2
|
import { roundToCenti } from "../../utils/rounding";
|
|
2
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
4
|
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
@@ -19,13 +20,17 @@ function ceilTimelineTime(value: number): number {
|
|
|
19
20
|
return Math.ceil((value - SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function floorTimelineTime(value: number): number {
|
|
24
|
+
return Math.floor((value + SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
export function resolveTimelineMinDuration(minDuration?: number): number {
|
|
23
28
|
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
|
|
27
|
-
function resolveTimelinePlaybackRate(rate?: number): number {
|
|
28
|
-
return
|
|
32
|
+
export function resolveTimelinePlaybackRate(rate?: number): number {
|
|
33
|
+
return clampPlaybackRate(rate ?? 1);
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
interface TimelineStartTrimClip {
|
|
@@ -38,8 +43,8 @@ interface TimelineStartTrimClip {
|
|
|
38
43
|
/**
|
|
39
44
|
* Delta bounds for trimming a clip's START edge (shared by single-clip and group
|
|
40
45
|
* resize). Left-bounded by how far the start can move toward `minStart` and by the
|
|
41
|
-
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration
|
|
42
|
-
*
|
|
46
|
+
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`, the start
|
|
47
|
+
* floored onto the centisecond grid. Otherwise unrounded; applyClipStartTrimDelta rounds.
|
|
43
48
|
*/
|
|
44
49
|
export function clipStartTrimDeltaBounds(
|
|
45
50
|
clip: TimelineStartTrimClip,
|
|
@@ -51,7 +56,7 @@ export function clipStartTrimDeltaBounds(
|
|
|
51
56
|
clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
|
|
52
57
|
return {
|
|
53
58
|
minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
|
|
54
|
-
maxDelta: clip.duration - minDuration,
|
|
59
|
+
maxDelta: floorTimelineTime(clip.start + clip.duration - minDuration) - clip.start,
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -137,7 +142,9 @@ export function clampTimelineGroupResizeDelta(
|
|
|
137
142
|
const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
|
|
138
143
|
const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
|
|
139
144
|
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
|
|
140
|
-
|
|
145
|
+
const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
|
|
146
|
+
const roundedPastTightestMember = delta > maxDelta + SAVED_MEDIA_OFFSET_TOLERANCE;
|
|
147
|
+
return roundedPastTightestMember ? maxDelta - SAVED_MEDIA_OFFSET_TOLERANCE : delta;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
export function resolveTimelineGroupResize(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
3
|
+
|
|
4
|
+
const x = (transform: string) => Number(/translateX\(([-\d.e]+)px\)/.exec(transform)?.[1]);
|
|
5
|
+
|
|
6
|
+
describe("getTimelinePlayheadTransform", () => {
|
|
7
|
+
it("keeps fractional pixels while playing, so slow motion does not stair-step", () => {
|
|
8
|
+
// Fit zoom on a two-minute film is about 13.3px a second: 120 frames of one second of playback.
|
|
9
|
+
const frames = Array.from({ length: 121 }, (_, i) => i / 120);
|
|
10
|
+
const playing = new Set(frames.map((t) => getTimelinePlayheadTransform(t, 13.3, 80, false, 1)));
|
|
11
|
+
const resting = new Set(frames.map((t) => getTimelinePlayheadTransform(t, 13.3, 80, true, 1)));
|
|
12
|
+
expect(playing.size).toBe(121);
|
|
13
|
+
expect(resting.size).toBeLessThan(20);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("at rest puts the line on the device pixel layout gives its ruler tick", () => {
|
|
17
|
+
// The 1px line sits 4px into the 9px wrapper; the tick is laid out at t * pps - 0.5 and rounded by layout.
|
|
18
|
+
for (const dpr of [1, 1.5, 2]) {
|
|
19
|
+
for (let i = 0; i <= 400; i += 1) {
|
|
20
|
+
const t = i * 0.0137;
|
|
21
|
+
const wrapper = x(getTimelinePlayheadTransform(t, 13.3, 80, true, dpr));
|
|
22
|
+
const lineDevicePx = Math.round((wrapper + 4) * dpr);
|
|
23
|
+
const tickDevicePx = Math.round((80 + t * 13.3 - 0.5) * dpr);
|
|
24
|
+
expect(lineDevicePx, `t=${t} dpr=${dpr}`).toBe(tickDevicePx);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("at time zero at 1x lands the line on the tick's column", () => {
|
|
30
|
+
expect(getTimelinePlayheadTransform(0, 100, 80, true, 1)).toBe("translateX(76px)");
|
|
31
|
+
expect(Math.round(80 - 0.5)).toBe(76 + 4);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getTimelinePlayheadLeft } from "./timelineLayout";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Moves the playhead by transform, with no relayout. Playing, it keeps fractional pixels so slow motion stays
|
|
5
|
+
* smooth; at rest it rounds to device pixels the way layout rounds the ruler ticks, so the line sits on its tick.
|
|
6
|
+
*/
|
|
7
|
+
export function getTimelinePlayheadTransform(
|
|
8
|
+
time: number,
|
|
9
|
+
pixelsPerSecond: number,
|
|
10
|
+
contentOrigin: number,
|
|
11
|
+
atRest: boolean,
|
|
12
|
+
devicePixelRatio = globalThis.devicePixelRatio || 1,
|
|
13
|
+
): string {
|
|
14
|
+
const left = getTimelinePlayheadLeft(time, pixelsPerSecond, contentOrigin);
|
|
15
|
+
if (!atRest) return `translateX(${left}px)`;
|
|
16
|
+
return `translateX(${Math.round(left * devicePixelRatio) / devicePixelRatio}px)`;
|
|
17
|
+
}
|
|
@@ -4,12 +4,12 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
5
|
import {
|
|
6
6
|
getTimelinePlaybackFollowScrollLeft,
|
|
7
|
-
getTimelinePlayheadLeft,
|
|
8
7
|
getTimelineScrubTime,
|
|
9
8
|
getTimelineScrollLeftForZoomTransition,
|
|
10
9
|
getTimelineScrollLeftForZoomAnchor,
|
|
11
10
|
shouldAutoScrollTimeline,
|
|
12
11
|
} from "./timelineLayout";
|
|
12
|
+
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
13
13
|
import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
|
|
14
14
|
|
|
15
15
|
interface UseTimelinePlayheadInput {
|
|
@@ -92,7 +92,12 @@ export function useTimelinePlayhead({
|
|
|
92
92
|
const syncPlayheadPosition = useCallback(
|
|
93
93
|
(time: number) => {
|
|
94
94
|
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
95
|
-
playheadRef.current.style.
|
|
95
|
+
playheadRef.current.style.transform = getTimelinePlayheadTransform(
|
|
96
|
+
time,
|
|
97
|
+
ppsRef.current,
|
|
98
|
+
contentOrigin,
|
|
99
|
+
!usePlayerStore.getState().isPlaying,
|
|
100
|
+
);
|
|
96
101
|
},
|
|
97
102
|
[playheadRef, durationRef, ppsRef, contentOrigin],
|
|
98
103
|
);
|
|
@@ -122,14 +127,24 @@ export function useTimelinePlayhead({
|
|
|
122
127
|
}, [zoomMode, scrollRef]);
|
|
123
128
|
|
|
124
129
|
useMountEffect(() => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
playheadRef.current.style.
|
|
130
|
+
let lastLiveTime = usePlayerStore.getState().currentTime;
|
|
131
|
+
const place = (t: number, atRest: boolean) => {
|
|
132
|
+
if (!playheadRef.current || durationRef.current <= 0) return false;
|
|
133
|
+
playheadRef.current.style.transform = getTimelinePlayheadTransform(
|
|
129
134
|
t,
|
|
130
135
|
ppsRef.current,
|
|
131
136
|
contentOriginRef.current,
|
|
132
|
-
|
|
137
|
+
atRest,
|
|
138
|
+
);
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
|
|
142
|
+
if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true);
|
|
143
|
+
});
|
|
144
|
+
const unsub = liveTime.subscribe((t) => {
|
|
145
|
+
lastLiveTime = t;
|
|
146
|
+
if (!place(t, !usePlayerStore.getState().isPlaying)) return;
|
|
147
|
+
const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
|
|
133
148
|
const scroll = scrollRef.current;
|
|
134
149
|
if (
|
|
135
150
|
!scroll ||
|
|
@@ -150,7 +165,10 @@ export function useTimelinePlayhead({
|
|
|
150
165
|
scroll.scrollLeft = nextScrollLeft;
|
|
151
166
|
}
|
|
152
167
|
});
|
|
153
|
-
return
|
|
168
|
+
return () => {
|
|
169
|
+
unsub();
|
|
170
|
+
unsubPlaying();
|
|
171
|
+
};
|
|
154
172
|
});
|
|
155
173
|
|
|
156
174
|
const seekFromX = useCallback(
|
|
@@ -71,6 +71,8 @@ export function useTimelineProviderState({
|
|
|
71
71
|
canPasteClip,
|
|
72
72
|
theme: themeOverrides,
|
|
73
73
|
sessionEpoch = 0,
|
|
74
|
+
previewIframeRef,
|
|
75
|
+
onZIndexReorder,
|
|
74
76
|
}: TimelineProps = {}): TimelineContextValue {
|
|
75
77
|
const {
|
|
76
78
|
onMoveElement,
|
|
@@ -184,6 +186,8 @@ export function useTimelineProviderState({
|
|
|
184
186
|
});
|
|
185
187
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
186
188
|
expandedElementsRef: timelineElementsRef,
|
|
189
|
+
previewIframeRef,
|
|
190
|
+
onZIndexReorder,
|
|
187
191
|
});
|
|
188
192
|
const {
|
|
189
193
|
draggedClip,
|
|
@@ -73,4 +73,50 @@ describe("useTimelineStackingSync", () => {
|
|
|
73
73
|
mocks.actions = null;
|
|
74
74
|
iframe.remove();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it("uses a host's iframe and commit over Studio's own", async () => {
|
|
78
|
+
const studioCommit = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
mocks.actions = {
|
|
80
|
+
previewIframeRef: { current: null },
|
|
81
|
+
handleDomZIndexReorderCommit: studioCommit,
|
|
82
|
+
};
|
|
83
|
+
const iframe = document.createElement("iframe");
|
|
84
|
+
document.body.appendChild(iframe);
|
|
85
|
+
const node = iframe.contentDocument!.createElement("div");
|
|
86
|
+
node.id = "a";
|
|
87
|
+
iframe.contentDocument!.body.appendChild(node);
|
|
88
|
+
const hostCommit = vi.fn().mockResolvedValue(undefined);
|
|
89
|
+
const element: TimelineElement = {
|
|
90
|
+
id: "a",
|
|
91
|
+
key: "a",
|
|
92
|
+
tag: "div",
|
|
93
|
+
start: 0,
|
|
94
|
+
duration: 2,
|
|
95
|
+
track: 0,
|
|
96
|
+
};
|
|
97
|
+
let sync: ReturnType<typeof useTimelineStackingSync> | null = null;
|
|
98
|
+
function Harness() {
|
|
99
|
+
sync = useTimelineStackingSync({
|
|
100
|
+
expandedElementsRef: { current: [element] },
|
|
101
|
+
previewIframeRef: { current: iframe },
|
|
102
|
+
onZIndexReorder: hostCommit,
|
|
103
|
+
});
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const root = mountReactHarness(<Harness />);
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await sync!.applyStackingPatches([{ key: "a", zIndex: 3 }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(sync!.zSyncEnabled).toBe(true);
|
|
113
|
+
expect(hostCommit).toHaveBeenCalledWith(
|
|
114
|
+
[expect.objectContaining({ element: node, zIndex: 3 })],
|
|
115
|
+
undefined,
|
|
116
|
+
);
|
|
117
|
+
expect(studioCommit).not.toHaveBeenCalled();
|
|
118
|
+
act(() => root.unmount());
|
|
119
|
+
mocks.actions = null;
|
|
120
|
+
iframe.remove();
|
|
121
|
+
});
|
|
76
122
|
});
|
|
@@ -5,23 +5,30 @@ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
|
5
5
|
import { findElementForSelection } from "../../components/editor/domEditingElement";
|
|
6
6
|
import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
|
|
7
7
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
8
|
+
import type { TimelineZIndexReorderCommit } from "../../hooks/useTimelineEditingTypes";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/** With both set, a lane move restacks the preview's z-index to match the rows; each overrides Studio's own. */
|
|
11
|
+
export interface TimelineStackingSyncProps {
|
|
12
|
+
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
|
13
|
+
onZIndexReorder?: TimelineZIndexReorderCommit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface UseTimelineStackingSyncInput extends TimelineStackingSyncProps {
|
|
10
17
|
expandedElementsRef: RefObject<TimelineElement[]>;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
// Lane ↔ stacking
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
|
|
20
|
+
// Lane ↔ stacking sync: the two deps commitDraggedClipMove takes so a lane-change drag also patches
|
|
21
|
+
// z-index, through the same iframe + z persist path the canvas menu and LayersPanel use. Either missing ⇒ no-op.
|
|
22
|
+
export function useTimelineStackingSync({
|
|
23
|
+
expandedElementsRef,
|
|
24
|
+
previewIframeRef,
|
|
25
|
+
onZIndexReorder,
|
|
26
|
+
}: UseTimelineStackingSyncInput) {
|
|
21
27
|
const domEditActions = useDomEditActionsContextOptional();
|
|
22
28
|
const shell = useStudioShellContextOptional();
|
|
23
|
-
const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
|
|
24
|
-
const handleDomZIndexReorderCommit =
|
|
29
|
+
const zSyncPreviewIframeRef = previewIframeRef ?? domEditActions?.previewIframeRef ?? null;
|
|
30
|
+
const handleDomZIndexReorderCommit =
|
|
31
|
+
onZIndexReorder ?? domEditActions?.handleDomZIndexReorderCommit;
|
|
25
32
|
const zSyncActiveCompPath = shell?.activeCompPath ?? null;
|
|
26
33
|
|
|
27
34
|
// Resolve a TimelineElement to its live iframe HTMLElement via the same
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
3
|
+
|
|
4
|
+
describe("timelineElementsChanged", () => {
|
|
5
|
+
const clip = { id: "v", tag: "video", start: 0, duration: 4, track: 1 };
|
|
6
|
+
it.each([
|
|
7
|
+
["muted", { muted: true }],
|
|
8
|
+
["sound", { hasAudio: true }],
|
|
9
|
+
["volume", { volume: 0 }],
|
|
10
|
+
["speed", { playbackRate: 2 }],
|
|
11
|
+
["hidden", { hidden: true }],
|
|
12
|
+
["audio group volume", { audioGroupVolume: 0 }],
|
|
13
|
+
["audio group hidden", { audioGroupHidden: true }],
|
|
14
|
+
["fade in", { fadeIn: 1 }],
|
|
15
|
+
["fade out", { fadeOut: 1 }],
|
|
16
|
+
])("sees a clip whose %s changed with no timing change", (_name, change) => {
|
|
17
|
+
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
|
|
18
|
+
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
|
|
3
|
+
const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
|
|
4
|
+
"id",
|
|
5
|
+
"start",
|
|
6
|
+
"duration",
|
|
7
|
+
"track",
|
|
8
|
+
"sourceDuration",
|
|
9
|
+
"muted",
|
|
10
|
+
"hasAudio",
|
|
11
|
+
"volume",
|
|
12
|
+
"playbackRate",
|
|
13
|
+
"hidden",
|
|
14
|
+
"audioGroupVolume",
|
|
15
|
+
"audioGroupHidden",
|
|
16
|
+
"fadeIn",
|
|
17
|
+
"fadeOut",
|
|
18
|
+
];
|
|
19
|
+
|
|
3
20
|
/** Whether a derived timeline changes any field that affects rendering. */
|
|
4
21
|
export function timelineElementsChanged(
|
|
5
22
|
previous: TimelineElement[],
|
|
@@ -8,13 +25,6 @@ export function timelineElementsChanged(
|
|
|
8
25
|
if (next.length !== previous.length) return true;
|
|
9
26
|
return next.some((element, index) => {
|
|
10
27
|
const prior = previous[index];
|
|
11
|
-
return (
|
|
12
|
-
!prior ||
|
|
13
|
-
element.id !== prior.id ||
|
|
14
|
-
element.start !== prior.start ||
|
|
15
|
-
element.duration !== prior.duration ||
|
|
16
|
-
element.track !== prior.track ||
|
|
17
|
-
element.sourceDuration !== prior.sourceDuration
|
|
18
|
-
);
|
|
28
|
+
return !prior || RENDERED_FIELDS.some((key) => element[key] !== prior[key]);
|
|
19
29
|
});
|
|
20
30
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type PreviewIframeSlot,
|
|
13
13
|
type UseTimelineSyncCallbacksParams,
|
|
14
14
|
} from "./useTimelineSyncCallbacks";
|
|
15
|
-
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
15
|
+
import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
17
|
import { usePlayerStore } from "../store/playerStore";
|
|
18
18
|
|
|
@@ -23,6 +23,11 @@ export const SHADOW_READY_TIMEOUT_MS = 15_000;
|
|
|
23
23
|
// A busy machine can need more than one budget; the shadow keeps loading for this many before it is dropped.
|
|
24
24
|
export const SHADOW_READY_BUDGETS = 3;
|
|
25
25
|
|
|
26
|
+
function restoreSeekPainted(iframe: HTMLIFrameElement | null): Promise<void> | undefined {
|
|
27
|
+
const win = iframe?.contentWindow as IframeWindow | null | undefined;
|
|
28
|
+
return win?.__hfWaitForSeekCompletion?.().catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
function isDocumentHidden(): boolean {
|
|
27
32
|
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
28
33
|
}
|
|
@@ -136,10 +141,16 @@ export function useShadowPreviewReload({
|
|
|
136
141
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
137
142
|
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
138
143
|
const markAdapterReady = useCallback(
|
|
139
|
-
(
|
|
144
|
+
(iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
|
140
145
|
if (gen == null || gen !== shadowGenRef.current) return;
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
const register = () => {
|
|
147
|
+
if (gen !== shadowGenRef.current) return;
|
|
148
|
+
pendingCommitRef.current = { gen, commit };
|
|
149
|
+
promoteWhenReady(gen);
|
|
150
|
+
};
|
|
151
|
+
const painted = restoreSeekPainted(iframe);
|
|
152
|
+
if (painted) void painted.then(register);
|
|
153
|
+
else register();
|
|
143
154
|
},
|
|
144
155
|
[promoteWhenReady],
|
|
145
156
|
);
|
|
@@ -325,6 +325,25 @@ describe("shadow reload readiness and failure", () => {
|
|
|
325
325
|
unmount(root);
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
it("promotes only once the shadow's restore seek has painted its frame", async () => {
|
|
329
|
+
const { getApi, live, gen, root } = beginReload();
|
|
330
|
+
const shadow = makeShadowWithSpies();
|
|
331
|
+
let seekPainted = () => {};
|
|
332
|
+
Object.assign(shadow.iframe.contentWindow!, {
|
|
333
|
+
__hfWaitForSeekCompletion: () => new Promise<void>((resolve) => (seekPainted = resolve)),
|
|
334
|
+
});
|
|
335
|
+
act(() => {
|
|
336
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
337
|
+
getApi().onShadowIframeLoad(gen);
|
|
338
|
+
getApi().onShadowReadyChange(gen, true);
|
|
339
|
+
});
|
|
340
|
+
expect(getApi().iframeRef.current).toBe(live);
|
|
341
|
+
|
|
342
|
+
await act(async () => seekPainted());
|
|
343
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
344
|
+
unmount(root);
|
|
345
|
+
});
|
|
346
|
+
|
|
328
347
|
it("promotes when the loader clears before the adapter is ready", () => {
|
|
329
348
|
const { getApi, gen, root } = beginReload();
|
|
330
349
|
const shadow = makeShadowWithSpies();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { Window } from "happy-dom";
|
|
3
3
|
import {
|
|
4
4
|
buildStandaloneRootTimelineElement,
|
|
@@ -597,3 +597,95 @@ describe("shouldIgnorePlaybackShortcutEvent", () => {
|
|
|
597
597
|
).toBe(false);
|
|
598
598
|
});
|
|
599
599
|
});
|
|
600
|
+
|
|
601
|
+
describe("shouldIgnorePlaybackShortcutEvent while a modal dialog is open", () => {
|
|
602
|
+
afterEach(() => {
|
|
603
|
+
vi.unstubAllGlobals();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// happy-dom has no layout, so the `hidden` attribute stands in for checkVisibility.
|
|
607
|
+
function stubStudioDocument(markup: string): Document {
|
|
608
|
+
const document = createDocument(markup);
|
|
609
|
+
const elementProto = document.defaultView!.Element.prototype as {
|
|
610
|
+
checkVisibility?: (this: Element) => boolean;
|
|
611
|
+
};
|
|
612
|
+
elementProto.checkVisibility = function () {
|
|
613
|
+
return this.closest("[hidden]") === null;
|
|
614
|
+
};
|
|
615
|
+
vi.stubGlobal("document", document);
|
|
616
|
+
return document;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
it("ignores playback keys with focus still on the page behind the dialog", () => {
|
|
620
|
+
const document = stubStudioDocument(
|
|
621
|
+
`<div role="dialog" aria-modal="true"><p>Lint results</p></div>`,
|
|
622
|
+
);
|
|
623
|
+
for (const code of ["Space", "ArrowRight", "KeyL"]) {
|
|
624
|
+
expect(
|
|
625
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent(code, { target: document.body })),
|
|
626
|
+
).toBe(true);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("ignores playback keys with focus inside the dialog", () => {
|
|
631
|
+
const document = stubStudioDocument(
|
|
632
|
+
`<div role="dialog" aria-modal="true" tabindex="-1"><p>Lint results</p></div>`,
|
|
633
|
+
);
|
|
634
|
+
const dialog = document.querySelector("[role='dialog']");
|
|
635
|
+
const text = document.querySelector("p");
|
|
636
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: dialog }))).toBe(
|
|
637
|
+
true,
|
|
638
|
+
);
|
|
639
|
+
expect(
|
|
640
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { target: text })),
|
|
641
|
+
).toBe(true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("keeps playback keys for dialogs that are hidden, inert or not modal", () => {
|
|
645
|
+
for (const markup of [
|
|
646
|
+
`<div role="dialog" aria-modal="true" hidden></div>`,
|
|
647
|
+
`<div inert><div role="dialog" aria-modal="true"></div></div>`,
|
|
648
|
+
`<div role="dialog"></div>`,
|
|
649
|
+
]) {
|
|
650
|
+
const document = stubStudioDocument(markup);
|
|
651
|
+
expect(
|
|
652
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
|
|
653
|
+
).toBe(false);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("counts a dialog under fullscreen only when the fullscreen element holds it", () => {
|
|
658
|
+
const document = stubStudioDocument(
|
|
659
|
+
`<div id="stage"><div id="inner" role="dialog" aria-modal="true"></div></div>` +
|
|
660
|
+
`<div id="outer" role="dialog" aria-modal="true"></div>`,
|
|
661
|
+
);
|
|
662
|
+
const space = () =>
|
|
663
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body }));
|
|
664
|
+
const fullscreenOn = (id: string) =>
|
|
665
|
+
Object.defineProperty(document, "fullscreenElement", {
|
|
666
|
+
configurable: true,
|
|
667
|
+
value: document.getElementById(id),
|
|
668
|
+
});
|
|
669
|
+
fullscreenOn("stage");
|
|
670
|
+
expect(space()).toBe(true);
|
|
671
|
+
document.getElementById("inner")!.remove();
|
|
672
|
+
expect(space()).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("counts an aria-modal dialog where the browser has no checkVisibility", () => {
|
|
676
|
+
const document = createDocument(`<div role="dialog" aria-modal="true"></div>`);
|
|
677
|
+
delete (document.defaultView!.Element.prototype as { checkVisibility?: unknown })
|
|
678
|
+
.checkVisibility;
|
|
679
|
+
vi.stubGlobal("document", document);
|
|
680
|
+
expect(
|
|
681
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
|
|
682
|
+
).toBe(true);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("keeps playback keys when no dialog is open", () => {
|
|
686
|
+
const document = stubStudioDocument(`<main><p>Preview</p></main>`);
|
|
687
|
+
expect(
|
|
688
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
|
|
689
|
+
).toBe(false);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
@@ -41,6 +41,23 @@ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null):
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const MODAL_DIALOG_SELECTOR = "[role=dialog][aria-modal=true]";
|
|
45
|
+
|
|
46
|
+
// An open modal owns the keyboard wherever focus sits, preview iframe included. It counts only
|
|
47
|
+
// when shown: visible, not inert, and not behind a fullscreen element that leaves it out.
|
|
48
|
+
function isModalDialogOpen(): boolean {
|
|
49
|
+
const doc = globalThis.document;
|
|
50
|
+
if (!doc) return false;
|
|
51
|
+
const fullscreen = doc.fullscreenElement;
|
|
52
|
+
return Array.from(doc.querySelectorAll(MODAL_DIALOG_SELECTOR)).some(
|
|
53
|
+
(dialog) =>
|
|
54
|
+
(!fullscreen || fullscreen.contains(dialog)) &&
|
|
55
|
+
!dialog.closest("[inert]") &&
|
|
56
|
+
(typeof dialog.checkVisibility !== "function" ||
|
|
57
|
+
dialog.checkVisibility({ visibilityProperty: true })),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
interface PlaybackShortcutCaptionState {
|
|
45
62
|
isCaptionEditMode: boolean;
|
|
46
63
|
selectedCaptionSegmentCount: number;
|
|
@@ -60,6 +77,7 @@ export function shouldIgnorePlaybackShortcutEvent(
|
|
|
60
77
|
): boolean {
|
|
61
78
|
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
|
62
79
|
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
|
80
|
+
if (isModalDialogOpen()) return true;
|
|
63
81
|
return (
|
|
64
82
|
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
|
65
83
|
captionState.isCaptionEditMode &&
|
|
@@ -62,6 +62,7 @@ export type IframeWindow = Window & {
|
|
|
62
62
|
__timeline?: TimelineLike;
|
|
63
63
|
__timelines?: Record<string, TimelineLike>;
|
|
64
64
|
__clipManifest?: ClipManifest;
|
|
65
|
+
__hfWaitForSeekCompletion?: () => Promise<void>;
|
|
65
66
|
/** Declared runtime-side in core's window.d.ts, which this package cannot see.
|
|
66
67
|
* Every member stays optional and is optional-called, so a runtime that
|
|
67
68
|
* predates the hook degrades to a no-op instead of throwing. */
|