@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import React, { act } from "react";
|
|
3
3
|
import { createRoot, type Root } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
|
|
5
6
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
6
7
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
7
8
|
import { VideoThumbnail } from "./VideoThumbnail";
|
|
@@ -13,12 +14,6 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
|
13
14
|
value: true,
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
class MockResizeObserver {
|
|
17
|
-
observe() {}
|
|
18
|
-
disconnect() {}
|
|
19
|
-
unobserve() {}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
17
|
let host: HTMLDivElement;
|
|
23
18
|
let root: Root | null = null;
|
|
24
19
|
|
|
@@ -36,8 +31,9 @@ afterEach(() => {
|
|
|
36
31
|
document.body.innerHTML = "";
|
|
37
32
|
});
|
|
38
33
|
|
|
39
|
-
async function render(width = 0) {
|
|
34
|
+
async function render(width = 0, height = 40) {
|
|
40
35
|
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
|
36
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: height });
|
|
41
37
|
root = createRoot(host);
|
|
42
38
|
await act(async () => {
|
|
43
39
|
root!.render(
|
|
@@ -66,13 +62,13 @@ describe("VideoThumbnail", () => {
|
|
|
66
62
|
expect(decodeVideoThumbnail).not.toHaveBeenCalled();
|
|
67
63
|
});
|
|
68
64
|
|
|
69
|
-
it("requests a
|
|
65
|
+
it("requests a filmstrip sized by the measured clip height", async () => {
|
|
70
66
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
71
67
|
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
72
68
|
weight: 256,
|
|
73
69
|
});
|
|
74
70
|
|
|
75
|
-
await render(
|
|
71
|
+
await render(440);
|
|
76
72
|
|
|
77
73
|
expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
|
|
78
74
|
1,
|
|
@@ -93,7 +89,7 @@ describe("VideoThumbnail", () => {
|
|
|
93
89
|
weight: 256,
|
|
94
90
|
});
|
|
95
91
|
|
|
96
|
-
await render(
|
|
92
|
+
await render(300);
|
|
97
93
|
|
|
98
94
|
const tiles = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
99
95
|
expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
|
|
@@ -105,11 +101,37 @@ describe("VideoThumbnail", () => {
|
|
|
105
101
|
weight: 128,
|
|
106
102
|
});
|
|
107
103
|
|
|
108
|
-
await render(
|
|
104
|
+
await render(60);
|
|
109
105
|
|
|
110
106
|
expect(decodeVideoThumbnail).toHaveBeenCalledTimes(1);
|
|
111
107
|
});
|
|
112
108
|
|
|
109
|
+
it("tiles a wide picture at the clip's measured height, whole", async () => {
|
|
110
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
111
|
+
value: { kind: "image", url: "blob:wide", aspect: 2.7 },
|
|
112
|
+
weight: 128,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await render(500, 40);
|
|
116
|
+
|
|
117
|
+
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("re-tiles at the height the resize observer reports", async () => {
|
|
121
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
122
|
+
value: { kind: "image", url: "blob:wide", aspect: 2.7 },
|
|
123
|
+
weight: 128,
|
|
124
|
+
});
|
|
125
|
+
await render(0, 0);
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
reportResize(500, 40);
|
|
129
|
+
await Promise.resolve();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
133
|
+
});
|
|
134
|
+
|
|
113
135
|
it("clears the loading shimmer when the scheduled decode fails", async () => {
|
|
114
136
|
vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
|
|
115
137
|
|
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
3
2
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
3
|
+
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
|
4
4
|
import {
|
|
5
5
|
createThumbnailKey,
|
|
6
6
|
type ThumbnailPriority,
|
|
7
7
|
type ThumbnailSnapshot,
|
|
8
8
|
} from "../lib/thumbnailScheduler";
|
|
9
9
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
10
|
-
import {
|
|
11
|
-
computeThumbnailStrip,
|
|
12
|
-
quantizeThumbnailFrameCount,
|
|
13
|
-
THUMBNAIL_CLIP_HEIGHT,
|
|
14
|
-
} from "./thumbnailUtils";
|
|
10
|
+
import { computeThumbnailStrip, quantizeThumbnailFrameCount } from "./thumbnailUtils";
|
|
15
11
|
|
|
16
12
|
interface VideoThumbnailProps {
|
|
17
13
|
videoSrc: string;
|
|
@@ -89,10 +85,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
89
85
|
sessionEpoch = 0,
|
|
90
86
|
priority = "visible",
|
|
91
87
|
}: VideoThumbnailProps) {
|
|
92
|
-
const [
|
|
93
|
-
const observerRef = useRef<ResizeObserver | null>(null);
|
|
88
|
+
const [container, setContainerRef] = useThumbnailStripSize();
|
|
94
89
|
const requestFrameCount = quantizeThumbnailFrameCount(
|
|
95
|
-
computeThumbnailStrip(
|
|
90
|
+
computeThumbnailStrip(container.width, 16 / 9, container.height).frameCount,
|
|
96
91
|
);
|
|
97
92
|
const requestProps = useMemo(
|
|
98
93
|
() => ({
|
|
@@ -114,7 +109,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
114
109
|
() => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
|
|
115
110
|
[requestFrameCount, requestProps],
|
|
116
111
|
);
|
|
117
|
-
const measured =
|
|
112
|
+
const measured = container.width > 0;
|
|
118
113
|
const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
|
|
119
114
|
const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
|
|
120
115
|
const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
|
|
@@ -122,24 +117,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
122
117
|
const urls =
|
|
123
118
|
value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
|
|
124
119
|
const aspect = value?.kind === "image" || value?.kind === "filmstrip" ? value.aspect : 16 / 9;
|
|
125
|
-
const { frameW, frameCount } = computeThumbnailStrip(
|
|
126
|
-
containerWidth,
|
|
127
|
-
aspect,
|
|
128
|
-
THUMBNAIL_CLIP_HEIGHT,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
132
|
-
observerRef.current?.disconnect();
|
|
133
|
-
if (!element) return;
|
|
134
|
-
const target = element.parentElement ?? element;
|
|
135
|
-
setContainerWidth(target.clientWidth);
|
|
136
|
-
observerRef.current = new ResizeObserver(([entry]) =>
|
|
137
|
-
setContainerWidth(entry.contentRect.width),
|
|
138
|
-
);
|
|
139
|
-
observerRef.current.observe(target);
|
|
140
|
-
}, []);
|
|
141
|
-
|
|
142
|
-
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
120
|
+
const { frameW, frameCount } = computeThumbnailStrip(container.width, aspect, container.height);
|
|
143
121
|
|
|
144
122
|
return (
|
|
145
123
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/** Bare keys Studio's
|
|
2
|
-
export const STUDIO_PLAIN_KEYS = {
|
|
1
|
+
/** Bare keys Studio's hotkeys bind; the shortcuts list names them from here. */
|
|
2
|
+
export const STUDIO_PLAIN_KEYS = {
|
|
3
|
+
fullscreen: "f",
|
|
4
|
+
split: "s",
|
|
5
|
+
record: "r",
|
|
6
|
+
addKeyframe: "k",
|
|
7
|
+
} as const;
|
|
3
8
|
|
|
4
9
|
export interface ShortcutHint {
|
|
5
10
|
key: string;
|
|
@@ -32,10 +37,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
32
37
|
{
|
|
33
38
|
title: "Keyframes (when an element is selected)",
|
|
34
39
|
hints: [
|
|
35
|
-
{ key:
|
|
40
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.addKeyframe), label: "Add / remove keyframe at playhead" },
|
|
36
41
|
{ key: "Del", label: "Delete selected keyframe" },
|
|
37
|
-
{ key: "H", label: "Toggle hold / bezier" },
|
|
38
|
-
{ key: "U", label: "Expand / collapse properties" },
|
|
39
42
|
{ key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
|
|
40
43
|
],
|
|
41
44
|
},
|
|
@@ -4,49 +4,45 @@ import {
|
|
|
4
4
|
encodePreviewPath,
|
|
5
5
|
resolveMediaPreviewUrl,
|
|
6
6
|
quantizeThumbnailFrameCount,
|
|
7
|
-
THUMBNAIL_CLIP_HEIGHT,
|
|
8
7
|
} from "./thumbnailUtils";
|
|
9
8
|
|
|
10
9
|
describe("computeThumbnailStrip", () => {
|
|
11
10
|
it("sizes tiles by aspect ratio at the clip height", () => {
|
|
12
|
-
|
|
13
|
-
expect(frameW).toBe(
|
|
11
|
+
expect(computeThumbnailStrip(500, 16 / 9, 40).frameW).toBe(71);
|
|
12
|
+
expect(computeThumbnailStrip(500, 2.7, 40).frameW).toBe(108);
|
|
14
13
|
});
|
|
15
14
|
|
|
16
15
|
it("repeats tiles to cover the container width", () => {
|
|
17
|
-
const { frameW, frameCount } = computeThumbnailStrip(500, 1);
|
|
18
|
-
expect(frameW).toBe(
|
|
19
|
-
expect(frameCount).toBe(
|
|
16
|
+
const { frameW, frameCount } = computeThumbnailStrip(500, 1, 40);
|
|
17
|
+
expect(frameW).toBe(40);
|
|
18
|
+
expect(frameCount).toBe(13);
|
|
20
19
|
expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
|
|
21
20
|
});
|
|
22
21
|
|
|
23
22
|
it("paints tiles across the full clip past the shared visible-frame budget", () => {
|
|
24
|
-
expect(computeThumbnailStrip(14_400, 16 / 9).frameCount).toBeGreaterThan(33);
|
|
23
|
+
expect(computeThumbnailStrip(14_400, 16 / 9, 40).frameCount).toBeGreaterThan(33);
|
|
25
24
|
});
|
|
26
25
|
|
|
27
|
-
it("returns one tile
|
|
28
|
-
expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
|
|
29
|
-
expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
|
|
26
|
+
it("returns one tile until the container is measured", () => {
|
|
27
|
+
expect(computeThumbnailStrip(0, 16 / 9, 40).frameCount).toBe(1);
|
|
28
|
+
expect(computeThumbnailStrip(-10, 16 / 9, 40).frameCount).toBe(1);
|
|
29
|
+
expect(computeThumbnailStrip(500, 16 / 9, 0).frameCount).toBe(1);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("falls back to 16:9 for degenerate aspects", () => {
|
|
33
|
-
const expected = Math.round(
|
|
34
|
-
expect(computeThumbnailStrip(300, 0).frameW).toBe(expected);
|
|
35
|
-
expect(computeThumbnailStrip(300, -2).frameW).toBe(expected);
|
|
36
|
-
expect(computeThumbnailStrip(300, Number.NaN).frameW).toBe(expected);
|
|
37
|
-
expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY).frameW).toBe(expected);
|
|
33
|
+
const expected = Math.round(40 * (16 / 9));
|
|
34
|
+
expect(computeThumbnailStrip(300, 0, 40).frameW).toBe(expected);
|
|
35
|
+
expect(computeThumbnailStrip(300, -2, 40).frameW).toBe(expected);
|
|
36
|
+
expect(computeThumbnailStrip(300, Number.NaN, 40).frameW).toBe(expected);
|
|
37
|
+
expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY, 40).frameW).toBe(expected);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("never returns a zero-width tile (avoids divide-by-zero repeat counts)", () => {
|
|
41
|
-
const { frameW, frameCount } = computeThumbnailStrip(300, 0.001);
|
|
41
|
+
const { frameW, frameCount } = computeThumbnailStrip(300, 0.001, 40);
|
|
42
42
|
expect(frameW).toBeGreaterThanOrEqual(1);
|
|
43
43
|
expect(Number.isFinite(frameCount)).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
it("honors a custom clip height", () => {
|
|
47
|
-
expect(computeThumbnailStrip(300, 2, 40).frameW).toBe(80);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
46
|
it("keeps narrow tiles above a caller-owned minimum", () => {
|
|
51
47
|
expect(computeThumbnailStrip(300, 0.25, 40, 48)).toEqual({
|
|
52
48
|
frameW: 48,
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
|
|
3
|
-
/** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
|
|
4
|
-
export const THUMBNAIL_CLIP_HEIGHT = 66;
|
|
5
3
|
|
|
6
4
|
export interface ThumbnailStripLayout {
|
|
7
5
|
/** Width of a single tile, in CSS px. */
|
|
@@ -63,19 +61,20 @@ export function probeImageAspect(
|
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
/**
|
|
66
|
-
* Compute the film-strip tile layout for a clip thumbnail:
|
|
67
|
-
* sized by the media's aspect ratio, repeated to fill the
|
|
68
|
-
* Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
|
|
64
|
+
* Compute the film-strip tile layout for a clip thumbnail: tiles as tall as
|
|
65
|
+
* the measured strip, sized by the media's aspect ratio, repeated to fill the
|
|
66
|
+
* clip width. Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
|
|
69
67
|
*/
|
|
70
68
|
export function computeThumbnailStrip(
|
|
71
69
|
containerWidth: number,
|
|
72
70
|
aspect: number,
|
|
73
|
-
clipHeight: number
|
|
71
|
+
clipHeight: number,
|
|
74
72
|
minFrameWidth = 1,
|
|
75
73
|
): ThumbnailStripLayout {
|
|
76
74
|
const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
|
|
77
75
|
const frameW = Math.max(minFrameWidth, Math.round(clipHeight * safeAspect));
|
|
78
|
-
const
|
|
76
|
+
const measured = containerWidth > 0 && clipHeight > 0;
|
|
77
|
+
const frameCount = measured ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
79
78
|
return { frameW, frameCount };
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
1
5
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveClipRenderContext } from "./timelineClipChildren";
|
|
6
|
+
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
|
|
7
|
+
import { getTrackStyle } from "./timelineIcons";
|
|
3
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
9
|
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
5
15
|
const clip: TimelineElement = {
|
|
6
16
|
id: "clip",
|
|
7
17
|
tag: "video",
|
|
@@ -29,3 +39,26 @@ describe("resolveClipRenderContext", () => {
|
|
|
29
39
|
});
|
|
30
40
|
});
|
|
31
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("renderClipChildren", () => {
|
|
44
|
+
// happy-dom cannot hit-test, so this pins what decides the hit: the picture takes no input.
|
|
45
|
+
it("lets a press on a picture stacked above the trim handles fall through to them", () => {
|
|
46
|
+
const host = document.createElement("div");
|
|
47
|
+
document.body.append(host);
|
|
48
|
+
const root = createRoot(host);
|
|
49
|
+
const picture = () =>
|
|
50
|
+
createElement("div", { "data-picture": "", style: { position: "absolute", zIndex: 10 } });
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
root.render(renderClipChildren(clip, getTrackStyle("video"), picture, undefined));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// happy-dom does not inherit pointer-events, so walk up to the layer that turns input off.
|
|
57
|
+
let node = host.querySelector<HTMLElement>("[data-picture]");
|
|
58
|
+
expect(node).not.toBeNull();
|
|
59
|
+
while (node && node !== host && node.style.pointerEvents !== "none") node = node.parentElement;
|
|
60
|
+
expect(node?.style.pointerEvents).toBe("none");
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
host.remove();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -57,10 +57,13 @@ export function renderClipChildren(
|
|
|
57
57
|
{renderClipOverlay?.(element)}
|
|
58
58
|
{!renderClipContent && <ClipLintDot element={element} />}
|
|
59
59
|
{renderClipContent && (
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
<div
|
|
60
|
+
// The picture can paint above the trim handles, so it takes no input and presses reach them.
|
|
61
|
+
// borderRadius: inherit clips it to the clip's rounded corners; the clip itself is
|
|
62
|
+
// overflow-visible because keyframe diamonds hang outside its bounds.
|
|
63
|
+
<div
|
|
64
|
+
className="absolute inset-0 overflow-hidden"
|
|
65
|
+
style={{ borderRadius: "inherit", pointerEvents: "none" }}
|
|
66
|
+
>
|
|
64
67
|
{renderClipContent(element, clipStyle, context)}
|
|
65
68
|
</div>
|
|
66
69
|
)}
|
|
@@ -11,86 +11,108 @@ afterEach(() => {
|
|
|
11
11
|
usePlayerStore.getState().reset();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
const element: TimelineElement = {
|
|
15
|
+
id: "clip-1",
|
|
16
|
+
tag: "div",
|
|
17
|
+
start: 0,
|
|
18
|
+
duration: 2,
|
|
19
|
+
track: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function mountGesture(kind: "drag" | "resize") {
|
|
23
|
+
const drag: DraggedClipState = {
|
|
24
|
+
pointerId: 0,
|
|
25
|
+
element,
|
|
26
|
+
originClientX: 0,
|
|
27
|
+
originClientY: 0,
|
|
28
|
+
originScrollLeft: 0,
|
|
29
|
+
originScrollTop: 0,
|
|
30
|
+
pointerClientX: 0,
|
|
31
|
+
pointerClientY: 0,
|
|
32
|
+
pointerOffsetX: 0,
|
|
33
|
+
pointerOffsetY: 0,
|
|
34
|
+
previewStart: 1,
|
|
35
|
+
previewTrack: 0,
|
|
36
|
+
desiredTrack: 0,
|
|
37
|
+
insertRow: null,
|
|
38
|
+
snapTime: null,
|
|
39
|
+
snapType: null,
|
|
40
|
+
started: true,
|
|
41
|
+
};
|
|
42
|
+
const resize: ResizingClipState = {
|
|
43
|
+
pointerId: 0,
|
|
44
|
+
element,
|
|
45
|
+
edge: "end",
|
|
46
|
+
originClientX: 0,
|
|
47
|
+
previewStart: 0,
|
|
48
|
+
previewDuration: 3,
|
|
49
|
+
started: true,
|
|
50
|
+
};
|
|
51
|
+
const draggedClipRef = { current: kind === "drag" ? drag : null };
|
|
52
|
+
const resizingClipRef = { current: kind === "resize" ? resize : null };
|
|
53
|
+
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
54
|
+
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
55
|
+
};
|
|
56
|
+
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
57
|
+
...previous,
|
|
58
|
+
previewStart: 3,
|
|
59
|
+
}));
|
|
60
|
+
const onMoveElement = vi.fn();
|
|
61
|
+
const onResizeElement = vi.fn();
|
|
62
|
+
const updateElement = vi.fn();
|
|
63
|
+
const stopAutoScroll = vi.fn();
|
|
64
|
+
const cancelGestureRef = { current: () => false };
|
|
65
|
+
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
66
|
+
lifecycleRef: { current: { kind, phase: "active", pointerId: null, sessionEpoch: 0 } },
|
|
67
|
+
sessionEpochRef: { current: 0 },
|
|
68
|
+
cancelGestureRef,
|
|
69
|
+
scrollRef: { current: null },
|
|
70
|
+
draggedClipRef,
|
|
71
|
+
resizingClipRef,
|
|
72
|
+
blockedClipRef: { current: null },
|
|
73
|
+
groupResizeRef: { current: null },
|
|
74
|
+
suppressClickRef: { current: false },
|
|
75
|
+
gestureSelectedKeysRef: { current: new Set() },
|
|
76
|
+
elementsRef: { current: [element] },
|
|
77
|
+
trackOrderRef: { current: [0] },
|
|
78
|
+
setDraggedClipState: setDraggedClip,
|
|
79
|
+
setResizingClipState: () => {},
|
|
80
|
+
setShowPopover: () => {},
|
|
81
|
+
setRangeSelectionRef: { current: null },
|
|
82
|
+
applyResizePointerRef: { current: () => {} },
|
|
83
|
+
syncClipDragAutoScrollRef: { current: () => {} },
|
|
84
|
+
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
85
|
+
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
86
|
+
publishDraggedClip: setDraggedClip,
|
|
87
|
+
updateElement,
|
|
88
|
+
onMoveElementRef: { current: onMoveElement },
|
|
89
|
+
onMoveElementsRef: { current: undefined },
|
|
90
|
+
onResizeElementRef: { current: onResizeElement },
|
|
91
|
+
onResizeElementsRef: { current: undefined },
|
|
92
|
+
onBlockedEditAttemptRef: { current: undefined },
|
|
93
|
+
readZIndexRef: { current: undefined },
|
|
94
|
+
onStackingPatchesRef: { current: undefined },
|
|
95
|
+
refreshAfterLaneMoveRef: { current: undefined },
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
draggedClipRef,
|
|
99
|
+
resizingClipRef,
|
|
100
|
+
updateDraggedClipPreview,
|
|
101
|
+
onMoveElement,
|
|
102
|
+
onResizeElement,
|
|
103
|
+
updateElement,
|
|
104
|
+
stopAutoScroll,
|
|
105
|
+
cancelGestureRef,
|
|
106
|
+
dispose,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const release = (clientX: number, clientY: number) =>
|
|
111
|
+
window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));
|
|
112
|
+
|
|
14
113
|
describe("timeline clip drag gesture lifecycle", () => {
|
|
15
114
|
it("finishes a drag after virtualization unmounts its source row", () => {
|
|
16
|
-
const
|
|
17
|
-
id: "clip-1",
|
|
18
|
-
tag: "div",
|
|
19
|
-
start: 0,
|
|
20
|
-
duration: 2,
|
|
21
|
-
track: 0,
|
|
22
|
-
};
|
|
23
|
-
const drag: DraggedClipState = {
|
|
24
|
-
pointerId: 0,
|
|
25
|
-
element,
|
|
26
|
-
originClientX: 0,
|
|
27
|
-
originClientY: 0,
|
|
28
|
-
originScrollLeft: 0,
|
|
29
|
-
originScrollTop: 0,
|
|
30
|
-
pointerClientX: 0,
|
|
31
|
-
pointerClientY: 0,
|
|
32
|
-
pointerOffsetX: 0,
|
|
33
|
-
pointerOffsetY: 0,
|
|
34
|
-
previewStart: 1,
|
|
35
|
-
previewTrack: 0,
|
|
36
|
-
desiredTrack: 0,
|
|
37
|
-
insertRow: null,
|
|
38
|
-
snapTime: null,
|
|
39
|
-
snapType: null,
|
|
40
|
-
started: true,
|
|
41
|
-
};
|
|
42
|
-
const draggedClipRef = { current: drag as DraggedClipState | null };
|
|
43
|
-
const resizingClipRef = { current: null as ResizingClipState | null };
|
|
44
|
-
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
45
|
-
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
46
|
-
};
|
|
47
|
-
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
48
|
-
...previous,
|
|
49
|
-
previewStart: 3,
|
|
50
|
-
}));
|
|
51
|
-
const onMoveElement = vi.fn();
|
|
52
|
-
const stopAutoScroll = vi.fn();
|
|
53
|
-
const cancelGestureRef = { current: () => false };
|
|
54
|
-
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
55
|
-
lifecycleRef: {
|
|
56
|
-
current: {
|
|
57
|
-
kind: "drag",
|
|
58
|
-
phase: "active",
|
|
59
|
-
pointerId: null,
|
|
60
|
-
sessionEpoch: 0,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
sessionEpochRef: { current: 0 },
|
|
64
|
-
cancelGestureRef,
|
|
65
|
-
scrollRef: { current: null },
|
|
66
|
-
draggedClipRef,
|
|
67
|
-
resizingClipRef,
|
|
68
|
-
blockedClipRef: { current: null },
|
|
69
|
-
groupResizeRef: { current: null },
|
|
70
|
-
suppressClickRef: { current: false },
|
|
71
|
-
gestureSelectedKeysRef: { current: new Set() },
|
|
72
|
-
elementsRef: { current: [element] },
|
|
73
|
-
trackOrderRef: { current: [0] },
|
|
74
|
-
setDraggedClipState: setDraggedClip,
|
|
75
|
-
setResizingClipState: () => {},
|
|
76
|
-
setShowPopover: () => {},
|
|
77
|
-
setRangeSelectionRef: { current: null },
|
|
78
|
-
applyResizePointerRef: { current: () => {} },
|
|
79
|
-
syncClipDragAutoScrollRef: { current: () => {} },
|
|
80
|
-
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
81
|
-
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
82
|
-
publishDraggedClip: setDraggedClip,
|
|
83
|
-
updateElement: vi.fn(),
|
|
84
|
-
onMoveElementRef: { current: onMoveElement },
|
|
85
|
-
onMoveElementsRef: { current: undefined },
|
|
86
|
-
onResizeElementRef: { current: undefined },
|
|
87
|
-
onResizeElementsRef: { current: undefined },
|
|
88
|
-
onBlockedEditAttemptRef: { current: undefined },
|
|
89
|
-
readZIndexRef: { current: undefined },
|
|
90
|
-
onStackingPatchesRef: { current: undefined },
|
|
91
|
-
refreshAfterLaneMoveRef: { current: undefined },
|
|
92
|
-
});
|
|
93
|
-
|
|
115
|
+
const g = mountGesture("drag");
|
|
94
116
|
const sourceRow = document.createElement("div");
|
|
95
117
|
sourceRow.dataset.timelineRow = "";
|
|
96
118
|
sourceRow.append(document.createElement("div"));
|
|
@@ -98,15 +120,49 @@ describe("timeline clip drag gesture lifecycle", () => {
|
|
|
98
120
|
sourceRow.remove();
|
|
99
121
|
|
|
100
122
|
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
101
|
-
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
102
|
-
expect(draggedClipRef.current?.previewStart).toBe(3);
|
|
123
|
+
expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(g.draggedClipRef.current?.previewStart).toBe(3);
|
|
103
125
|
|
|
104
126
|
window.dispatchEvent(new MouseEvent("pointerup"));
|
|
105
|
-
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
106
|
-
expect(draggedClipRef.current).toBeNull();
|
|
107
|
-
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
128
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
129
|
+
expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
g.dispose();
|
|
132
|
+
expect(g.cancelGestureRef.current()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
["above the window", 20, -40],
|
|
137
|
+
["past the window's right edge", window.innerWidth + 40, 10],
|
|
138
|
+
["on the window's right edge", window.innerWidth, 10],
|
|
139
|
+
["on the window's bottom edge", 20, window.innerHeight],
|
|
140
|
+
])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
|
|
141
|
+
const g = mountGesture("drag");
|
|
142
|
+
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
143
|
+
release(clientX, clientY);
|
|
144
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
145
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
146
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
147
|
+
// The cancel ended the gesture, so a later release has nothing to commit.
|
|
148
|
+
release(20, 10);
|
|
149
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
150
|
+
g.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("cancels a trim released outside the window", () => {
|
|
154
|
+
const g = mountGesture("resize");
|
|
155
|
+
release(20, -40);
|
|
156
|
+
expect(g.onResizeElement).not.toHaveBeenCalled();
|
|
157
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
158
|
+
expect(g.resizingClipRef.current).toBeNull();
|
|
159
|
+
g.dispose();
|
|
160
|
+
});
|
|
108
161
|
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
it("still commits a drag released on the window's last pixel", () => {
|
|
163
|
+
const g = mountGesture("drag");
|
|
164
|
+
release(window.innerWidth - 1, window.innerHeight - 1);
|
|
165
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
|
|
166
|
+
g.dispose();
|
|
111
167
|
});
|
|
112
168
|
});
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "./timelineClipDragTypes";
|
|
9
9
|
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
10
10
|
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
11
|
+
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
11
12
|
import {
|
|
12
13
|
beginTimelineOptimisticGesture,
|
|
13
14
|
rollbackLatestTimelineOptimisticGesture,
|
|
@@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
349
350
|
};
|
|
350
351
|
|
|
351
352
|
const handleWindowPointerUp = (event: PointerEvent) => {
|
|
353
|
+
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
|
|
352
354
|
const claimed = claimActiveGesture(event);
|
|
353
355
|
if (claimed === "ignored") return;
|
|
354
356
|
if (claimed) {
|
|
@@ -880,6 +880,61 @@ describe("resolveTimelineResize", () => {
|
|
|
880
880
|
expect(next.start).toBe(2);
|
|
881
881
|
});
|
|
882
882
|
|
|
883
|
+
function headTrimmedFullyRight(clip: { start: number; duration: number }) {
|
|
884
|
+
const single = resolveTimelineResize(
|
|
885
|
+
{ ...clip, originClientX: 0, pixelsPerSecond: 100, minStart: 0, maxEnd: 10 },
|
|
886
|
+
"start",
|
|
887
|
+
1000,
|
|
888
|
+
);
|
|
889
|
+
const group = resolveTimelineGroupResize([clip], "start", 10).members[0]!;
|
|
890
|
+
return { single: single.duration, group: group.duration };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
it.each([
|
|
894
|
+
{ start: 1, duration: 0.625 },
|
|
895
|
+
{ start: 1.005, duration: 2 },
|
|
896
|
+
])("keeps the minimum duration when a head trim runs off the grid (%o)", (clip) => {
|
|
897
|
+
const { single, group } = headTrimmedFullyRight(clip);
|
|
898
|
+
expect(Math.min(single, group)).toBeGreaterThanOrEqual(0.1);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("keeps the minimum duration for any off-grid start and length on a 1 ms grid", () => {
|
|
902
|
+
const short: string[] = [];
|
|
903
|
+
for (let i = 0; i < 20; i++) {
|
|
904
|
+
for (let j = 0; j < 18; j++) {
|
|
905
|
+
const clip = { start: (1000 + i * 13) / 1000, duration: (100 + j * 17) / 1000 };
|
|
906
|
+
const { single, group } = headTrimmedFullyRight(clip);
|
|
907
|
+
if (Math.min(single, group) < 0.1 - 1e-9) short.push(`${clip.start}+${clip.duration}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
expect(short).toEqual([]);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("stops a group head trim where the single-clip trim stops", () => {
|
|
914
|
+
const { single, group } = headTrimmedFullyRight({ start: 1.003, duration: 2 });
|
|
915
|
+
expect(group).toBe(single);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("moves on-grid group members together when the tightest member sits 5 ms off the grid", () => {
|
|
919
|
+
const members = [
|
|
920
|
+
{ start: 1.005, duration: 2 },
|
|
921
|
+
{ start: 0, duration: 5 },
|
|
922
|
+
{ start: 0.24, duration: 5 },
|
|
923
|
+
];
|
|
924
|
+
const next = resolveTimelineGroupResize(members, "start", 10).members;
|
|
925
|
+
expect(next.map((m) => m.start)).toEqual([2.9, 1.89, 2.13]);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("never moves a group head trim left of zero near the minimum length", () => {
|
|
929
|
+
const [member] = resolveTimelineGroupResize(
|
|
930
|
+
[{ start: 0.004, duration: 0.1 }],
|
|
931
|
+
"start",
|
|
932
|
+
10,
|
|
933
|
+
).members;
|
|
934
|
+
expect(member!.start).toBeGreaterThanOrEqual(0);
|
|
935
|
+
expect(member!.duration).toBeGreaterThanOrEqual(0.1);
|
|
936
|
+
});
|
|
937
|
+
|
|
883
938
|
it("moves every group member by the same amount when one reaches its media start", () => {
|
|
884
939
|
const members = [
|
|
885
940
|
{ start: 2, duration: 3, playbackStart: 1, playbackRate: 1.5 },
|