@hyperframes/studio 0.6.86 → 0.6.87
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/index-BA19FAPN.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-BT9VHgSy.js +0 -140
- package/dist/assets/index-DHcptK1_.css +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { KeyframeCacheEntry } from "../store/playerStore";
|
|
3
|
+
|
|
4
|
+
const SUB_TRACK_H = 24;
|
|
5
|
+
const DIAMOND_SIZE = 6;
|
|
6
|
+
const HALF = DIAMOND_SIZE / 2;
|
|
7
|
+
|
|
8
|
+
interface TimelinePropertyRowsProps {
|
|
9
|
+
keyframesData: KeyframeCacheEntry;
|
|
10
|
+
clipWidthPx: number;
|
|
11
|
+
clipLeftPx: number;
|
|
12
|
+
accentColor: string;
|
|
13
|
+
isSelected: boolean;
|
|
14
|
+
currentPercentage: number;
|
|
15
|
+
elementId: string;
|
|
16
|
+
selectedKeyframes: Set<string>;
|
|
17
|
+
onClickKeyframe?: (percentage: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractProperties(data: KeyframeCacheEntry): string[] {
|
|
21
|
+
const props = new Set<string>();
|
|
22
|
+
for (const kf of data.keyframes) {
|
|
23
|
+
for (const key of Object.keys(kf.properties)) {
|
|
24
|
+
props.add(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return Array.from(props).sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
|
31
|
+
keyframesData,
|
|
32
|
+
clipWidthPx,
|
|
33
|
+
clipLeftPx,
|
|
34
|
+
accentColor,
|
|
35
|
+
isSelected,
|
|
36
|
+
currentPercentage,
|
|
37
|
+
elementId,
|
|
38
|
+
selectedKeyframes,
|
|
39
|
+
onClickKeyframe,
|
|
40
|
+
}: TimelinePropertyRowsProps) {
|
|
41
|
+
const properties = extractProperties(keyframesData);
|
|
42
|
+
if (properties.length === 0 || clipWidthPx < 20) return null;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col">
|
|
46
|
+
{properties.map((prop) => {
|
|
47
|
+
const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties);
|
|
48
|
+
if (propKeyframes.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div key={prop} className="relative flex items-center" style={{ height: SUB_TRACK_H }}>
|
|
52
|
+
<span className="absolute left-1 text-[8px] font-medium text-neutral-600 z-10 select-none">
|
|
53
|
+
{prop}
|
|
54
|
+
</span>
|
|
55
|
+
<svg
|
|
56
|
+
className="absolute"
|
|
57
|
+
style={{ left: clipLeftPx, width: clipWidthPx, height: SUB_TRACK_H }}
|
|
58
|
+
viewBox={`0 0 ${clipWidthPx} ${SUB_TRACK_H}`}
|
|
59
|
+
>
|
|
60
|
+
<line
|
|
61
|
+
x1={0}
|
|
62
|
+
y1={SUB_TRACK_H / 2}
|
|
63
|
+
x2={clipWidthPx}
|
|
64
|
+
y2={SUB_TRACK_H / 2}
|
|
65
|
+
stroke={isSelected ? accentColor : "#525252"}
|
|
66
|
+
strokeOpacity={0.15}
|
|
67
|
+
strokeWidth={1}
|
|
68
|
+
/>
|
|
69
|
+
{propKeyframes.map((kf) => {
|
|
70
|
+
const x = (kf.percentage / 100) * clipWidthPx;
|
|
71
|
+
const y = SUB_TRACK_H / 2;
|
|
72
|
+
const key = `${elementId}:${kf.percentage}`;
|
|
73
|
+
const isKfSelected = selectedKeyframes.has(key);
|
|
74
|
+
const isHold = kf.ease === "steps(1)";
|
|
75
|
+
const fillColor =
|
|
76
|
+
isKfSelected || (isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5)
|
|
77
|
+
? accentColor
|
|
78
|
+
: isSelected
|
|
79
|
+
? `${accentColor}80`
|
|
80
|
+
: "#737373";
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<g
|
|
84
|
+
key={kf.percentage}
|
|
85
|
+
onClick={(e) => {
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
onClickKeyframe?.(kf.percentage);
|
|
88
|
+
}}
|
|
89
|
+
style={{ cursor: "pointer" }}
|
|
90
|
+
>
|
|
91
|
+
{isHold ? (
|
|
92
|
+
<rect
|
|
93
|
+
x={x - HALF}
|
|
94
|
+
y={y - HALF}
|
|
95
|
+
width={DIAMOND_SIZE}
|
|
96
|
+
height={DIAMOND_SIZE}
|
|
97
|
+
fill={fillColor}
|
|
98
|
+
/>
|
|
99
|
+
) : (
|
|
100
|
+
<rect
|
|
101
|
+
x={x - HALF}
|
|
102
|
+
y={y - HALF}
|
|
103
|
+
width={DIAMOND_SIZE}
|
|
104
|
+
height={DIAMOND_SIZE}
|
|
105
|
+
fill={fillColor}
|
|
106
|
+
transform={`rotate(45, ${x}, ${y})`}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
</g>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</svg>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export { SUB_TRACK_H };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
|
|
4
|
+
|
|
5
|
+
function makeDoc(html: string): Document {
|
|
6
|
+
const d = document.implementation.createHTMLDocument();
|
|
7
|
+
d.body.innerHTML = html;
|
|
8
|
+
return d;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
12
|
+
it("harvests hfId from a data-start element that has data-hf-id", () => {
|
|
13
|
+
const doc = makeDoc(`
|
|
14
|
+
<div data-composition-id="root">
|
|
15
|
+
<div id="hero" class="clip" data-start="0" data-duration="5" data-hf-id="hf-abc123"></div>
|
|
16
|
+
</div>
|
|
17
|
+
`);
|
|
18
|
+
|
|
19
|
+
const elements = parseTimelineFromDOM(doc, 10);
|
|
20
|
+
const hero = elements.find((el) => el.domId === "hero");
|
|
21
|
+
|
|
22
|
+
expect(hero).toBeDefined();
|
|
23
|
+
expect(hero?.hfId).toBe("hf-abc123");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("leaves hfId undefined when element has no data-hf-id", () => {
|
|
27
|
+
const doc = makeDoc(`
|
|
28
|
+
<div data-composition-id="root">
|
|
29
|
+
<div id="plain" class="clip" data-start="0" data-duration="5"></div>
|
|
30
|
+
</div>
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
const elements = parseTimelineFromDOM(doc, 10);
|
|
34
|
+
const plain = elements.find((el) => el.domId === "plain");
|
|
35
|
+
|
|
36
|
+
expect(plain).toBeDefined();
|
|
37
|
+
expect(plain?.hfId).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
42
|
+
it("harvests hfId from an implicit layer child that has data-hf-id", () => {
|
|
43
|
+
const doc = makeDoc(`
|
|
44
|
+
<div data-composition-id="root">
|
|
45
|
+
<div id="layer" class="clip" data-hf-id="hf-xyz789"></div>
|
|
46
|
+
</div>
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
const layers = createImplicitTimelineLayersFromDOM(doc, 10);
|
|
50
|
+
const layer = layers.find((el) => el.domId === "layer");
|
|
51
|
+
|
|
52
|
+
expect(layer).toBeDefined();
|
|
53
|
+
expect(layer?.hfId).toBe("hf-xyz789");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -26,14 +26,21 @@ import {
|
|
|
26
26
|
|
|
27
27
|
// Re-export helpers that were previously public from this module so that
|
|
28
28
|
// existing import sites (hook + tests) don't need to change.
|
|
29
|
+
// fallow-ignore-next-line unused-exports
|
|
29
30
|
export {
|
|
30
31
|
readTimelineDurationFromDocument,
|
|
32
|
+
// fallow-ignore-next-line unused-exports
|
|
31
33
|
resolveMediaElement,
|
|
34
|
+
// fallow-ignore-next-line unused-exports
|
|
32
35
|
applyMediaMetadataFromElement,
|
|
33
36
|
getTimelineElementSelector,
|
|
37
|
+
// fallow-ignore-next-line unused-exports
|
|
34
38
|
getTimelineElementSourceFile,
|
|
39
|
+
// fallow-ignore-next-line unused-exports
|
|
35
40
|
getTimelineElementSelectorIndex,
|
|
41
|
+
// fallow-ignore-next-line unused-exports
|
|
36
42
|
buildTimelineElementIdentity,
|
|
43
|
+
// fallow-ignore-next-line unused-exports
|
|
37
44
|
getTimelineElementIdentity,
|
|
38
45
|
findTimelineDomNodeForClip,
|
|
39
46
|
} from "./timelineElementHelpers";
|
|
@@ -72,8 +79,10 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
72
79
|
let selectorIndex: number | undefined;
|
|
73
80
|
let sourceFile: string | undefined;
|
|
74
81
|
|
|
82
|
+
let hfId: string | undefined;
|
|
75
83
|
if (hostEl) {
|
|
76
84
|
domId = hostEl.id || undefined;
|
|
85
|
+
hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
|
77
86
|
selector = getTimelineElementSelector(hostEl);
|
|
78
87
|
selectorIndex =
|
|
79
88
|
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
|
@@ -98,6 +107,7 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
98
107
|
duration: clip.duration,
|
|
99
108
|
track: clip.track,
|
|
100
109
|
domId,
|
|
110
|
+
hfId,
|
|
101
111
|
selector,
|
|
102
112
|
selectorIndex,
|
|
103
113
|
sourceFile,
|
|
@@ -127,6 +137,7 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
127
137
|
}
|
|
128
138
|
if (hostEl) {
|
|
129
139
|
entry.domId = hostEl.id || undefined;
|
|
140
|
+
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
|
|
130
141
|
entry.selector = getTimelineElementSelector(hostEl);
|
|
131
142
|
entry.selectorIndex =
|
|
132
143
|
doc && entry.selector
|
|
@@ -187,6 +198,7 @@ export function createImplicitTimelineLayersFromDOM(
|
|
|
187
198
|
|
|
188
199
|
layers.push({
|
|
189
200
|
domId: child.id || undefined,
|
|
201
|
+
hfId: child.getAttribute("data-hf-id") || undefined,
|
|
190
202
|
duration: rootDuration,
|
|
191
203
|
id: identity.id,
|
|
192
204
|
key: identity.key,
|
|
@@ -262,6 +274,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
262
274
|
duration: dur,
|
|
263
275
|
track: isNaN(track) ? 0 : track,
|
|
264
276
|
domId: el.id || undefined,
|
|
277
|
+
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
265
278
|
selector,
|
|
266
279
|
selectorIndex,
|
|
267
280
|
sourceFile,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { buildMissingCompositionElements } from "./timelineIframeHelpers";
|
|
4
|
+
import type { IframeWindow } from "./playbackTypes";
|
|
5
|
+
|
|
6
|
+
function makeDoc(html: string): Document {
|
|
7
|
+
const d = document.implementation.createHTMLDocument();
|
|
8
|
+
d.body.innerHTML = html;
|
|
9
|
+
return d;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
13
|
+
it("harvests hfId from data-hf-id on composition host elements", () => {
|
|
14
|
+
const doc = makeDoc(`
|
|
15
|
+
<div data-composition-id="root">
|
|
16
|
+
<div
|
|
17
|
+
data-composition-id="scene-a"
|
|
18
|
+
data-composition-src="scenes/a.html"
|
|
19
|
+
data-hf-id="hf-scene1"
|
|
20
|
+
data-start="0"
|
|
21
|
+
data-duration="5"
|
|
22
|
+
></div>
|
|
23
|
+
</div>
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
|
27
|
+
const entry = missing[0];
|
|
28
|
+
|
|
29
|
+
expect(entry).toBeDefined();
|
|
30
|
+
expect(entry?.hfId).toBe("hf-scene1");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("leaves hfId undefined when element has no data-hf-id", () => {
|
|
34
|
+
const doc = makeDoc(`
|
|
35
|
+
<div data-composition-id="root">
|
|
36
|
+
<div
|
|
37
|
+
data-composition-id="scene-b"
|
|
38
|
+
data-composition-src="scenes/b.html"
|
|
39
|
+
data-start="0"
|
|
40
|
+
data-duration="5"
|
|
41
|
+
></div>
|
|
42
|
+
</div>
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
|
46
|
+
const entry = missing[0];
|
|
47
|
+
|
|
48
|
+
expect(entry).toBeDefined();
|
|
49
|
+
expect(entry?.hfId).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -22,6 +22,8 @@ export interface TimelineElement {
|
|
|
22
22
|
duration: number;
|
|
23
23
|
track: number;
|
|
24
24
|
domId?: string;
|
|
25
|
+
/** Stable `data-hf-id` attribute value — used as primary patch target when present */
|
|
26
|
+
hfId?: string;
|
|
25
27
|
/** Best-effort selector used when patching source HTML back from timeline edits */
|
|
26
28
|
selector?: string;
|
|
27
29
|
/** Zero-based occurrence index for non-unique selectors */
|
|
@@ -68,6 +70,23 @@ interface PlayerState {
|
|
|
68
70
|
toggleSelectedKeyframe: (key: string) => void;
|
|
69
71
|
clearSelectedKeyframes: () => void;
|
|
70
72
|
|
|
73
|
+
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
74
|
+
selectedElementIds: Set<string>;
|
|
75
|
+
toggleSelectedElementId: (id: string) => void;
|
|
76
|
+
clearSelectedElementIds: () => void;
|
|
77
|
+
|
|
78
|
+
/** Clipboard for keyframe copy/paste — stores keyframes with relative times. */
|
|
79
|
+
keyframeClipboard: Array<{
|
|
80
|
+
relativeTime: number;
|
|
81
|
+
properties: Record<string, number | string>;
|
|
82
|
+
ease?: string;
|
|
83
|
+
}> | null;
|
|
84
|
+
setKeyframeClipboard: (data: PlayerState["keyframeClipboard"]) => void;
|
|
85
|
+
|
|
86
|
+
/** Elements with expanded property rows in the timeline. */
|
|
87
|
+
expandedTimelineElements: Set<string>;
|
|
88
|
+
toggleExpandedElement: (id: string) => void;
|
|
89
|
+
|
|
71
90
|
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
|
72
91
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
73
92
|
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
|
@@ -138,6 +157,28 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
138
157
|
}),
|
|
139
158
|
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
|
|
140
159
|
|
|
160
|
+
keyframeClipboard: null,
|
|
161
|
+
setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
|
|
162
|
+
|
|
163
|
+
selectedElementIds: new Set<string>(),
|
|
164
|
+
toggleSelectedElementId: (id: string) =>
|
|
165
|
+
set((s) => {
|
|
166
|
+
const next = new Set(s.selectedElementIds);
|
|
167
|
+
if (next.has(id)) next.delete(id);
|
|
168
|
+
else next.add(id);
|
|
169
|
+
return { selectedElementIds: next };
|
|
170
|
+
}),
|
|
171
|
+
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
|
172
|
+
|
|
173
|
+
expandedTimelineElements: new Set<string>(),
|
|
174
|
+
toggleExpandedElement: (id: string) =>
|
|
175
|
+
set((s) => {
|
|
176
|
+
const next = new Set(s.expandedTimelineElements);
|
|
177
|
+
if (next.has(id)) next.delete(id);
|
|
178
|
+
else next.add(id);
|
|
179
|
+
return { expandedTimelineElements: next };
|
|
180
|
+
}),
|
|
181
|
+
|
|
141
182
|
keyframeCache: new Map(),
|
|
142
183
|
setKeyframeCache: (elementId, data) =>
|
|
143
184
|
set((s) => {
|
|
@@ -210,6 +251,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
210
251
|
inPoint: null,
|
|
211
252
|
outPoint: null,
|
|
212
253
|
selectedKeyframes: new Set(),
|
|
254
|
+
selectedElementIds: new Set(),
|
|
255
|
+
expandedTimelineElements: new Set(),
|
|
213
256
|
keyframeCache: new Map(),
|
|
214
257
|
}),
|
|
215
258
|
}));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const WINDOW_SIZE = 1024;
|
|
2
|
+
const HOP_SIZE = 512;
|
|
3
|
+
|
|
4
|
+
// fallow-ignore-next-line complexity
|
|
5
|
+
export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
|
|
6
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
7
|
+
const sampleRate = audioBuffer.sampleRate;
|
|
8
|
+
|
|
9
|
+
const energies: number[] = [];
|
|
10
|
+
for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
|
|
11
|
+
let sum = 0;
|
|
12
|
+
for (let j = 0; j < WINDOW_SIZE; j++) {
|
|
13
|
+
const sample = channelData[i + j]!;
|
|
14
|
+
sum += sample * sample;
|
|
15
|
+
}
|
|
16
|
+
energies.push(sum / WINDOW_SIZE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const beats: number[] = [];
|
|
20
|
+
const localWindowSize = 20;
|
|
21
|
+
|
|
22
|
+
for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
|
|
23
|
+
let localMean = 0;
|
|
24
|
+
for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
|
|
25
|
+
localMean += energies[j]!;
|
|
26
|
+
}
|
|
27
|
+
localMean /= localWindowSize * 2;
|
|
28
|
+
|
|
29
|
+
const threshold = localMean * 1.5;
|
|
30
|
+
const current = energies[i]!;
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
current > threshold &&
|
|
34
|
+
current > (energies[i - 1] ?? 0) &&
|
|
35
|
+
current > (energies[i + 1] ?? 0)
|
|
36
|
+
) {
|
|
37
|
+
const timeInSeconds = (i * HOP_SIZE) / sampleRate;
|
|
38
|
+
if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
|
|
39
|
+
beats.push(Math.round(timeInSeconds * 1000) / 1000);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return beats;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// fallow-ignore-next-line complexity
|
|
48
|
+
export async function detectBeatsFromUrl(url: string): Promise<number[]> {
|
|
49
|
+
const audioContext = new AudioContext();
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(url);
|
|
52
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
53
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
54
|
+
return detectBeats(audioBuffer);
|
|
55
|
+
} finally {
|
|
56
|
+
await audioContext.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
absoluteToPercentage,
|
|
4
|
+
absoluteToPercentageForAnimation,
|
|
5
|
+
findTweenAtTime,
|
|
6
|
+
isTimeWithinTween,
|
|
7
|
+
percentageToAbsolute,
|
|
8
|
+
percentageToAbsoluteForAnimation,
|
|
9
|
+
resolveTweenDuration,
|
|
10
|
+
resolveTweenStart,
|
|
11
|
+
} from "./globalTimeCompiler";
|
|
12
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
13
|
+
|
|
14
|
+
function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
15
|
+
return {
|
|
16
|
+
id: "#el-to-0",
|
|
17
|
+
targetSelector: "#el",
|
|
18
|
+
method: "to",
|
|
19
|
+
position: 0,
|
|
20
|
+
properties: { x: 100 },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("absoluteToPercentage", () => {
|
|
26
|
+
test("mid-point of a tween", () => {
|
|
27
|
+
expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("tween with offset start", () => {
|
|
31
|
+
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("clamps below tween start to 0%", () => {
|
|
35
|
+
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("clamps past tween end to 100%", () => {
|
|
39
|
+
expect(absoluteToPercentage(5, 0, 2)).toBe(100);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("zero duration returns 0", () => {
|
|
43
|
+
expect(absoluteToPercentage(1, 0, 0)).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("percentageToAbsolute", () => {
|
|
48
|
+
test("converts percentage back to absolute time", () => {
|
|
49
|
+
expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("0% returns tween start", () => {
|
|
53
|
+
expect(percentageToAbsolute(0, 2, 3)).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("100% returns tween end", () => {
|
|
57
|
+
expect(percentageToAbsolute(100, 2, 3)).toBe(5);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isTimeWithinTween", () => {
|
|
62
|
+
test("time inside returns true", () => {
|
|
63
|
+
expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("time at start returns true", () => {
|
|
67
|
+
expect(isTimeWithinTween(0, 0, 2)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("time at end returns true", () => {
|
|
71
|
+
expect(isTimeWithinTween(2, 0, 2)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("time before returns false", () => {
|
|
75
|
+
expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("time after returns false", () => {
|
|
79
|
+
expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveTweenStart", () => {
|
|
84
|
+
test("numeric position", () => {
|
|
85
|
+
expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("parseable string position", () => {
|
|
89
|
+
expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("unparseable string position returns null", () => {
|
|
93
|
+
expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("relative position +=0.5 returns null", () => {
|
|
97
|
+
expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("resolveTweenDuration", () => {
|
|
102
|
+
test("explicit duration", () => {
|
|
103
|
+
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("missing duration defaults to 1", () => {
|
|
107
|
+
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("findTweenAtTime", () => {
|
|
112
|
+
const anims = [
|
|
113
|
+
makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
|
|
114
|
+
makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
|
|
115
|
+
makeAnim({
|
|
116
|
+
id: "#other-to-0",
|
|
117
|
+
targetSelector: "#other",
|
|
118
|
+
position: 0,
|
|
119
|
+
duration: 2,
|
|
120
|
+
}),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
test("finds tween at time within range", () => {
|
|
124
|
+
expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("finds second tween", () => {
|
|
128
|
+
expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returns null for gap between tweens", () => {
|
|
132
|
+
expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("filters by selector", () => {
|
|
136
|
+
expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("returns null for unmatched selector", () => {
|
|
140
|
+
expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("skips tweens with unresolvable string positions", () => {
|
|
144
|
+
const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
|
|
145
|
+
expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("animation-level helpers", () => {
|
|
150
|
+
const anim = makeAnim({ position: 0.5, duration: 2 });
|
|
151
|
+
|
|
152
|
+
test("absoluteToPercentageForAnimation", () => {
|
|
153
|
+
expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("absoluteToPercentageForAnimation returns null for string position", () => {
|
|
157
|
+
const labelAnim = makeAnim({ position: "label" });
|
|
158
|
+
expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("percentageToAbsoluteForAnimation", () => {
|
|
162
|
+
expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("percentageToAbsoluteForAnimation returns null for string position", () => {
|
|
166
|
+
const labelAnim = makeAnim({ position: "+=1" });
|
|
167
|
+
expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
|
|
3
|
+
export function absoluteToPercentage(
|
|
4
|
+
time: number,
|
|
5
|
+
tweenStart: number,
|
|
6
|
+
tweenDuration: number,
|
|
7
|
+
): number {
|
|
8
|
+
if (tweenDuration <= 0) return 0;
|
|
9
|
+
const raw = ((time - tweenStart) / tweenDuration) * 100;
|
|
10
|
+
return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function percentageToAbsolute(
|
|
14
|
+
pct: number,
|
|
15
|
+
tweenStart: number,
|
|
16
|
+
tweenDuration: number,
|
|
17
|
+
): number {
|
|
18
|
+
return tweenStart + (pct / 100) * tweenDuration;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isTimeWithinTween(
|
|
22
|
+
time: number,
|
|
23
|
+
tweenStart: number,
|
|
24
|
+
tweenDuration: number,
|
|
25
|
+
): boolean {
|
|
26
|
+
return time >= tweenStart && time <= tweenStart + tweenDuration;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveTweenStart(animation: GsapAnimation): number | null {
|
|
30
|
+
if (typeof animation.position === "number") return animation.position;
|
|
31
|
+
const parsed = Number.parseFloat(animation.position as string);
|
|
32
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveTweenDuration(animation: GsapAnimation): number {
|
|
37
|
+
return animation.duration ?? 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findTweenAtTime(
|
|
41
|
+
time: number,
|
|
42
|
+
animations: GsapAnimation[],
|
|
43
|
+
selector: string,
|
|
44
|
+
): GsapAnimation | null {
|
|
45
|
+
for (const anim of animations) {
|
|
46
|
+
if (!matchesSelector(anim.targetSelector, selector)) continue;
|
|
47
|
+
const start = resolveTweenStart(anim);
|
|
48
|
+
if (start === null) continue;
|
|
49
|
+
const duration = resolveTweenDuration(anim);
|
|
50
|
+
if (isTimeWithinTween(time, start, duration)) return anim;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function absoluteToPercentageForAnimation(
|
|
56
|
+
time: number,
|
|
57
|
+
animation: GsapAnimation,
|
|
58
|
+
): number | null {
|
|
59
|
+
const start = resolveTweenStart(animation);
|
|
60
|
+
if (start === null) return null;
|
|
61
|
+
const duration = resolveTweenDuration(animation);
|
|
62
|
+
return absoluteToPercentage(time, start, duration);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function percentageToAbsoluteForAnimation(
|
|
66
|
+
pct: number,
|
|
67
|
+
animation: GsapAnimation,
|
|
68
|
+
): number | null {
|
|
69
|
+
const start = resolveTweenStart(animation);
|
|
70
|
+
if (start === null) return null;
|
|
71
|
+
const duration = resolveTweenDuration(animation);
|
|
72
|
+
return percentageToAbsolute(pct, start, duration);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchesSelector(tweenSelector: string, querySelector: string): boolean {
|
|
76
|
+
return tweenSelector.split(",").some((part) => part.trim() === querySelector);
|
|
77
|
+
}
|