@hyperframes/studio 0.8.81 → 0.8.82
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-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
- package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2414 -2172
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- 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/anchoredResizeReleaseShift.test.ts +2 -6
- 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/propertyPanelAudioFxGroup.test.tsx +71 -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/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- 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 +36 -37
- 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/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 +8 -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/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- 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/useTimelinePlayhead.ts +26 -8
- 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/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
|
@@ -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(
|
|
@@ -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. */
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
12
13
|
|
|
13
14
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
14
15
|
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
|
|
@@ -46,6 +47,26 @@ describe("parseTimelineFromDOM — media in-point", () => {
|
|
|
46
47
|
});
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
describe("parseTimelineFromDOM — in-point read as playback reads it", () => {
|
|
51
|
+
it.each([
|
|
52
|
+
['data-playback-start="-1" data-media-start="2"', 2, "playback-start"],
|
|
53
|
+
['data-playback-start="abc" data-media-start="2"', 2, "playback-start"],
|
|
54
|
+
['data-media-start="junk"', 0, "media-start"],
|
|
55
|
+
['data-media-start="1.5s"', 0, "media-start"],
|
|
56
|
+
])("%s", (inPoint, playbackStart, playbackStartAttr) => {
|
|
57
|
+
const doc = makeDoc(
|
|
58
|
+
`<div data-composition-id="root"><video id="v" class="clip" data-start="0" data-duration="4" ${inPoint}></video></div>`,
|
|
59
|
+
);
|
|
60
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
61
|
+
const video = doc.getElementById("v")!;
|
|
62
|
+
expect(element.playbackStart).toBe(readMediaOffsetSeconds((n) => video.getAttribute(n)));
|
|
63
|
+
expect([element.playbackStart, element.playbackStartAttr]).toEqual([
|
|
64
|
+
playbackStart,
|
|
65
|
+
playbackStartAttr,
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
49
70
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
50
71
|
it("bridges a real GSAP transition marker onto both named clips", () => {
|
|
51
72
|
document.body.innerHTML = `
|
|
@@ -526,3 +547,74 @@ describe("a composition clip's source length", () => {
|
|
|
526
547
|
expect(card?.sourceDuration).toBeUndefined();
|
|
527
548
|
});
|
|
528
549
|
});
|
|
550
|
+
|
|
551
|
+
describe("what the live clip list says a clip plays", () => {
|
|
552
|
+
function manifestVideo(attrs: string): TimelineElement {
|
|
553
|
+
const doc = makeDoc(
|
|
554
|
+
`<div data-composition-id="root"><video id="v" src="a.mp4" data-start="0" data-duration="4" ${attrs}></video></div>`,
|
|
555
|
+
);
|
|
556
|
+
return createTimelineElementFromManifestClip({
|
|
557
|
+
clip: {
|
|
558
|
+
id: "v",
|
|
559
|
+
label: "v",
|
|
560
|
+
kind: "video",
|
|
561
|
+
tagName: "video",
|
|
562
|
+
start: 0,
|
|
563
|
+
duration: 4,
|
|
564
|
+
track: 0,
|
|
565
|
+
assetUrl: null,
|
|
566
|
+
},
|
|
567
|
+
fallbackIndex: 0,
|
|
568
|
+
doc,
|
|
569
|
+
hostEl: doc.getElementById("v"),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
it("carries data-volume and muted from the element", () => {
|
|
574
|
+
expect(manifestVideo('data-volume="0" muted')).toMatchObject({ volume: 0, muted: true });
|
|
575
|
+
const plain = manifestVideo("");
|
|
576
|
+
expect(plain.volume).toBeUndefined();
|
|
577
|
+
expect(plain.muted).toBeUndefined();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("carries the clip-edge fades, and drops a zero one", () => {
|
|
581
|
+
const faded = manifestVideo('data-fade-in="1.5" data-fade-out="0"');
|
|
582
|
+
expect(faded.fadeIn).toBe(1.5);
|
|
583
|
+
expect(faded.fadeOut).toBeUndefined();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("gives a video with neither muted nor data-has-audio sound, as the compiler does", () => {
|
|
587
|
+
expect(manifestVideo("").hasAudio).toBe(true);
|
|
588
|
+
expect(manifestVideo("muted").hasAudio).toBeUndefined();
|
|
589
|
+
expect(manifestVideo('data-has-audio="false"').hasAudio).toBeUndefined();
|
|
590
|
+
expect(manifestVideo('muted data-has-audio="true"')).toMatchObject({
|
|
591
|
+
hasAudio: true,
|
|
592
|
+
muted: true,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it.each(["", ' data-has-audio="true"'])(
|
|
597
|
+
"does not give a timed wrapper the sound of an untimed video%s inside it",
|
|
598
|
+
(videoAttrs) => {
|
|
599
|
+
const doc = makeDoc(
|
|
600
|
+
`<div data-composition-id="root"><div id="w" data-start="0" data-duration="4"><video src="a.mp4"${videoAttrs}></video></div></div>`,
|
|
601
|
+
);
|
|
602
|
+
const wrapper = createTimelineElementFromManifestClip({
|
|
603
|
+
clip: {
|
|
604
|
+
id: "w",
|
|
605
|
+
label: "w",
|
|
606
|
+
kind: "element",
|
|
607
|
+
tagName: "div",
|
|
608
|
+
start: 0,
|
|
609
|
+
duration: 4,
|
|
610
|
+
track: 0,
|
|
611
|
+
assetUrl: null,
|
|
612
|
+
},
|
|
613
|
+
fallbackIndex: 0,
|
|
614
|
+
doc,
|
|
615
|
+
hostEl: doc.getElementById("w"),
|
|
616
|
+
});
|
|
617
|
+
expect(wrapper.hasAudio).toBeUndefined();
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
});
|
|
@@ -304,8 +304,6 @@ export function parseTimelineFromDOM(
|
|
|
304
304
|
if (mediaEl.tagName === "IMG") {
|
|
305
305
|
entry.tag = "img";
|
|
306
306
|
}
|
|
307
|
-
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
308
|
-
if (vol) entry.volume = parseFloat(vol);
|
|
309
307
|
// Override AFTER the helper (which sets the raw relative attribute) so the
|
|
310
308
|
// resolved absolute URL wins — the Studio can then fetch the asset
|
|
311
309
|
// regardless of whether the attribute value was relative or absolute.
|
|
@@ -13,7 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
-
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
16
|
+
import { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Layer-reveal lift transparency
|
|
@@ -168,14 +168,34 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
|
|
|
168
168
|
: null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
|
|
172
|
+
export function readPlaybackStartAttributes(
|
|
173
|
+
getAttr: AttrReader,
|
|
174
|
+
): Pick<TimelineElement, "playbackStart" | "playbackStartAttr"> {
|
|
175
|
+
const playbackStartAttr =
|
|
176
|
+
getAttr("data-playback-start") != null
|
|
177
|
+
? "playback-start"
|
|
178
|
+
: getAttr("data-media-start") != null
|
|
179
|
+
? "media-start"
|
|
180
|
+
: undefined;
|
|
181
|
+
return playbackStartAttr
|
|
182
|
+
? { playbackStart: readMediaOffsetSeconds(getAttr), playbackStartAttr }
|
|
183
|
+
: {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function playbackStartAttributeForElement(
|
|
187
|
+
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
188
|
+
): "data-media-start" | "data-playback-start" {
|
|
189
|
+
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
|
|
190
|
+
? "data-playback-start"
|
|
191
|
+
: "data-media-start";
|
|
192
|
+
}
|
|
193
|
+
|
|
171
194
|
function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
|
|
177
|
-
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
|
|
178
|
-
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
|
|
195
|
+
Object.assign(
|
|
196
|
+
entry,
|
|
197
|
+
readPlaybackStartAttributes((n) => el.getAttribute(n)),
|
|
198
|
+
);
|
|
179
199
|
|
|
180
200
|
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
181
201
|
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
|
|
@@ -193,9 +213,25 @@ function setOptional<K extends keyof TimelineElement>(
|
|
|
193
213
|
else entry[key] = value;
|
|
194
214
|
}
|
|
195
215
|
|
|
196
|
-
|
|
216
|
+
function readVolume(el: Element, media: Element): number | undefined {
|
|
217
|
+
const volume = Number.parseFloat(
|
|
218
|
+
el.getAttribute("data-volume") ?? media.getAttribute("data-volume") ?? "",
|
|
219
|
+
);
|
|
220
|
+
return Number.isFinite(volume) ? volume : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
|
|
197
224
|
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
198
|
-
|
|
225
|
+
const media = resolveMediaElement(el) ?? el;
|
|
226
|
+
const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
|
|
227
|
+
const hasAudio = el.getAttribute("data-has-audio");
|
|
228
|
+
const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
|
|
229
|
+
setOptional(entry, "hasAudio", sound ? true : undefined);
|
|
230
|
+
setOptional(entry, "muted", muted ? true : undefined);
|
|
231
|
+
setOptional(entry, "volume", readVolume(el, media));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function applyFadeMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
199
235
|
const fades = readElementFades(el);
|
|
200
236
|
setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
|
|
201
237
|
setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
|
|
@@ -204,6 +240,7 @@ function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
204
240
|
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
205
241
|
applyPlaybackMetadataFromElement(entry, el);
|
|
206
242
|
applyAudioMetadataFromElement(entry, el);
|
|
243
|
+
applyFadeMetadataFromElement(entry, el);
|
|
207
244
|
|
|
208
245
|
const mediaEl = resolveMediaElement(el);
|
|
209
246
|
if (!mediaEl) return;
|
|
@@ -49,7 +49,7 @@ export interface TimelineViewportBudgets {
|
|
|
49
49
|
|
|
50
50
|
const MEBIBYTE = 1024 * 1024;
|
|
51
51
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
52
|
-
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K
|
|
52
|
+
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // Decode-memory cap, 4K width / a 66 px 16:9 tile; 40 px clips reuse frames past it.
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* The sole default budget owner for timeline viewport and media virtualization.
|
|
@@ -55,8 +55,9 @@ export interface TimelineElement {
|
|
|
55
55
|
playbackRate?: number;
|
|
56
56
|
sourceDuration?: number;
|
|
57
57
|
volume?: number;
|
|
58
|
-
/** `data-has-audio
|
|
58
|
+
/** A video with sound to mix (`data-has-audio`, or unmuted without it); `muted` silences it. */
|
|
59
59
|
hasAudio?: boolean;
|
|
60
|
+
muted?: boolean;
|
|
60
61
|
/** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
|
|
61
62
|
fadeIn?: number;
|
|
62
63
|
fadeOut?: number;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Mounts the timeline chrome by package name with no StudioShellProvider, as a host app does.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
AudioMeterStrip,
|
|
7
|
+
TimelineHistoryButtons,
|
|
8
|
+
TimelineToolbar,
|
|
9
|
+
useClipboard,
|
|
10
|
+
usePlayerStore,
|
|
11
|
+
type UseClipboardOptions,
|
|
12
|
+
} from "@hyperframes/studio";
|
|
13
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
14
|
+
import { useAudioMetersVisible } from "./utils/audioMeterVisibility";
|
|
15
|
+
|
|
16
|
+
installReactActEnvironment();
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.setState({ elements: [], selectedElementId: null });
|
|
21
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("timeline chrome package exports, outside Studio's shell", () => {
|
|
25
|
+
it("mounts the toolbar with the host's history and no Add beat", async () => {
|
|
26
|
+
const onUndo = vi.fn();
|
|
27
|
+
const root = mountReactHarness(
|
|
28
|
+
<TimelineToolbar history={{ canUndo: true, onUndo }} showAddBeat={false} />,
|
|
29
|
+
);
|
|
30
|
+
const undo = document.querySelector<HTMLButtonElement>('button[aria-label="Undo"]');
|
|
31
|
+
act(() => undo?.click());
|
|
32
|
+
expect(onUndo).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(document.querySelector('button[aria-label="Add beat at playhead"]')).toBeNull();
|
|
34
|
+
await act(async () => root.unmount());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("mounts the history buttons disabled when given nothing", async () => {
|
|
38
|
+
const root = mountReactHarness(<TimelineHistoryButtons />);
|
|
39
|
+
const buttons = Array.from(document.querySelectorAll("button"));
|
|
40
|
+
expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
|
|
41
|
+
await act(async () => root.unmount());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("mounts the meter strip on the host's preview iframe", async () => {
|
|
45
|
+
usePlayerStore.setState({
|
|
46
|
+
elements: [{ id: "music", tag: "audio", start: 0, duration: 4, track: 0 }],
|
|
47
|
+
});
|
|
48
|
+
useAudioMetersVisible.setState({ visible: true });
|
|
49
|
+
const root = mountReactHarness(<AudioMeterStrip previewIframeRef={{ current: null }} />);
|
|
50
|
+
expect(document.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
|
|
51
|
+
await act(async () => root.unmount());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("runs the clipboard with no DOM edit session", async () => {
|
|
55
|
+
const toasts: string[] = [];
|
|
56
|
+
const options: UseClipboardOptions = {
|
|
57
|
+
projectId: "p",
|
|
58
|
+
activeCompPath: "index.html",
|
|
59
|
+
domEditSelectionRef: { current: null },
|
|
60
|
+
showToast: (message) => toasts.push(message),
|
|
61
|
+
writeProjectFile: async () => {},
|
|
62
|
+
recordEdit: async () => {},
|
|
63
|
+
reloadPreview: () => {},
|
|
64
|
+
handleTimelineElementsDelete: async () => {},
|
|
65
|
+
handleDomEditElementDelete: async () => {},
|
|
66
|
+
previewIframeRef: { current: null },
|
|
67
|
+
waitForPendingDomEditSaves: async () => {},
|
|
68
|
+
};
|
|
69
|
+
const api: { current: ReturnType<typeof useClipboard> | null } = { current: null };
|
|
70
|
+
function Harness() {
|
|
71
|
+
api.current = useClipboard(options);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const root = mountReactHarness(<Harness />);
|
|
75
|
+
expect(api.current?.handleCopy()).toBe(false);
|
|
76
|
+
expect(toasts).toEqual(["Nothing selected to copy."]);
|
|
77
|
+
await act(async () => root.unmount());
|
|
78
|
+
});
|
|
79
|
+
});
|