@hyperframes/studio 0.7.76 → 0.7.78
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-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
- package/dist/assets/index-BSZrK0bx.js +428 -0
- package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
- package/dist/assets/index-DpkO2Hvw.css +1 -0
- package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +25030 -23214
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +66 -32
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import {
|
|
7
|
+
KeyframeDiamondContextMenu,
|
|
8
|
+
type KeyframeDiamondContextMenuState,
|
|
9
|
+
} from "./KeyframeDiamondContextMenu";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
|
|
14
|
+
|
|
15
|
+
const state: KeyframeDiamondContextMenuState = {
|
|
16
|
+
x: 10,
|
|
17
|
+
y: 10,
|
|
18
|
+
element,
|
|
19
|
+
elementId: "box",
|
|
20
|
+
percentage: 50,
|
|
21
|
+
tweenPercentage: 25,
|
|
22
|
+
propertyGroup: "position",
|
|
23
|
+
animationId: "box-to-1-position",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function clickMenuItem(label: string, props: Partial<Record<string, unknown>>) {
|
|
27
|
+
const host = document.createElement("div");
|
|
28
|
+
document.body.appendChild(host);
|
|
29
|
+
const root = createRoot(host);
|
|
30
|
+
act(() =>
|
|
31
|
+
root.render(
|
|
32
|
+
<KeyframeDiamondContextMenu
|
|
33
|
+
state={state}
|
|
34
|
+
onClose={() => {}}
|
|
35
|
+
onDelete={vi.fn()}
|
|
36
|
+
onDeleteAll={vi.fn()}
|
|
37
|
+
{...props}
|
|
38
|
+
/>,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const button = Array.from(document.body.querySelectorAll("button")).find(
|
|
42
|
+
(candidate) => candidate.textContent === label,
|
|
43
|
+
);
|
|
44
|
+
act(() => button?.click());
|
|
45
|
+
act(() => root.unmount());
|
|
46
|
+
host.remove();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("KeyframeDiamondContextMenu", () => {
|
|
50
|
+
// Two animations can carry a keyframe at the same clip percentage. Dropping the
|
|
51
|
+
// property group / tween percentage / animation id here sends the mutation back
|
|
52
|
+
// to first-match-by-percentage, which retimes or deletes the wrong tween.
|
|
53
|
+
it("hands every action the full keyframe identity, not just the percentage", () => {
|
|
54
|
+
const onDelete = vi.fn();
|
|
55
|
+
const onMoveToPlayhead = vi.fn();
|
|
56
|
+
|
|
57
|
+
clickMenuItem("Delete Keyframe", { onDelete });
|
|
58
|
+
clickMenuItem("Move to Playhead", { onMoveToPlayhead });
|
|
59
|
+
|
|
60
|
+
const target = {
|
|
61
|
+
percentage: 50,
|
|
62
|
+
tweenPercentage: 25,
|
|
63
|
+
propertyGroup: "position",
|
|
64
|
+
animationId: "box-to-1-position",
|
|
65
|
+
};
|
|
66
|
+
expect(onDelete).toHaveBeenCalledWith("box", target);
|
|
67
|
+
expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
5
|
+
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
4
6
|
|
|
5
7
|
export interface KeyframeDiamondContextMenuState {
|
|
6
8
|
x: number;
|
|
7
9
|
y: number;
|
|
10
|
+
element: TimelineElement;
|
|
8
11
|
elementId: string;
|
|
9
12
|
percentage: number;
|
|
10
13
|
tweenPercentage?: number;
|
|
14
|
+
propertyGroup?: string;
|
|
15
|
+
animationId?: string;
|
|
11
16
|
currentEase?: string;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
interface KeyframeDiamondContextMenuProps {
|
|
15
20
|
state: KeyframeDiamondContextMenuState;
|
|
16
21
|
onClose: () => void;
|
|
17
|
-
onDelete: (elementId: string,
|
|
18
|
-
onDeleteAll: (
|
|
19
|
-
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
20
|
-
onCopyProperties?: (elementId: string, percentage: number) => void;
|
|
22
|
+
onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
23
|
+
onDeleteAll: (element: TimelineElement) => void;
|
|
21
24
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
22
|
-
onMoveToPlayhead?: (
|
|
25
|
+
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
|
@@ -30,6 +33,14 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
30
33
|
onMoveToPlayhead,
|
|
31
34
|
}: KeyframeDiamondContextMenuProps) {
|
|
32
35
|
const menuRef = useContextMenuDismiss(onClose);
|
|
36
|
+
// The clicked diamond's identity, built once: the menu's two mutating entries
|
|
37
|
+
// both act on it, and they must not disagree about which keyframe was clicked.
|
|
38
|
+
const keyframe: TimelineKeyframeTarget = {
|
|
39
|
+
percentage: state.percentage,
|
|
40
|
+
tweenPercentage: state.tweenPercentage,
|
|
41
|
+
propertyGroup: state.propertyGroup,
|
|
42
|
+
animationId: state.animationId,
|
|
43
|
+
};
|
|
33
44
|
|
|
34
45
|
const menuWidth = 200;
|
|
35
46
|
const menuHeight = onMoveToPlayhead ? 100 : 70;
|
|
@@ -51,7 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
51
62
|
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
|
52
63
|
// and returns the tween-% for the mutation. Passing tween-% here would
|
|
53
64
|
// miss the lookup on any tween whose window is shorter than the clip.
|
|
54
|
-
onMoveToPlayhead(state.
|
|
65
|
+
onMoveToPlayhead(state.element, keyframe);
|
|
55
66
|
onClose();
|
|
56
67
|
}}
|
|
57
68
|
>
|
|
@@ -64,7 +75,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
64
75
|
type="button"
|
|
65
76
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
66
77
|
onClick={() => {
|
|
67
|
-
onDelete(state.elementId,
|
|
78
|
+
onDelete(state.elementId, keyframe);
|
|
68
79
|
onClose();
|
|
69
80
|
}}
|
|
70
81
|
>
|
|
@@ -75,7 +86,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
75
86
|
type="button"
|
|
76
87
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
77
88
|
onClick={() => {
|
|
78
|
-
onDeleteAll(state.
|
|
89
|
+
onDeleteAll(state.element);
|
|
79
90
|
onClose();
|
|
80
91
|
}}
|
|
81
92
|
>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { CaretRight } from "@phosphor-icons/react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { TRACK_H } from "./timelineLayout";
|
|
4
|
+
import { TrackClipCount } from "./TrackClipCount";
|
|
5
|
+
|
|
6
|
+
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
|
|
7
|
+
// here, not on the clip bar, and re-expands a collapsed layer.
|
|
8
|
+
export function LayerDisclosureRow({
|
|
9
|
+
keyframeClip,
|
|
10
|
+
clipCount,
|
|
11
|
+
isExpanded,
|
|
12
|
+
gutterBackground,
|
|
13
|
+
columnWidth,
|
|
14
|
+
lanesId,
|
|
15
|
+
onToggleClipExpanded,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
keyframeClip: TimelineElement;
|
|
19
|
+
clipCount: number;
|
|
20
|
+
isExpanded: boolean;
|
|
21
|
+
gutterBackground: string;
|
|
22
|
+
/** Same adaptive width the lane rows use: a narrowed header column must not
|
|
23
|
+
* leave this row hanging over the clips it labels. */
|
|
24
|
+
columnWidth: number;
|
|
25
|
+
/** Id of the element holding the lanes this row's caret expands. */
|
|
26
|
+
lanesId: string;
|
|
27
|
+
onToggleClipExpanded: () => void;
|
|
28
|
+
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
|
35
|
+
style={{
|
|
36
|
+
width: columnWidth,
|
|
37
|
+
height: TRACK_H,
|
|
38
|
+
color: "#ffffff",
|
|
39
|
+
background: gutterBackground,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-expanded={isExpanded}
|
|
45
|
+
aria-controls={lanesId}
|
|
46
|
+
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
|
47
|
+
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
|
48
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
|
|
49
|
+
// only the hit box grows.
|
|
50
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
|
51
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
52
|
+
onClick={(event) => {
|
|
53
|
+
event.stopPropagation();
|
|
54
|
+
onToggleClipExpanded();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<CaretRight
|
|
58
|
+
size={11}
|
|
59
|
+
weight="bold"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
|
62
|
+
/>
|
|
63
|
+
</button>
|
|
64
|
+
{/* Decorative: the disclosure button above already names the row's keyframe
|
|
65
|
+
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
|
66
|
+
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
67
|
+
◇
|
|
68
|
+
</span>
|
|
69
|
+
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
|
|
70
|
+
{name}
|
|
71
|
+
</span>
|
|
72
|
+
<TrackClipCount clipCount={clipCount} />
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -1,7 +1,146 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
hasUnloadedAssets,
|
|
8
|
+
Player,
|
|
9
|
+
readPreviewErrorMessage,
|
|
10
|
+
shouldShowCompositionLoadingOverlay,
|
|
11
|
+
} from "./Player";
|
|
12
|
+
|
|
13
|
+
vi.mock("@hyperframes/player", () => ({}));
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
16
|
+
|
|
17
|
+
let root: Root | null = null;
|
|
18
|
+
let lifecycleLog: string[] = [];
|
|
19
|
+
|
|
20
|
+
class TestHyperframesPlayer extends HTMLElement {
|
|
21
|
+
readonly iframeElement = document.createElement("iframe");
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
|
|
26
|
+
const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
|
|
27
|
+
this.iframeElement.addEventListener = ((type, listener, options) => {
|
|
28
|
+
lifecycleLog.push(`iframe:${type}`);
|
|
29
|
+
addIframeListener(type, listener, options);
|
|
30
|
+
}) as typeof this.iframeElement.addEventListener;
|
|
31
|
+
|
|
32
|
+
const addPlayerListener = this.addEventListener.bind(this);
|
|
33
|
+
this.addEventListener = ((type, listener, options) => {
|
|
34
|
+
lifecycleLog.push(`player:${type}`);
|
|
35
|
+
addPlayerListener(type, listener, options);
|
|
36
|
+
}) as typeof this.addEventListener;
|
|
37
|
+
|
|
38
|
+
const setPlayerAttribute = this.setAttribute.bind(this);
|
|
39
|
+
this.setAttribute = (name, value) => {
|
|
40
|
+
if (name === "src") lifecycleLog.push("src");
|
|
41
|
+
setPlayerAttribute(name, value);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!customElements.get("hyperframes-player")) {
|
|
47
|
+
customElements.define("hyperframes-player", TestHyperframesPlayer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (root) act(() => root?.unmount());
|
|
52
|
+
root = null;
|
|
53
|
+
lifecycleLog = [];
|
|
54
|
+
document.body.innerHTML = "";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function mountPlayer() {
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
document.body.append(host);
|
|
60
|
+
root = createRoot(host);
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root?.render(
|
|
63
|
+
createElement(Player, {
|
|
64
|
+
directUrl: "/api/projects/demo/preview",
|
|
65
|
+
onLoad: vi.fn(),
|
|
66
|
+
suppressLoadingOverlay: true,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
|
73
|
+
if (!player) throw new Error("player did not mount");
|
|
74
|
+
return { host, player };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createAudioIframe() {
|
|
78
|
+
const iframe = document.createElement("iframe");
|
|
79
|
+
document.body.appendChild(iframe);
|
|
80
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
81
|
+
expect(audio).toBeDefined();
|
|
82
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
83
|
+
return { audio: audio!, iframe };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("preview errors", () => {
|
|
87
|
+
it("reads the player probe error for the visible retry state", () => {
|
|
88
|
+
expect(
|
|
89
|
+
readPreviewErrorMessage(
|
|
90
|
+
new CustomEvent("error", {
|
|
91
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
).toBe("Composition timeline not found after 8s");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back when the player emits an unstructured error", () => {
|
|
98
|
+
expect(readPreviewErrorMessage(new Event("error"))).toBe(
|
|
99
|
+
"The composition preview did not become ready.",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
104
|
+
await mountPlayer();
|
|
105
|
+
const srcIndex = lifecycleLog.indexOf("src");
|
|
106
|
+
|
|
107
|
+
expect(srcIndex).toBeGreaterThan(-1);
|
|
108
|
+
for (const listener of [
|
|
109
|
+
"iframe:load",
|
|
110
|
+
"player:click",
|
|
111
|
+
"player:shadertransitionstate",
|
|
112
|
+
"player:ready",
|
|
113
|
+
"player:error",
|
|
114
|
+
]) {
|
|
115
|
+
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
|
116
|
+
expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries a failed preview with a fresh player URL", async () => {
|
|
121
|
+
const { host, player } = await mountPlayer();
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
player.dispatchEvent(
|
|
125
|
+
new CustomEvent("error", {
|
|
126
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
|
|
132
|
+
const retry = Array.from(host.querySelectorAll("button")).find(
|
|
133
|
+
(button) => button.textContent === "Retry preview",
|
|
134
|
+
);
|
|
135
|
+
if (!retry) throw new Error("retry action did not render");
|
|
136
|
+
|
|
137
|
+
act(() => retry.click());
|
|
138
|
+
|
|
139
|
+
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
|
140
|
+
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
|
141
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
5
144
|
|
|
6
145
|
describe("composition loading overlay", () => {
|
|
7
146
|
it("shows while the composition is loading", () => {
|
|
@@ -13,10 +152,7 @@ describe("composition loading overlay", () => {
|
|
|
13
152
|
});
|
|
14
153
|
|
|
15
154
|
it("keeps the asset overlay up while media is still buffering", () => {
|
|
16
|
-
const iframe =
|
|
17
|
-
document.body.appendChild(iframe);
|
|
18
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
19
|
-
expect(audio).toBeDefined();
|
|
155
|
+
const { audio, iframe } = createAudioIframe();
|
|
20
156
|
Object.defineProperty(audio, "readyState", {
|
|
21
157
|
value: 0,
|
|
22
158
|
configurable: true,
|
|
@@ -25,7 +161,6 @@ describe("composition loading overlay", () => {
|
|
|
25
161
|
value: 2,
|
|
26
162
|
configurable: true,
|
|
27
163
|
});
|
|
28
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
29
164
|
|
|
30
165
|
expect(hasUnloadedAssets(iframe, false)).toBe(true);
|
|
31
166
|
|
|
@@ -33,10 +168,7 @@ describe("composition loading overlay", () => {
|
|
|
33
168
|
});
|
|
34
169
|
|
|
35
170
|
it("does not keep the asset overlay stuck on failed media sources", () => {
|
|
36
|
-
const iframe =
|
|
37
|
-
document.body.appendChild(iframe);
|
|
38
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
39
|
-
expect(audio).toBeDefined();
|
|
171
|
+
const { audio, iframe } = createAudioIframe();
|
|
40
172
|
Object.defineProperty(audio, "error", {
|
|
41
173
|
value: { code: 4, message: "format error" },
|
|
42
174
|
configurable: true,
|
|
@@ -49,7 +181,6 @@ describe("composition loading overlay", () => {
|
|
|
49
181
|
value: 3,
|
|
50
182
|
configurable: true,
|
|
51
183
|
});
|
|
52
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
53
184
|
|
|
54
185
|
expect(hasUnloadedAssets(iframe, false)).toBe(false);
|
|
55
186
|
|
|
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
41
|
+
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
|
41
42
|
|
|
42
43
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
43
44
|
return compositionLoading;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function readPreviewErrorMessage(event: Event): string {
|
|
48
|
+
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
|
|
49
|
+
return typeof event.detail.message === "string" && event.detail.message.trim()
|
|
50
|
+
? event.detail.message
|
|
51
|
+
: DEFAULT_PREVIEW_ERROR;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
47
55
|
const root = player.shadowRoot;
|
|
48
56
|
if (!root) return;
|
|
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
122
130
|
const loadCountRef = useRef(0);
|
|
123
131
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
124
132
|
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
+
const retryPreviewRef = useRef<(() => void) | null>(null);
|
|
134
|
+
const retryCountRef = useRef(0);
|
|
125
135
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
126
136
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
127
137
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
128
138
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
129
139
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
130
140
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
141
|
+
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
131
142
|
|
|
132
143
|
// eslint-disable-next-line no-restricted-syntax
|
|
133
144
|
useEffect(() => {
|
|
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
161
172
|
);
|
|
162
173
|
applyPreviewVariablesToUrl(srcUrl);
|
|
163
174
|
const src = srcUrl.pathname + srcUrl.search;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
container.appendChild(player);
|
|
174
|
-
|
|
175
|
-
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
176
|
-
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
177
|
-
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
178
|
-
// against the gray pasteboard, consistent with professional editors.
|
|
179
|
-
if (player.shadowRoot) {
|
|
180
|
-
const pasteboardStyle = document.createElement("style");
|
|
181
|
-
pasteboardStyle.textContent =
|
|
182
|
-
".hfp-container{overflow:visible}" +
|
|
183
|
-
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
184
|
-
player.shadowRoot.appendChild(pasteboardStyle);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
enableInteractiveIframe(player);
|
|
188
|
-
|
|
189
|
-
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
175
|
+
const retryPreview = () => {
|
|
176
|
+
retryCountRef.current += 1;
|
|
177
|
+
const retryUrl = new URL(src, window.location.origin);
|
|
178
|
+
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
|
179
|
+
setPreviewError(null);
|
|
180
|
+
setCompositionLoading(true);
|
|
181
|
+
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
|
182
|
+
};
|
|
183
|
+
retryPreviewRef.current = retryPreview;
|
|
190
184
|
const iframe = player.iframeElement;
|
|
191
|
-
if (typeof ref === "function") {
|
|
192
|
-
ref(iframe);
|
|
193
|
-
} else if (ref) {
|
|
194
|
-
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Prevent the web component's built-in click-to-toggle behavior.
|
|
198
|
-
// The studio manages playback exclusively via useTimelinePlayer.
|
|
199
185
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
200
|
-
player.addEventListener("click", preventToggle, { capture: true });
|
|
201
|
-
|
|
202
186
|
const handleShaderTransitionState = (event: Event) => {
|
|
203
187
|
const loading = getShaderTransitionLoading(event);
|
|
204
188
|
if (loading !== null) setShaderTransitionLoading(loading);
|
|
205
189
|
};
|
|
206
|
-
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
207
|
-
|
|
208
190
|
const handleReady = () => {
|
|
191
|
+
setPreviewError(null);
|
|
209
192
|
setCompositionLoading(false);
|
|
210
193
|
};
|
|
211
|
-
const handleError = () => {
|
|
194
|
+
const handleError = (event: Event) => {
|
|
195
|
+
setPreviewError(readPreviewErrorMessage(event));
|
|
212
196
|
setCompositionLoading(false);
|
|
213
197
|
};
|
|
214
|
-
player.addEventListener("ready", handleReady);
|
|
215
|
-
player.addEventListener("error", handleError);
|
|
216
|
-
|
|
217
|
-
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
218
198
|
const handleLoad = () => {
|
|
219
199
|
loadCountRef.current++;
|
|
200
|
+
setPreviewError(null);
|
|
220
201
|
setShaderTransitionLoading(false);
|
|
221
202
|
setCompositionLoading(true);
|
|
222
203
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
264
245
|
setAssetsLoading(false);
|
|
265
246
|
}
|
|
266
247
|
};
|
|
248
|
+
|
|
249
|
+
// Attach lifecycle listeners before assigning src or connecting the
|
|
250
|
+
// custom element. A warm local iframe can otherwise finish before
|
|
251
|
+
// Studio observes its load and never initialize the timeline.
|
|
267
252
|
iframe.addEventListener("load", handleLoad);
|
|
253
|
+
player.addEventListener("click", preventToggle, { capture: true });
|
|
254
|
+
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
255
|
+
player.addEventListener("ready", handleReady);
|
|
256
|
+
player.addEventListener("error", handleError);
|
|
257
|
+
|
|
258
|
+
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
259
|
+
if (typeof ref === "function") {
|
|
260
|
+
ref(iframe);
|
|
261
|
+
} else if (ref) {
|
|
262
|
+
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
player.setAttribute("shader-capture-scale", "1");
|
|
266
|
+
player.setAttribute("shader-loading", "player");
|
|
267
|
+
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
268
|
+
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
269
|
+
player.style.width = "100%";
|
|
270
|
+
player.style.height = "100%";
|
|
271
|
+
player.style.display = "block";
|
|
272
|
+
player.style.background = "transparent";
|
|
273
|
+
player.setAttribute("src", src);
|
|
274
|
+
container.appendChild(player);
|
|
275
|
+
|
|
276
|
+
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
277
|
+
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
278
|
+
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
279
|
+
// against the gray pasteboard, consistent with professional editors.
|
|
280
|
+
if (player.shadowRoot) {
|
|
281
|
+
const pasteboardStyle = document.createElement("style");
|
|
282
|
+
pasteboardStyle.textContent =
|
|
283
|
+
".hfp-container{overflow:visible}" +
|
|
284
|
+
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
285
|
+
player.shadowRoot.appendChild(pasteboardStyle);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
enableInteractiveIframe(player);
|
|
268
289
|
|
|
269
290
|
cleanup = () => {
|
|
270
291
|
iframe.removeEventListener("load", handleLoad);
|
|
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
275
296
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
276
297
|
assetPollRef.current = null;
|
|
277
298
|
container.removeChild(player);
|
|
299
|
+
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
|
278
300
|
// Clear the forwarded ref only if it still points to THIS iframe.
|
|
279
301
|
// During crossfade refreshes the retiring Player unmounts after the
|
|
280
302
|
// new Player has already assigned its iframe to the same ref — blindly
|
|
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
381
403
|
/>
|
|
382
404
|
</div>
|
|
383
405
|
)}
|
|
406
|
+
{previewError && (
|
|
407
|
+
<div
|
|
408
|
+
className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
|
|
409
|
+
data-hyperframes-ignore=""
|
|
410
|
+
data-testid="composition-preview-error"
|
|
411
|
+
>
|
|
412
|
+
<div className="max-w-sm">
|
|
413
|
+
<p className="text-sm font-semibold text-white">Preview failed to load</p>
|
|
414
|
+
<p className="mt-1 text-xs text-neutral-400">{previewError}</p>
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
|
|
418
|
+
onClick={() => retryPreviewRef.current?.()}
|
|
419
|
+
>
|
|
420
|
+
Retry preview
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
384
425
|
</div>
|
|
385
426
|
);
|
|
386
427
|
},
|