@hyperframes/studio 0.8.189 → 0.8.191
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-CkK8Zijf.js → hyperframes-player-Bv9hQ3v7.js} +1 -1
- package/dist/assets/index-CBO8uz7-.css +1 -0
- package/dist/assets/{index-B2JcCaRF.js → index-D_QqfWhx.js} +133 -132
- package/dist/assets/{index-ATnRoBug.js → index-Df14bh-L.js} +1 -1
- package/dist/{chunk-563JPIL4.js → chunk-IWO4UENE.js} +8 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +4166 -3922
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/captions/parser.dom.test.ts +126 -0
- package/src/captions/parser.ts +45 -23
- package/src/components/StudioRightPanels.tsx +9 -0
- package/src/components/dock/DockTab.tsx +2 -0
- package/src/components/dock/dockAccessibility.test.ts +3 -3
- package/src/components/dock/dockLayout.ts +1 -1
- package/src/components/dock/panelRegistry.ts +6 -0
- package/src/components/editor/overlayFrameLoop.test.ts +42 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -3
- package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
- package/src/components/panels/MotionBlurPanel.tsx +163 -0
- package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
- package/src/components/panels/motionBlurPanelModel.ts +98 -0
- package/src/components/renders/useRenderQueue.ts +0 -8
- package/src/hooks/usePanelLayout.test.ts +1 -1
- package/src/player/components/PlayerControls.test.tsx +67 -2
- package/src/player/components/PlayerControls.tsx +5 -3
- package/src/player/components/PreviewSeekBar.tsx +47 -0
- package/src/player/components/TimelineClip.filmstrip.test.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +1 -1
- package/src/styles/theme.css +1 -1
- package/src/utils/studioHelpers.ts +2 -1
- package/src/utils/studioUrlState.ts +8 -1
- package/dist/assets/index-CESvLzz0.css +0 -1
- package/src/components/renders/useRenderQueueMotionBlur.test.tsx +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.191",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
"dockview-react": "8.3.1",
|
|
60
60
|
"gsap": "^3.13.0",
|
|
61
61
|
"mediabunny": "^1.45.3",
|
|
62
|
-
"@hyperframes/core": "0.8.
|
|
63
|
-
"@hyperframes/parsers": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
66
|
-
"@hyperframes/studio-server": "0.8.
|
|
67
|
-
"@hyperframes/
|
|
62
|
+
"@hyperframes/core": "0.8.191",
|
|
63
|
+
"@hyperframes/parsers": "0.8.191",
|
|
64
|
+
"@hyperframes/player": "0.8.191",
|
|
65
|
+
"@hyperframes/shader-transitions": "0.8.191",
|
|
66
|
+
"@hyperframes/studio-server": "0.8.191",
|
|
67
|
+
"@hyperframes/sdk": "0.8.191"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.191"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { generateCaptionHtml } from "./generator";
|
|
4
|
+
import { parseCaptionComposition } from "./parser";
|
|
5
|
+
|
|
6
|
+
const transcript = "We asked what you needed. Forty-seven percent".split(" ").map((text, i) => ({
|
|
7
|
+
id: `word-${i}`,
|
|
8
|
+
text,
|
|
9
|
+
start: i * 0.5,
|
|
10
|
+
end: i * 0.5 + 0.4,
|
|
11
|
+
}));
|
|
12
|
+
const source = `const TRANSCRIPT = ${JSON.stringify(transcript)};`;
|
|
13
|
+
|
|
14
|
+
function createCaptionDocument(
|
|
15
|
+
groupSizes: number[],
|
|
16
|
+
groupClass = "caption-group",
|
|
17
|
+
wordClass = "word",
|
|
18
|
+
) {
|
|
19
|
+
const doc = document.implementation.createHTMLDocument();
|
|
20
|
+
let offset = 0;
|
|
21
|
+
for (const size of groupSizes) {
|
|
22
|
+
const group = doc.createElement("div");
|
|
23
|
+
group.className = groupClass;
|
|
24
|
+
for (const word of transcript.slice(offset, offset + size)) {
|
|
25
|
+
const span = doc.createElement("span");
|
|
26
|
+
span.className = wordClass;
|
|
27
|
+
span.textContent = word.text;
|
|
28
|
+
group.appendChild(span);
|
|
29
|
+
}
|
|
30
|
+
doc.body.appendChild(group);
|
|
31
|
+
offset += size;
|
|
32
|
+
}
|
|
33
|
+
return doc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function importCaptions(doc: Document, captionSource = source) {
|
|
37
|
+
const model = parseCaptionComposition(doc, window, captionSource, 1920, 1080, 4);
|
|
38
|
+
expect(model).not.toBeNull();
|
|
39
|
+
if (!model) throw new Error("Expected a caption model");
|
|
40
|
+
const groups = model.groupOrder.map((id) =>
|
|
41
|
+
model.groups.get(id)?.segmentIds.map((segmentId) => model.segments.get(segmentId)?.text),
|
|
42
|
+
);
|
|
43
|
+
return { model, groups };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("parseCaptionComposition grouping", () => {
|
|
47
|
+
it.each([{ sizes: [5, 2] }, { sizes: [2, 4, 1] }])(
|
|
48
|
+
"preserves authored group sizes $sizes",
|
|
49
|
+
({ sizes }) => {
|
|
50
|
+
const { model, groups } = importCaptions(createCaptionDocument(sizes));
|
|
51
|
+
expect(groups.map((group) => group?.length)).toEqual(sizes);
|
|
52
|
+
expect(groups.flat()).toEqual(transcript.map((word) => word.text));
|
|
53
|
+
expect(
|
|
54
|
+
[...model.segments.values()].map(({ wordId, text, start, end }) => ({
|
|
55
|
+
id: wordId,
|
|
56
|
+
text,
|
|
57
|
+
start,
|
|
58
|
+
end,
|
|
59
|
+
})),
|
|
60
|
+
).toEqual(transcript);
|
|
61
|
+
for (const group of model.groups.values()) {
|
|
62
|
+
expect(group.segmentIds.map((id) => model.segments.get(id)?.groupIndex)).toEqual(
|
|
63
|
+
group.segmentIds.map((_, index) => index),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
it.each(["caption-line", "caption-block"])(
|
|
70
|
+
"supports %s with caption-word spans",
|
|
71
|
+
(groupClass) => {
|
|
72
|
+
const { groups } = importCaptions(
|
|
73
|
+
createCaptionDocument([2, 4, 1], groupClass, "caption-word"),
|
|
74
|
+
);
|
|
75
|
+
expect(groups.map((group) => group?.length)).toEqual([2, 4, 1]);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it("does not count nested caption lines as additional groups", () => {
|
|
80
|
+
const doc = createCaptionDocument([2, 4, 1]);
|
|
81
|
+
for (const group of doc.querySelectorAll(".caption-group")) {
|
|
82
|
+
const line = doc.createElement("div");
|
|
83
|
+
line.className = "caption-line";
|
|
84
|
+
line.append(...group.childNodes);
|
|
85
|
+
group.appendChild(line);
|
|
86
|
+
}
|
|
87
|
+
expect(importCaptions(doc).groups.map((group) => group?.length)).toEqual([2, 4, 1]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each([
|
|
91
|
+
{ name: "absent", sizes: [] },
|
|
92
|
+
{ name: "incomplete", sizes: [2, 1] },
|
|
93
|
+
{ name: "empty", sizes: [0, 0] },
|
|
94
|
+
])("uses default grouping without losing words when DOM groups are $name", ({ sizes }) => {
|
|
95
|
+
const { groups } = importCaptions(createCaptionDocument(sizes));
|
|
96
|
+
expect(groups).toEqual([
|
|
97
|
+
transcript.slice(0, 5).map((word) => word.text),
|
|
98
|
+
transcript.slice(5).map((word) => word.text),
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps authored boundaries when generated captions are imported again", () => {
|
|
103
|
+
const original = importCaptions(createCaptionDocument([2, 4, 1]));
|
|
104
|
+
const html = generateCaptionHtml(original.model);
|
|
105
|
+
const doc = document.implementation.createHTMLDocument();
|
|
106
|
+
doc.body.innerHTML = html;
|
|
107
|
+
const template = doc.querySelector("template");
|
|
108
|
+
if (!template) throw new Error("Expected generated caption template");
|
|
109
|
+
doc.body.replaceChildren(template.content.cloneNode(true));
|
|
110
|
+
const script = doc.querySelector("script")?.textContent;
|
|
111
|
+
if (!script) throw new Error("Expected generated caption script");
|
|
112
|
+
// Execute only our generator's output to create its real group and word elements.
|
|
113
|
+
new Function("document", "window", "gsap", script)(
|
|
114
|
+
doc,
|
|
115
|
+
{},
|
|
116
|
+
{
|
|
117
|
+
timeline: () => ({ to() {} }),
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
expect(importCaptions(doc, html).groups).toEqual([
|
|
121
|
+
transcript.slice(0, 2).map((word) => word.text),
|
|
122
|
+
transcript.slice(2, 6).map((word) => word.text),
|
|
123
|
+
transcript.slice(6).map((word) => word.text),
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/captions/parser.ts
CHANGED
|
@@ -38,23 +38,32 @@ export function buildCaptionModel(
|
|
|
38
38
|
transcript: TranscriptWord[],
|
|
39
39
|
options: BuildOptions,
|
|
40
40
|
): CaptionModel {
|
|
41
|
-
const {
|
|
41
|
+
const { wordsPerGroup = 5 } = options;
|
|
42
|
+
const wordGroups: TranscriptWord[][] = [];
|
|
43
|
+
for (let offset = 0; offset < transcript.length; offset += wordsPerGroup) {
|
|
44
|
+
wordGroups.push(transcript.slice(offset, offset + wordsPerGroup));
|
|
45
|
+
}
|
|
46
|
+
return buildGroupedCaptionModel(wordGroups, options);
|
|
47
|
+
}
|
|
42
48
|
|
|
49
|
+
function buildGroupedCaptionModel(
|
|
50
|
+
wordGroups: TranscriptWord[][],
|
|
51
|
+
{ width, height, duration }: BuildOptions,
|
|
52
|
+
): CaptionModel {
|
|
43
53
|
const segments = new Map<string, CaptionSegment>();
|
|
44
54
|
const groups = new Map<string, CaptionGroup>();
|
|
45
55
|
const groupOrder: string[] = [];
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup);
|
|
50
|
-
const groupId = `group-${groupIdx / wordsPerGroup}`;
|
|
57
|
+
for (const chunk of wordGroups) {
|
|
58
|
+
const groupId = `group-${groups.size}`;
|
|
51
59
|
const segmentIds: string[] = [];
|
|
52
60
|
|
|
53
61
|
chunk.forEach((word, wordIdx) => {
|
|
54
|
-
const
|
|
62
|
+
const segmentIndex = segments.size;
|
|
63
|
+
const segmentId = `segment-${segmentIndex}`;
|
|
55
64
|
const segment: CaptionSegment = {
|
|
56
65
|
id: segmentId,
|
|
57
|
-
wordId: word.id ?? `w${
|
|
66
|
+
wordId: word.id ?? `w${segmentIndex}`,
|
|
58
67
|
text: word.text,
|
|
59
68
|
start: word.start,
|
|
60
69
|
end: word.end,
|
|
@@ -96,6 +105,24 @@ export function buildCaptionModel(
|
|
|
96
105
|
};
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
const GROUP_SELECTOR = ".caption-group, .caption-line, .caption-block";
|
|
109
|
+
const WORD_SELECTOR = ".word, .caption-word";
|
|
110
|
+
|
|
111
|
+
/** Read outer groups in transcript order; return null if their word counts do not match. */
|
|
112
|
+
function readTranscriptGroups(transcript: TranscriptWord[], groupEls: NodeListOf<Element>) {
|
|
113
|
+
const wordGroups: TranscriptWord[][] = [];
|
|
114
|
+
let offset = 0;
|
|
115
|
+
for (const group of groupEls) {
|
|
116
|
+
// Caption templates can nest layout lines inside a single timed group.
|
|
117
|
+
if (group.parentElement?.closest(GROUP_SELECTOR)) continue;
|
|
118
|
+
const size = group.querySelectorAll(WORD_SELECTOR).length;
|
|
119
|
+
if (size === 0) continue;
|
|
120
|
+
wordGroups.push(transcript.slice(offset, offset + size));
|
|
121
|
+
offset += size;
|
|
122
|
+
}
|
|
123
|
+
return offset === transcript.length ? wordGroups : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
/**
|
|
100
127
|
* Extracts a transcript word array from caption composition source code.
|
|
101
128
|
*
|
|
@@ -130,7 +157,8 @@ export function extractTranscript(source: string): TranscriptWord[] {
|
|
|
130
157
|
* from the source and reading computed styles from rendered elements.
|
|
131
158
|
*
|
|
132
159
|
* Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
|
|
133
|
-
* elements to build a fully-styled CaptionModel.
|
|
160
|
+
* elements to build a fully-styled CaptionModel. Preserves DOM group boundaries
|
|
161
|
+
* when they account for every transcript word; otherwise uses default grouping.
|
|
134
162
|
*
|
|
135
163
|
* Returns null if no transcript is found in the source.
|
|
136
164
|
*/
|
|
@@ -149,25 +177,19 @@ export function parseCaptionComposition(
|
|
|
149
177
|
}
|
|
150
178
|
|
|
151
179
|
// Step 2: Look for grouping and word elements in the iframe DOM
|
|
152
|
-
const groupEls = iframeDoc.querySelectorAll(
|
|
153
|
-
const wordEls = iframeDoc.querySelectorAll(
|
|
154
|
-
|
|
155
|
-
// Step 3: Infer wordsPerGroup from element counts
|
|
156
|
-
let wordsPerGroup = 5; // default
|
|
157
|
-
if (groupEls.length > 0 && wordEls.length > 0) {
|
|
158
|
-
wordsPerGroup = Math.round(wordEls.length / groupEls.length);
|
|
159
|
-
if (wordsPerGroup < 1) {
|
|
160
|
-
wordsPerGroup = 1;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
180
|
+
const groupEls = iframeDoc.querySelectorAll(GROUP_SELECTOR);
|
|
181
|
+
const wordEls = iframeDoc.querySelectorAll(WORD_SELECTOR);
|
|
163
182
|
|
|
164
|
-
//
|
|
165
|
-
const
|
|
183
|
+
// Preserve authored boundaries when the DOM accounts for the complete transcript.
|
|
184
|
+
const wordGroups = readTranscriptGroups(transcript, groupEls);
|
|
185
|
+
const options = {
|
|
166
186
|
width: compositionWidth,
|
|
167
187
|
height: compositionHeight,
|
|
168
188
|
duration: compositionDuration,
|
|
169
|
-
|
|
170
|
-
|
|
189
|
+
};
|
|
190
|
+
const model = wordGroups
|
|
191
|
+
? buildGroupedCaptionModel(wordGroups, options)
|
|
192
|
+
: buildCaptionModel(transcript, options);
|
|
171
193
|
|
|
172
194
|
// Step 5: Read computed styles from the first word or group element
|
|
173
195
|
const firstWordEl = wordEls.item(0) as Element | null;
|
|
@@ -8,6 +8,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
|
|
8
8
|
import { RenderQueuePanel } from "./renders/RenderQueuePanel";
|
|
9
9
|
import { SlideshowPanel } from "./panels/SlideshowPanel";
|
|
10
10
|
import { VariablesPanel } from "./panels/VariablesPanel";
|
|
11
|
+
import { MotionBlurPanel } from "./panels/MotionBlurPanel";
|
|
11
12
|
import { Dock } from "./dock/Dock";
|
|
12
13
|
import { useDockLayoutStore } from "./dock/dockLayoutStore";
|
|
13
14
|
import type { RenderJob } from "./renders/useRenderQueue";
|
|
@@ -300,6 +301,14 @@ export const StudioRightPanels = memo(function StudioRightPanels({
|
|
|
300
301
|
recordEdit={recordEdit}
|
|
301
302
|
/>
|
|
302
303
|
</Dock.Panel>
|
|
304
|
+
<Dock.Panel id="motionBlur">
|
|
305
|
+
<MotionBlurPanel
|
|
306
|
+
sdkSession={sdkSession}
|
|
307
|
+
publishSdkSession={publishSdkSession}
|
|
308
|
+
reloadPreview={reloadPreview}
|
|
309
|
+
recordEdit={recordEdit}
|
|
310
|
+
/>
|
|
311
|
+
</Dock.Panel>
|
|
303
312
|
<Dock.Panel id="slideshow">
|
|
304
313
|
<SlideshowPanel
|
|
305
314
|
scenes={slideshowScenes}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useSyncExternalStore } from "react";
|
|
2
2
|
import {
|
|
3
|
+
Aperture,
|
|
3
4
|
BracketsCurly,
|
|
4
5
|
ChartBarHorizontal,
|
|
5
6
|
Code,
|
|
@@ -29,6 +30,7 @@ const TAB_ICONS: Record<PanelId, Icon> = {
|
|
|
29
30
|
renders: FilmSlate,
|
|
30
31
|
variables: BracketsCurly,
|
|
31
32
|
slideshow: Presentation,
|
|
33
|
+
motionBlur: Aperture,
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -158,9 +158,9 @@ describe("dock tab strips", () => {
|
|
|
158
158
|
it("jump to the first and last tab with Home and End", () => {
|
|
159
159
|
const tabs = tabsOf("design");
|
|
160
160
|
press(tabs[0] as HTMLElement, "End");
|
|
161
|
-
expect(api.getPanel("design")?.group.activePanel?.id).toBe("
|
|
162
|
-
expect(document.activeElement).toBe(tabs[
|
|
163
|
-
press(tabs[
|
|
161
|
+
expect(api.getPanel("design")?.group.activePanel?.id).toBe("motionBlur");
|
|
162
|
+
expect(document.activeElement).toBe(tabs[4]);
|
|
163
|
+
press(tabs[4] as HTMLElement, "Home");
|
|
164
164
|
expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
|
|
165
165
|
});
|
|
166
166
|
});
|
|
@@ -9,7 +9,7 @@ const MIN_SIDE_W = 200;
|
|
|
9
9
|
const MIN_SIDE_W_FLOOR = 120;
|
|
10
10
|
const DEFAULT_TIMELINE_H = 360;
|
|
11
11
|
const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
|
|
12
|
-
const DEFAULT_RIGHT = ["design", "layers", "renders", "variables"] as const;
|
|
12
|
+
const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const;
|
|
13
13
|
|
|
14
14
|
/** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
|
|
15
15
|
export function defaultSideWidths(viewportWidth: number) {
|
|
@@ -12,6 +12,7 @@ export const PANEL_IDS = [
|
|
|
12
12
|
"renders",
|
|
13
13
|
"variables",
|
|
14
14
|
"slideshow",
|
|
15
|
+
"motionBlur",
|
|
15
16
|
] as const;
|
|
16
17
|
|
|
17
18
|
export type PanelId = (typeof PANEL_IDS)[number];
|
|
@@ -56,6 +57,11 @@ export const PANEL_DEFINITIONS = {
|
|
|
56
57
|
renders: { title: "Renders", zone: "right", reopen: { near: "design", direction: "within" } },
|
|
57
58
|
variables: { title: "Variables", zone: "right", reopen: { near: "design", direction: "within" } },
|
|
58
59
|
slideshow: { title: "Slideshow", zone: "right", reopen: { near: "design", direction: "within" } },
|
|
60
|
+
motionBlur: {
|
|
61
|
+
title: "Blur",
|
|
62
|
+
zone: "right",
|
|
63
|
+
reopen: { near: "design", direction: "within" },
|
|
64
|
+
},
|
|
59
65
|
} as const satisfies Record<PanelId, PanelDefinition>;
|
|
60
66
|
|
|
61
67
|
export function isPanelId(value: unknown): value is PanelId {
|
|
@@ -121,6 +121,48 @@ describe("overlay frame loop", () => {
|
|
|
121
121
|
expect(runs).toBeLessThanOrEqual(Math.ceil(1000 / IDLE_POLL_MS) + 1);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("polls less often the longer nothing happens, and fast again after a wake", () => {
|
|
125
|
+
let runs = 0;
|
|
126
|
+
subscribeOverlayFrame(() => {
|
|
127
|
+
runs += 1;
|
|
128
|
+
});
|
|
129
|
+
framesOver(10_000);
|
|
130
|
+
runs = 0;
|
|
131
|
+
// Backed off: a 4 s wait between polls, where a steady quarter-second poll would run 48 times.
|
|
132
|
+
framesOver(12_000);
|
|
133
|
+
expect(runs).toBeLessThanOrEqual(4);
|
|
134
|
+
|
|
135
|
+
window.dispatchEvent(new Event("pointermove"));
|
|
136
|
+
framesOver(500);
|
|
137
|
+
runs = 0;
|
|
138
|
+
framesOver(IDLE_POLL_MS * 2);
|
|
139
|
+
expect(runs).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not poll while the page is hidden, and wakes when it shows", () => {
|
|
143
|
+
let hidden = false;
|
|
144
|
+
Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
|
|
145
|
+
try {
|
|
146
|
+
let runs = 0;
|
|
147
|
+
subscribeOverlayFrame(() => {
|
|
148
|
+
runs += 1;
|
|
149
|
+
});
|
|
150
|
+
framesOver(500);
|
|
151
|
+
hidden = true;
|
|
152
|
+
runs = 0;
|
|
153
|
+
framesOver(8_000);
|
|
154
|
+
// At most the one poll armed before the page hid; none is armed after it.
|
|
155
|
+
expect(runs).toBeLessThanOrEqual(1);
|
|
156
|
+
|
|
157
|
+
hidden = false;
|
|
158
|
+
window.dispatchEvent(new Event("visibilitychange"));
|
|
159
|
+
framesOver(200);
|
|
160
|
+
expect(runs).toBeGreaterThan(5);
|
|
161
|
+
} finally {
|
|
162
|
+
delete (document as { hidden?: boolean }).hidden;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
124
166
|
it("wakes to full rate on a pointer event", () => {
|
|
125
167
|
let runs = 0;
|
|
126
168
|
subscribeOverlayFrame(() => {
|
|
@@ -30,11 +30,14 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
30
30
|
const AWAKE_MS = 400;
|
|
31
31
|
/** How often a parked loop runs one frame anyway, in case a wake was missed. */
|
|
32
32
|
export const IDLE_POLL_MS = 250;
|
|
33
|
+
/** The longest a parked loop waits between polls; each idle poll doubles the wait up to this. */
|
|
34
|
+
export const MAX_IDLE_POLL_MS = 4000;
|
|
33
35
|
|
|
34
36
|
const subscribers = new Set<() => void>();
|
|
35
37
|
|
|
36
38
|
let frameId: number | null = null;
|
|
37
39
|
let idleTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
let idlePollMs = IDLE_POLL_MS;
|
|
38
41
|
let awakeUntil = 0;
|
|
39
42
|
let listenersAttached = false;
|
|
40
43
|
let stopBootWake: (() => void) | null = null;
|
|
@@ -137,10 +140,13 @@ function schedule(): void {
|
|
|
137
140
|
frameId = requestAnimationFrame(runFrame);
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
143
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
144
|
+
const wait = idlePollMs;
|
|
145
|
+
idlePollMs = Math.min(idlePollMs * 2, MAX_IDLE_POLL_MS);
|
|
140
146
|
idleTimerId = setTimeout(() => {
|
|
141
147
|
idleTimerId = null;
|
|
142
148
|
frameId = requestAnimationFrame(runFrame);
|
|
143
|
-
},
|
|
149
|
+
}, wait);
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
/** Longest idle slice, so input that arrives during one waits at most this long for its frame. */
|
|
@@ -167,6 +173,7 @@ export function runWhenInputIdle(step: (timeLeft: () => number) => boolean): voi
|
|
|
167
173
|
/** Something moved, or might have. Run frames at full rate for a moment. */
|
|
168
174
|
export function requestOverlayFrames(): void {
|
|
169
175
|
awakeUntil = performance.now() + AWAKE_MS;
|
|
176
|
+
idlePollMs = IDLE_POLL_MS;
|
|
170
177
|
if (idleTimerId != null) {
|
|
171
178
|
clearTimeout(idleTimerId);
|
|
172
179
|
idleTimerId = null;
|
|
@@ -199,8 +206,8 @@ function detachListeners(): void {
|
|
|
199
206
|
|
|
200
207
|
/**
|
|
201
208
|
* Run `update` on the shared loop. The subscriber must be a pure poll: it is
|
|
202
|
-
* called on an ordinary animation frame while the editor is active
|
|
203
|
-
*
|
|
209
|
+
* called on an ordinary animation frame while the editor is active, then
|
|
210
|
+
* less often the longer it stays idle, down to once every four seconds.
|
|
204
211
|
*/
|
|
205
212
|
export function subscribeOverlayFrame(update: () => void): () => void {
|
|
206
213
|
subscribers.add(update);
|
|
@@ -230,6 +237,7 @@ export function resetOverlayFrameLoopForTests(): void {
|
|
|
230
237
|
frameId = null;
|
|
231
238
|
idleTimerId = null;
|
|
232
239
|
awakeUntil = 0;
|
|
240
|
+
idlePollMs = IDLE_POLL_MS;
|
|
233
241
|
lastPreviewFrame = null;
|
|
234
242
|
lastPreviewPlaying = null;
|
|
235
243
|
detachListeners();
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
7
|
+
import { MotionBlurPanel } from "./MotionBlurPanel";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const page = (attrs = "") =>
|
|
12
|
+
`<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
|
|
13
|
+
|
|
14
|
+
const shell = {
|
|
15
|
+
activeCompPath: null as string | null,
|
|
16
|
+
previewIframeRef: { current: null as HTMLIFrameElement | null },
|
|
17
|
+
showToast: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
21
|
+
useStudioShellContext: () => shell,
|
|
22
|
+
useStudioPlaybackContext: () => ({ refreshKey: 0 }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const files: Record<string, string> = {};
|
|
26
|
+
const writeProjectFile = vi.fn(async (path: string, content: string) => {
|
|
27
|
+
files[path] = content;
|
|
28
|
+
});
|
|
29
|
+
vi.mock("../../contexts/FileManagerContext", () => ({
|
|
30
|
+
useFileManagerContext: () => ({
|
|
31
|
+
readProjectFile: async (path: string) => files[path] ?? "",
|
|
32
|
+
writeProjectFile,
|
|
33
|
+
compositions: ["index.html", "scene.html"],
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
let root: Root | null = null;
|
|
38
|
+
const recordEdit = vi.fn(async () => {});
|
|
39
|
+
|
|
40
|
+
function cleanup() {
|
|
41
|
+
act(() => root?.unmount());
|
|
42
|
+
root = null;
|
|
43
|
+
document.body.innerHTML = "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
cleanup();
|
|
48
|
+
shell.activeCompPath = null;
|
|
49
|
+
shell.previewIframeRef.current = null;
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function mount(attrs = "") {
|
|
54
|
+
files["index.html"] = page(attrs);
|
|
55
|
+
const host = document.createElement("div");
|
|
56
|
+
document.body.append(host);
|
|
57
|
+
root = createRoot(host);
|
|
58
|
+
const session = await openComposition(files["index.html"], { history: false });
|
|
59
|
+
await act(async () => {
|
|
60
|
+
root?.render(
|
|
61
|
+
<MotionBlurPanel
|
|
62
|
+
sdkSession={session}
|
|
63
|
+
publishSdkSession={vi.fn()}
|
|
64
|
+
reloadPreview={vi.fn()}
|
|
65
|
+
recordEdit={recordEdit}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
return host;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
73
|
+
const click = async (el: Element | null) => {
|
|
74
|
+
await act(async () => (el as HTMLElement).click());
|
|
75
|
+
await settle();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const radio = (host: HTMLElement, label: string) =>
|
|
79
|
+
[...host.querySelectorAll('[role="radio"]')].find((el) => el.textContent === label) ?? null;
|
|
80
|
+
const checked = (host: HTMLElement) =>
|
|
81
|
+
[...host.querySelectorAll('[role="radio"]')]
|
|
82
|
+
.filter((el) => el.getAttribute("aria-checked") === "true")
|
|
83
|
+
.map((el) => el.textContent);
|
|
84
|
+
|
|
85
|
+
describe("MotionBlurPanel", () => {
|
|
86
|
+
it("offers Off, Subtle and Strong and nothing else", async () => {
|
|
87
|
+
const host = await mount();
|
|
88
|
+
expect([...host.querySelectorAll('[role="radio"]')].map((el) => el.textContent)).toEqual([
|
|
89
|
+
"Off",
|
|
90
|
+
"Subtle",
|
|
91
|
+
"Strong",
|
|
92
|
+
]);
|
|
93
|
+
expect(
|
|
94
|
+
host.querySelectorAll('[role="slider"], [role="switch"], [role="combobox"], input'),
|
|
95
|
+
).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("shows the level the root is at", async () => {
|
|
99
|
+
expect(checked(await mount())).toEqual(["Off"]);
|
|
100
|
+
cleanup();
|
|
101
|
+
expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Subtle"]);
|
|
102
|
+
cleanup();
|
|
103
|
+
expect(
|
|
104
|
+
checked(
|
|
105
|
+
await mount(
|
|
106
|
+
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
).toEqual(["Strong"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("Subtle writes data-motion-blur and leaves the default shutter off the root", async () => {
|
|
113
|
+
const host = await mount(
|
|
114
|
+
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
|
|
115
|
+
);
|
|
116
|
+
await click(radio(host, "Subtle"));
|
|
117
|
+
const html = files["index.html"] ?? "";
|
|
118
|
+
expect(html).toContain('data-motion-blur="on"');
|
|
119
|
+
expect(html).not.toContain("data-motion-blur-shutter");
|
|
120
|
+
expect(html).not.toContain("data-motion-blur-samples");
|
|
121
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("Strong writes the maximum shutter and samples", async () => {
|
|
125
|
+
const host = await mount();
|
|
126
|
+
await click(radio(host, "Strong"));
|
|
127
|
+
const html = files["index.html"] ?? "";
|
|
128
|
+
expect(html).toContain('data-motion-blur="on"');
|
|
129
|
+
expect(html).toContain('data-motion-blur-shutter="720"');
|
|
130
|
+
expect(html).toContain('data-motion-blur-samples="64"');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("Off removes data-motion-blur and leaves every other setting on the root", async () => {
|
|
134
|
+
const host = await mount(
|
|
135
|
+
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64" data-motion-blur-tier="filter" data-motion-blur-events="on"',
|
|
136
|
+
);
|
|
137
|
+
await click(radio(host, "Off"));
|
|
138
|
+
const html = files["index.html"] ?? "";
|
|
139
|
+
expect(html).not.toContain('data-motion-blur="on"');
|
|
140
|
+
for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"', 'events="on"']) {
|
|
141
|
+
expect(html).toContain(`data-motion-blur-${kept}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("writes nothing when the chosen level is already set", async () => {
|
|
146
|
+
const host = await mount(' data-motion-blur="on"');
|
|
147
|
+
await click(radio(host, "Subtle"));
|
|
148
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("follows the live preview over the session, so an undo or an outside edit shows", async () => {
|
|
152
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
153
|
+
doc.body.innerHTML =
|
|
154
|
+
'<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720"></div>';
|
|
155
|
+
shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
156
|
+
expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Strong"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("writes the level picked even when the preview and the file disagree", async () => {
|
|
160
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
161
|
+
doc.body.innerHTML =
|
|
162
|
+
'<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"></div>';
|
|
163
|
+
shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
164
|
+
const host = await mount();
|
|
165
|
+
await click(radio(host, "Subtle"));
|
|
166
|
+
expect(files["index.html"]).toContain('data-motion-blur="on"');
|
|
167
|
+
expect(files["index.html"]).not.toContain("data-motion-blur-samples");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is read-only for a sub-composition, which blur does not read", async () => {
|
|
171
|
+
shell.activeCompPath = "scene.html";
|
|
172
|
+
const host = await mount();
|
|
173
|
+
expect(host.textContent).toContain("read from the main composition");
|
|
174
|
+
expect(radio(host, "Strong")?.hasAttribute("disabled")).toBe(true);
|
|
175
|
+
expect(checked(host)).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|