@hyperframes/studio 0.8.139 → 0.8.141
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-5Vx2AiUz.js +473 -0
- package/dist/assets/{index-DsEkE5_G.js → index-BAnh7cSQ.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-C0thd4eM.js} +204 -204
- package/dist/assets/{index-BmIPrb8I.js → index-P8fgj_Xa.js} +1 -1
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6518 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +60 -1
- package/src/components/nle/NLEPreview.tsx +9 -2
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +43 -0
- package/src/components/sidebar/CompositionsTab.tsx +17 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/runtimeProtocol.ts +6 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/dist/assets/hyperframes-player-CckOoCJ8.js +0 -473
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { StyleSections } from "./propertyPanelStyleSections";
|
|
6
|
+
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function mount(
|
|
15
|
+
Panel: typeof StyleSections | typeof FlatStyleSection,
|
|
16
|
+
tag = "img",
|
|
17
|
+
sourceFile = "index.html",
|
|
18
|
+
src = "assets/old.png",
|
|
19
|
+
) {
|
|
20
|
+
const selected = document.createElement(tag);
|
|
21
|
+
selected.id = "picture";
|
|
22
|
+
selected.setAttribute("src", src);
|
|
23
|
+
document.body.append(selected);
|
|
24
|
+
const element: DomEditSelection = {
|
|
25
|
+
element: selected,
|
|
26
|
+
id: "picture",
|
|
27
|
+
selector: "#picture",
|
|
28
|
+
label: "Picture",
|
|
29
|
+
tagName: tag,
|
|
30
|
+
sourceFile,
|
|
31
|
+
compositionPath: sourceFile,
|
|
32
|
+
isCompositionHost: false,
|
|
33
|
+
isInsideLockedComposition: false,
|
|
34
|
+
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
|
|
35
|
+
textContent: "",
|
|
36
|
+
dataAttributes: {},
|
|
37
|
+
inlineStyles: {},
|
|
38
|
+
computedStyles: {},
|
|
39
|
+
textFields: [],
|
|
40
|
+
capabilities: {
|
|
41
|
+
canSelect: true,
|
|
42
|
+
canEditStyles: true,
|
|
43
|
+
canCrop: true,
|
|
44
|
+
canMove: true,
|
|
45
|
+
canResize: true,
|
|
46
|
+
canApplyManualOffset: true,
|
|
47
|
+
canApplyManualSize: true,
|
|
48
|
+
canApplyManualRotation: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
document.body.append(host);
|
|
53
|
+
const root = createRoot(host);
|
|
54
|
+
const onSetStyle = vi.fn((prop: string, value: string) =>
|
|
55
|
+
selected.style.setProperty(prop, value),
|
|
56
|
+
);
|
|
57
|
+
const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
|
|
58
|
+
if (value !== null) selected.setAttribute(attr, value);
|
|
59
|
+
});
|
|
60
|
+
act(() =>
|
|
61
|
+
root.render(
|
|
62
|
+
<Panel
|
|
63
|
+
projectId="fixture"
|
|
64
|
+
element={element}
|
|
65
|
+
styles={{}}
|
|
66
|
+
assets={["assets/old.png", "assets/new.png"]}
|
|
67
|
+
onSetStyle={onSetStyle}
|
|
68
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
69
|
+
/>,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
const image = Array.from(host.querySelectorAll("button")).find(
|
|
73
|
+
(button) => button.textContent === "Image",
|
|
74
|
+
);
|
|
75
|
+
if (!image) throw new Error("Image control must exist");
|
|
76
|
+
act(() => image.click());
|
|
77
|
+
// The Image asset select is the one whose options include the fixture asset.
|
|
78
|
+
const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
|
|
79
|
+
Array.from(node.options).some((option) => option.value === "assets/new.png"),
|
|
80
|
+
);
|
|
81
|
+
if (!assetSelect) throw new Error("Project asset control must exist");
|
|
82
|
+
function choose(value: string) {
|
|
83
|
+
const control = assetSelect;
|
|
84
|
+
if (!control) throw new Error("Asset control must exist");
|
|
85
|
+
act(() => {
|
|
86
|
+
control.value = value;
|
|
87
|
+
control.dispatchEvent(new Event("change", { bubbles: true }));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe.each([
|
|
94
|
+
["classic", StyleSections],
|
|
95
|
+
["flat", FlatStyleSection],
|
|
96
|
+
] as const)("%s Image fill", (_name, Panel) => {
|
|
97
|
+
it("shows the img source as the current project asset", () => {
|
|
98
|
+
const view = mount(Panel);
|
|
99
|
+
expect(view.assetSelect.value).toBe("assets/old.png");
|
|
100
|
+
act(() => view.root.unmount());
|
|
101
|
+
});
|
|
102
|
+
it.each([
|
|
103
|
+
"assets/100%.png",
|
|
104
|
+
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
|
|
105
|
+
])("shows an authored source containing a literal percent: %s", (src) => {
|
|
106
|
+
const view = mount(Panel, "img", "index.html", src);
|
|
107
|
+
const input = Array.from(view.host.querySelectorAll("label"))
|
|
108
|
+
.find((node) => node.querySelector("span")?.textContent === "External URL")
|
|
109
|
+
?.querySelector("input");
|
|
110
|
+
expect(input?.value).toBe(src);
|
|
111
|
+
act(() => view.root.unmount());
|
|
112
|
+
});
|
|
113
|
+
it("replaces the img source rather than writing a hidden background", () => {
|
|
114
|
+
const view = mount(Panel);
|
|
115
|
+
view.choose("assets/new.png");
|
|
116
|
+
expect(view.selected.getAttribute("src")).toBe("assets/new.png");
|
|
117
|
+
expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
|
|
118
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
119
|
+
act(() => view.root.unmount());
|
|
120
|
+
});
|
|
121
|
+
it("replaces an img with an external URL without CSS wrapping", () => {
|
|
122
|
+
const view = mount(Panel);
|
|
123
|
+
const row = Array.from(view.host.querySelectorAll("label")).find(
|
|
124
|
+
(node) =>
|
|
125
|
+
node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
|
|
126
|
+
);
|
|
127
|
+
const input = row?.querySelector("input");
|
|
128
|
+
if (!input) throw new Error("External URL input must exist");
|
|
129
|
+
act(() => {
|
|
130
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
|
|
131
|
+
input,
|
|
132
|
+
"https://example.com/new.png",
|
|
133
|
+
);
|
|
134
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
135
|
+
});
|
|
136
|
+
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
|
|
137
|
+
expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
|
|
138
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
139
|
+
act(() => view.root.unmount());
|
|
140
|
+
});
|
|
141
|
+
it("keeps the img source when cleared", () => {
|
|
142
|
+
const view = mount(Panel);
|
|
143
|
+
view.choose("");
|
|
144
|
+
expect(view.selected.getAttribute("src")).toBe("assets/old.png");
|
|
145
|
+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
146
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
147
|
+
act(() => view.root.unmount());
|
|
148
|
+
});
|
|
149
|
+
it("uses source-relative image paths in nested compositions", () => {
|
|
150
|
+
const view = mount(Panel, "img", "compositions/scene.html");
|
|
151
|
+
view.choose("assets/new.png");
|
|
152
|
+
expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
|
|
153
|
+
act(() => view.root.unmount());
|
|
154
|
+
});
|
|
155
|
+
it("keeps background-image replacement and clearing for ordinary elements", () => {
|
|
156
|
+
const view = mount(Panel, "div");
|
|
157
|
+
view.choose("assets/new.png");
|
|
158
|
+
expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
|
|
159
|
+
view.choose("");
|
|
160
|
+
expect(view.selected.style.backgroundImage).toBe("none");
|
|
161
|
+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
162
|
+
act(() => view.root.unmount());
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -45,6 +45,7 @@ export function StyleSections({
|
|
|
45
45
|
styles,
|
|
46
46
|
assets,
|
|
47
47
|
onSetStyle,
|
|
48
|
+
onSetHtmlAttribute,
|
|
48
49
|
onImportAssets,
|
|
49
50
|
gsapBorderRadius,
|
|
50
51
|
hideFlex = false,
|
|
@@ -54,6 +55,7 @@ export function StyleSections({
|
|
|
54
55
|
styles: Record<string, string>;
|
|
55
56
|
assets: string[];
|
|
56
57
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
58
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
57
59
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
58
60
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
59
61
|
// When true, the Flex `Section` is suppressed. The flat inspector renders
|
|
@@ -108,12 +110,12 @@ export function StyleSections({
|
|
|
108
110
|
const backgroundImage = styles["background-image"] ?? "none";
|
|
109
111
|
const hasTextControls = isTextEditableSelection(element);
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
let fillMode = "Solid";
|
|
114
|
+
if (element.tagName === "img") {
|
|
115
|
+
fillMode = "Image";
|
|
116
|
+
} else if (backgroundImage && backgroundImage !== "none") {
|
|
117
|
+
fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
|
|
118
|
+
}
|
|
117
119
|
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
|
118
120
|
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
|
119
121
|
// Remember the last authored gradient so an exploratory Solid↔Gradient
|
|
@@ -488,7 +490,8 @@ export function StyleSections({
|
|
|
488
490
|
) : (
|
|
489
491
|
<ImageFillField
|
|
490
492
|
projectId={projectId}
|
|
491
|
-
|
|
493
|
+
element={element}
|
|
494
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
492
495
|
value={imageUrl}
|
|
493
496
|
assets={assets}
|
|
494
497
|
disabled={styleEditingDisabled}
|
|
@@ -12,6 +12,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
12
12
|
const playerMounts: string[] = [];
|
|
13
13
|
let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
|
|
14
14
|
|
|
15
|
+
const measured = vi.hoisted(() => ({ size: null as { width: number; height: number } | null }));
|
|
16
|
+
vi.mock("../../utils/previewCompositionSize", async (original) => ({
|
|
17
|
+
...(await original<typeof import("../../utils/previewCompositionSize")>()),
|
|
18
|
+
readPreviewCompositionSize: () => measured.size,
|
|
19
|
+
}));
|
|
20
|
+
|
|
15
21
|
vi.mock("../../player", async () => {
|
|
16
22
|
const React = await import("react");
|
|
17
23
|
|
|
@@ -81,7 +87,12 @@ function renderPreview(
|
|
|
81
87
|
{
|
|
82
88
|
box = { width: 800, height: 600 },
|
|
83
89
|
fillBox,
|
|
84
|
-
|
|
90
|
+
compositionSizeHint,
|
|
91
|
+
}: {
|
|
92
|
+
box?: { width: number; height: number };
|
|
93
|
+
fillBox?: boolean;
|
|
94
|
+
compositionSizeHint?: { width: number; height: number };
|
|
95
|
+
} = {},
|
|
85
96
|
) {
|
|
86
97
|
resizeCallbacks = [];
|
|
87
98
|
const host = document.createElement("div");
|
|
@@ -103,6 +114,7 @@ function renderPreview(
|
|
|
103
114
|
setShadowIframeNode: () => {},
|
|
104
115
|
resetPreviewSlots: () => {},
|
|
105
116
|
fillBox,
|
|
117
|
+
compositionSizeHint,
|
|
106
118
|
}),
|
|
107
119
|
);
|
|
108
120
|
});
|
|
@@ -332,6 +344,22 @@ describe("NLEPreview", () => {
|
|
|
332
344
|
view.cleanup();
|
|
333
345
|
});
|
|
334
346
|
|
|
347
|
+
it("tells the preview how large it shows once a zoom settles, which the player cannot see", () => {
|
|
348
|
+
const view = renderPreview();
|
|
349
|
+
const frame = view.host.querySelector<HTMLElement>('[data-testid="mock-player"]')!;
|
|
350
|
+
const postMessage = vi.fn();
|
|
351
|
+
Object.defineProperty(frame, "contentWindow", { value: { postMessage } });
|
|
352
|
+
Object.defineProperty(frame, "offsetWidth", { value: 800 });
|
|
353
|
+
frame.getBoundingClientRect = () => ({ width: 1600 }) as DOMRect;
|
|
354
|
+
|
|
355
|
+
pinchIn(view, 10);
|
|
356
|
+
expect(postMessage).toHaveBeenLastCalledWith(
|
|
357
|
+
expect.objectContaining({ action: "set-display-scale", scale: 2 }),
|
|
358
|
+
"*",
|
|
359
|
+
);
|
|
360
|
+
view.cleanup();
|
|
361
|
+
});
|
|
362
|
+
|
|
335
363
|
it("says how far it is zoomed, shows where in the frame, and Fit puts it back", () => {
|
|
336
364
|
const view = renderPreview();
|
|
337
365
|
expect([chip(view), navigator(view)]).toEqual([null, null]);
|
|
@@ -378,6 +406,37 @@ describe("NLEPreview", () => {
|
|
|
378
406
|
filled.cleanup();
|
|
379
407
|
});
|
|
380
408
|
|
|
409
|
+
it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
|
|
410
|
+
const box = { width: 800, height: 600 };
|
|
411
|
+
const plain = renderPreview(undefined, { box });
|
|
412
|
+
expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
|
|
413
|
+
plain.cleanup();
|
|
414
|
+
|
|
415
|
+
const hinted = renderPreview(undefined, {
|
|
416
|
+
box,
|
|
417
|
+
compositionSizeHint: { width: 1276, height: 1078 },
|
|
418
|
+
});
|
|
419
|
+
const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
|
|
420
|
+
expect(shape).toBeCloseTo(1276 / 1078, 3);
|
|
421
|
+
expect(hinted.stage.style.height).toBe("584px");
|
|
422
|
+
hinted.cleanup();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("lets the size the preview measures win over the host's hint", () => {
|
|
426
|
+
measured.size = { width: 1920, height: 1080 };
|
|
427
|
+
try {
|
|
428
|
+
const box = { width: 800, height: 600 };
|
|
429
|
+
const hinted = renderPreview(undefined, {
|
|
430
|
+
box,
|
|
431
|
+
compositionSizeHint: { width: 1276, height: 1078 },
|
|
432
|
+
});
|
|
433
|
+
expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
|
|
434
|
+
hinted.cleanup();
|
|
435
|
+
} finally {
|
|
436
|
+
measured.size = null;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
381
440
|
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
|
|
382
441
|
const view = renderPreview([
|
|
383
442
|
{ gen: 0, role: "live" },
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type RefObject,
|
|
13
13
|
} from "react";
|
|
14
14
|
import { Player } from "../../player";
|
|
15
|
+
import { postFrameDisplayScale } from "../../player/lib/runtimeProtocol";
|
|
15
16
|
import type { NLEContextValue } from "./NLEContext";
|
|
16
17
|
import {
|
|
17
18
|
DEFAULT_PREVIEW_ZOOM,
|
|
@@ -50,6 +51,7 @@ interface NLEPreviewProps extends Pick<
|
|
|
50
51
|
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
51
52
|
/** Draws the picture edge to edge in this box, without Studio's inset band. */
|
|
52
53
|
fillBox?: boolean;
|
|
54
|
+
compositionSizeHint?: PreviewCompositionSize;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export function getPreviewPlayerKey({
|
|
@@ -127,6 +129,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
127
129
|
onStageRef,
|
|
128
130
|
onCompositionSizeChange,
|
|
129
131
|
fillBox,
|
|
132
|
+
compositionSizeHint,
|
|
130
133
|
}: NLEPreviewProps) {
|
|
131
134
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
132
135
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -151,9 +154,12 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
151
154
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
152
155
|
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
153
156
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
157
|
+
const hintWidth = compositionSizeHint?.width;
|
|
158
|
+
const hintHeight = compositionSizeHint?.height;
|
|
154
159
|
|
|
155
160
|
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
156
161
|
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
162
|
+
useEffect(() => postFrameDisplayScale(previewIframeRef.current), [settledZoom]);
|
|
157
163
|
const hudRef = useRef<HTMLDivElement>(null);
|
|
158
164
|
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
159
165
|
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -183,7 +189,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
183
189
|
resolvePreviewStageSize(
|
|
184
190
|
rect.width,
|
|
185
191
|
rect.height,
|
|
186
|
-
compositionSize
|
|
192
|
+
compositionSize ??
|
|
193
|
+
(hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
|
|
187
194
|
portrait,
|
|
188
195
|
gutterPx,
|
|
189
196
|
insetPx,
|
|
@@ -195,7 +202,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
195
202
|
const observer = new ResizeObserver(updateStageSize);
|
|
196
203
|
observer.observe(viewport);
|
|
197
204
|
return () => observer.disconnect();
|
|
198
|
-
}, [compositionSize, portrait, gutterPx, insetPx]);
|
|
205
|
+
}, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
|
|
199
206
|
|
|
200
207
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
201
208
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
@@ -247,6 +247,49 @@ describe("composition card thumbnails", () => {
|
|
|
247
247
|
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-2");
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
+
it("tell a hover preview how large it shows, so its videos are not copied at source size", () => {
|
|
251
|
+
vi.useFakeTimers();
|
|
252
|
+
try {
|
|
253
|
+
const host = mount();
|
|
254
|
+
const card = host.querySelector<HTMLElement>('[aria-label^="Open composition"]')!;
|
|
255
|
+
act(() => {
|
|
256
|
+
card.dispatchEvent(
|
|
257
|
+
new MouseEvent("pointerover", { bubbles: true, relatedTarget: document.body }),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
act(() => vi.advanceTimersByTime(300));
|
|
261
|
+
const frame = host.querySelector("iframe")!;
|
|
262
|
+
const postMessage = vi.fn();
|
|
263
|
+
Object.defineProperty(frame, "contentWindow", { value: { postMessage } });
|
|
264
|
+
Object.defineProperty(frame, "offsetWidth", { value: 1920 });
|
|
265
|
+
frame.getBoundingClientRect = () => ({ width: 80 }) as DOMRect;
|
|
266
|
+
act(() => {
|
|
267
|
+
frame.dispatchEvent(new Event("load"));
|
|
268
|
+
});
|
|
269
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
270
|
+
expect.objectContaining({ action: "set-display-scale", scale: 80 / 1920 }),
|
|
271
|
+
"*",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// A module-script composition starts listening only after load, then says it is ready.
|
|
275
|
+
postMessage.mockClear();
|
|
276
|
+
act(() => {
|
|
277
|
+
window.dispatchEvent(
|
|
278
|
+
new MessageEvent("message", {
|
|
279
|
+
source: frame.contentWindow,
|
|
280
|
+
data: { source: "hf-preview", type: "ready" },
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
285
|
+
expect.objectContaining({ action: "set-display-scale", scale: 80 / 1920 }),
|
|
286
|
+
"*",
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
vi.useRealTimers();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
250
293
|
it("show a fallback when the render fails, and retry it at the next content revision", async () => {
|
|
251
294
|
const host = mount();
|
|
252
295
|
renders[0]!.answer(500);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
THUMBNAIL_SEEK_TIME_SECONDS,
|
|
14
14
|
} from "../../player/components/CompositionThumbnail";
|
|
15
15
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
16
|
+
import { postFrameDisplayScale } from "../../player/lib/runtimeProtocol";
|
|
16
17
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
17
18
|
import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
|
|
18
19
|
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
@@ -258,6 +259,22 @@ function CompCard({
|
|
|
258
259
|
if (hovered) requestIframePlaybackSync(true);
|
|
259
260
|
}, [hovered, requestIframePlaybackSync]);
|
|
260
261
|
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (livePreviewLoaded) postFrameDisplayScale(iframeRef.current);
|
|
264
|
+
}, [livePreviewLoaded, previewScale]);
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (!hovered) return;
|
|
268
|
+
const onRuntimeReady = (event: MessageEvent) => {
|
|
269
|
+
const frame = iframeRef.current;
|
|
270
|
+
if (frame && event.source === frame.contentWindow && event.data?.type === "ready") {
|
|
271
|
+
postFrameDisplayScale(frame);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
window.addEventListener("message", onRuntimeReady);
|
|
275
|
+
return () => window.removeEventListener("message", onRuntimeReady);
|
|
276
|
+
}, [hovered]);
|
|
277
|
+
|
|
261
278
|
useEffect(() => {
|
|
262
279
|
return () => {
|
|
263
280
|
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
|
|
|
21
21
|
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
|
22
22
|
"utf8",
|
|
23
23
|
);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
|
|
28
|
+
const listeners = new Map<string, (event: unknown) => void>();
|
|
29
|
+
class FakeEventSource {
|
|
30
|
+
addEventListener(type: string, listener: (event: unknown) => void) {
|
|
31
|
+
listeners.set(type, listener);
|
|
32
|
+
}
|
|
33
|
+
close = vi.fn();
|
|
34
|
+
}
|
|
35
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
36
|
+
try {
|
|
37
|
+
const onReconnect = vi.fn();
|
|
38
|
+
sseFileChangeChannel(vi.fn(), onReconnect);
|
|
39
|
+
listeners.get("open")?.(new Event("open"));
|
|
40
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
41
|
+
listeners.get("error")?.(new Event("error"));
|
|
42
|
+
listeners.get("open")?.(new Event("open"));
|
|
43
|
+
listeners.get("open")?.(new Event("open"));
|
|
44
|
+
expect(onReconnect).toHaveBeenCalledTimes(2);
|
|
45
|
+
} finally {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
}
|
|
27
48
|
});
|
|
28
49
|
|
|
29
50
|
it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
|
|
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
|
|
|
42
63
|
vi.stubGlobal("EventSource", FakeEventSource);
|
|
43
64
|
try {
|
|
44
65
|
const onDelivery = vi.fn();
|
|
45
|
-
const stop = sseFileChangeChannel(onDelivery);
|
|
66
|
+
const stop = sseFileChangeChannel(onDelivery, vi.fn());
|
|
46
67
|
const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
|
|
47
68
|
listeners.get("file-change")?.(event);
|
|
48
69
|
expect(opened).toEqual(["/api/events"]);
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
// Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
3
|
import { gsap } from "gsap";
|
|
6
4
|
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
7
5
|
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
8
|
-
import puppeteer from "puppeteer-core";
|
|
9
6
|
import { expect, it, vi } from "vitest";
|
|
10
|
-
import {
|
|
7
|
+
import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
|
|
11
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
10
|
import { planValueEdit } from "./gsapValueAtPlayhead";
|
|
14
11
|
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
12
|
vi.setConfig({ testTimeout: 60_000 });
|
|
17
13
|
|
|
18
14
|
const SRC = `var tl = gsap.timeline({ paused: true });
|
|
@@ -49,25 +45,10 @@ const page = (script: string) => `<!doctype html><html><body>
|
|
|
49
45
|
|
|
50
46
|
it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
|
|
51
47
|
const written = writeDrag(90, 2);
|
|
52
|
-
const
|
|
53
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
54
|
-
const browser = await puppeteer.launch({
|
|
55
|
-
executablePath,
|
|
56
|
-
headless: true,
|
|
57
|
-
args: ["--no-sandbox"],
|
|
58
|
-
});
|
|
48
|
+
const browser = await launchTestChrome();
|
|
59
49
|
try {
|
|
60
50
|
const tab = await browser.newPage();
|
|
61
|
-
await tab
|
|
62
|
-
// Only the GSAP script is served; anything else is refused, never fetched.
|
|
63
|
-
tab.on("request", (request) =>
|
|
64
|
-
request.url().endsWith("/gsap.min.js")
|
|
65
|
-
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
66
|
-
: request.abort("blockedbyclient"),
|
|
67
|
-
);
|
|
68
|
-
await tab.setContent(page(written), { waitUntil: "load" });
|
|
69
|
-
await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
70
|
-
await tab.waitForFunction(() => "__player" in window);
|
|
51
|
+
await showWithRuntime(tab, page(written));
|
|
71
52
|
const shown = await tab.evaluate(() => {
|
|
72
53
|
const w = window as unknown as {
|
|
73
54
|
__player: { seek(t: number): void };
|