@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
|
@@ -1,6 +1,6 @@
|
|
|
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 { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
5
|
import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
|
|
6
6
|
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
@@ -25,8 +25,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
25
25
|
priority = "visible",
|
|
26
26
|
rich = false,
|
|
27
27
|
}: ImageThumbnailProps) {
|
|
28
|
-
const [
|
|
29
|
-
const observerRef = useRef<ResizeObserver | null>(null);
|
|
28
|
+
const [container, setContainerRef] = useThumbnailStripSize();
|
|
30
29
|
const request = useMemo(
|
|
31
30
|
() => ({
|
|
32
31
|
key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
|
|
@@ -42,20 +41,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
42
41
|
const snapshot = useThumbnailLease(request);
|
|
43
42
|
const value = snapshot.status === "ready" ? snapshot.value : null;
|
|
44
43
|
const aspect = value?.kind === "image" ? value.aspect : 16 / 9;
|
|
45
|
-
const { frameW, frameCount } = computeThumbnailStrip(
|
|
46
|
-
|
|
47
|
-
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
48
|
-
observerRef.current?.disconnect();
|
|
49
|
-
if (!element) return;
|
|
50
|
-
const target = element.parentElement ?? element;
|
|
51
|
-
setContainerWidth(target.clientWidth);
|
|
52
|
-
observerRef.current = new ResizeObserver(([entry]) =>
|
|
53
|
-
setContainerWidth(entry.contentRect.width),
|
|
54
|
-
);
|
|
55
|
-
observerRef.current.observe(target);
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
44
|
+
const { frameW, frameCount } = computeThumbnailStrip(container.width, aspect, container.height);
|
|
59
45
|
|
|
60
46
|
return (
|
|
61
47
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { PlayerControls } from "./PlayerControls";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
let host: HTMLDivElement;
|
|
12
|
+
let root: Root;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
usePlayerStore.setState({
|
|
16
|
+
isPlaying: true,
|
|
17
|
+
currentTime: 2,
|
|
18
|
+
duration: 30,
|
|
19
|
+
timeDisplayMode: "time",
|
|
20
|
+
});
|
|
21
|
+
host = document.createElement("div");
|
|
22
|
+
document.body.append(host);
|
|
23
|
+
root = createRoot(host);
|
|
24
|
+
await act(async () =>
|
|
25
|
+
root.render(React.createElement(PlayerControls, { onTogglePlay: () => {}, onSeek: () => {} })),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await act(async () => root.unmount());
|
|
31
|
+
host.remove();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("PlayerControls timecode", () => {
|
|
35
|
+
it("rewrites the timecode only when the shown text changes", async () => {
|
|
36
|
+
const timecode = host.querySelector("button.font-mono > span");
|
|
37
|
+
expect(timecode?.textContent).toBe("00:02");
|
|
38
|
+
const writes: MutationRecord[] = [];
|
|
39
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
40
|
+
observer.observe(timecode as Node, { childList: true, characterData: true, subtree: true });
|
|
41
|
+
|
|
42
|
+
for (let frame = 1; frame <= 30; frame++) liveTime.notify(2 + frame / 120);
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
expect(writes).toHaveLength(0);
|
|
45
|
+
|
|
46
|
+
liveTime.notify(3.01);
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
expect(timecode?.textContent).toBe("00:03");
|
|
49
|
+
expect(writes.length).toBeGreaterThan(0);
|
|
50
|
+
observer.disconnect();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -213,10 +213,11 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
213
213
|
currentTimeRef.current = time;
|
|
214
214
|
if (!timeDisplayRef.current) return;
|
|
215
215
|
const currentDuration = durationRef.current;
|
|
216
|
-
|
|
216
|
+
const text =
|
|
217
217
|
timeDisplayModeRef.current === "frame"
|
|
218
218
|
? formatFrameTime(time, currentDuration)
|
|
219
219
|
: formatTime(time);
|
|
220
|
+
if (timeDisplayRef.current.textContent !== text) timeDisplayRef.current.textContent = text;
|
|
220
221
|
};
|
|
221
222
|
const unsubscribe = liveTime.subscribe(updateTime);
|
|
222
223
|
updateTime(usePlayerStore.getState().currentTime);
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "./timelineLayout";
|
|
40
40
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
41
41
|
import { formatTime } from "../lib/time";
|
|
42
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
42
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
43
43
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
44
44
|
|
|
45
45
|
vi.mock("./timelineRowVirtualizationFlag", () => ({
|
|
@@ -153,6 +153,27 @@ function renderSharedAutomationTimeline(selectedElementId?: string) {
|
|
|
153
153
|
return { host, root };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
describe("Timeline playhead motion", () => {
|
|
157
|
+
it("moves by fractional pixels while playing and snaps to device pixels once paused", () => {
|
|
158
|
+
usePlayerStore.setState({
|
|
159
|
+
duration: 11,
|
|
160
|
+
timelineReady: true,
|
|
161
|
+
currentTime: 10,
|
|
162
|
+
isPlaying: true,
|
|
163
|
+
zoomMode: "manual",
|
|
164
|
+
manualZoomPercent: 100,
|
|
165
|
+
elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
|
|
166
|
+
});
|
|
167
|
+
const { root, playhead } = renderTimelineGeometry("clip-1");
|
|
168
|
+
const wrapperLeft = GUTTER + TRACKS_LEFT_PAD + 1000.3 - PLAYHEAD_HEAD_W / 2;
|
|
169
|
+
act(() => liveTime.notify(10.003));
|
|
170
|
+
expect(playhead.style.transform).toBe(`translateX(${wrapperLeft}px)`);
|
|
171
|
+
act(() => usePlayerStore.setState({ isPlaying: false }));
|
|
172
|
+
expect(playhead.style.transform).toBe(`translateX(${Math.round(wrapperLeft)}px)`);
|
|
173
|
+
act(() => root.unmount());
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
156
177
|
describe("Timeline provider boundary", () => {
|
|
157
178
|
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
|
158
179
|
usePlayerStore.setState({
|
|
@@ -173,7 +194,9 @@ describe("Timeline provider boundary", () => {
|
|
|
173
194
|
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
|
174
195
|
expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
|
175
196
|
expect(rulerTick.style.left).toBe("999.5px");
|
|
176
|
-
expect(playhead.style.
|
|
197
|
+
expect(playhead.style.transform).toBe(
|
|
198
|
+
`translateX(${Math.round(GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
|
|
199
|
+
);
|
|
177
200
|
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
|
178
201
|
expect(
|
|
179
202
|
resolveTimelineAssetDrop(
|
|
@@ -283,7 +306,9 @@ describe("Timeline provider boundary", () => {
|
|
|
283
306
|
expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
|
|
284
307
|
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
|
|
285
308
|
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
|
|
286
|
-
expect(playhead.style.
|
|
309
|
+
expect(playhead.style.transform).toBe(
|
|
310
|
+
`translateX(${Math.round(LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
|
|
311
|
+
);
|
|
287
312
|
expect(diamondX).toBe(rulerX);
|
|
288
313
|
expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
|
|
289
314
|
expect(collapsedHeader.textContent).toContain("Outro");
|
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
TRACKS_BOTTOM_PAD,
|
|
10
10
|
TRACK_H,
|
|
11
11
|
PLAYHEAD_HEAD_W,
|
|
12
|
-
getTimelinePlayheadLeft,
|
|
13
12
|
getTimelineRowTop,
|
|
14
13
|
getTimelineRowHeight,
|
|
15
14
|
} from "./timelineLayout";
|
|
15
|
+
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
16
16
|
import { TimelineLanes } from "./TimelineLanes";
|
|
17
17
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
18
18
|
import { useTimelineContext } from "./TimelineProvider";
|
|
@@ -234,7 +234,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
234
234
|
|
|
235
235
|
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
|
236
236
|
track the scrub and clutter the beat being moved. Explicit width +
|
|
237
|
-
the half-head offset baked into
|
|
237
|
+
the half-head offset baked into getTimelinePlayheadTransform keep the
|
|
238
238
|
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
|
239
239
|
ticks' center), instead of relying on shrink-wrap sizing. */}
|
|
240
240
|
<TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
|
|
@@ -242,7 +242,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
242
242
|
ref={props.playheadRef}
|
|
243
243
|
className="absolute top-0 bottom-0 pointer-events-none"
|
|
244
244
|
style={{
|
|
245
|
-
left:
|
|
245
|
+
left: 0,
|
|
246
|
+
transform: getTimelinePlayheadTransform(0, 0, props.contentOrigin, true),
|
|
247
|
+
willChange: "transform",
|
|
246
248
|
width: PLAYHEAD_HEAD_W,
|
|
247
249
|
display: beatDragging ? "none" : undefined,
|
|
248
250
|
}}
|
|
@@ -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">
|
|
@@ -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
|
|
|
@@ -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 },
|
|
@@ -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
|
+
}
|