@hyperframes/studio 0.6.99 → 0.6.101
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-BITwbxi-.css +1 -0
- package/dist/assets/{index-C52IT_lp.js → index-CQ3n6Y9q.js} +1 -1
- package/dist/assets/index-CTiqZ7XQ.js +296 -0
- package/dist/assets/{index-DOh7E1uj.js → index-DvttAtOD.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -4
- package/src/App.tsx +13 -13
- package/src/components/editor/PropertyPanel.tsx +24 -16
- package/src/components/editor/manualEditingAvailability.ts +12 -1
- package/src/components/nle/NLELayout.tsx +89 -1
- package/src/components/renders/useRenderQueue.ts +12 -8
- package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
- package/src/hooks/gsapScriptCommitTypes.ts +3 -0
- package/src/hooks/gsapTargetCache.ts +65 -0
- package/src/hooks/useAppHotkeys.ts +10 -0
- package/src/hooks/useDomEditCommits.ts +12 -14
- package/src/hooks/useDomEditSession.ts +13 -0
- package/src/hooks/useDomGeometryCommits.ts +1 -36
- package/src/hooks/useElementLifecycleOps.ts +5 -0
- package/src/hooks/useGsapAnimationOps.ts +26 -1
- package/src/hooks/useGsapScriptCommits.ts +5 -2
- package/src/hooks/useRazorSplit.ts +3 -0
- package/src/hooks/useSdkSelectionSync.ts +25 -0
- package/src/hooks/useSdkSession.test.ts +20 -0
- package/src/hooks/useSdkSession.ts +101 -0
- package/src/hooks/useTimelineEditing.ts +23 -3
- package/src/player/components/Timeline.tsx +31 -18
- package/src/player/components/TimelineClip.tsx +3 -3
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
- package/src/player/store/playerStore.ts +22 -8
- package/src/telemetry/events.test.ts +16 -1
- package/src/telemetry/events.ts +15 -0
- package/src/utils/blockCategories.ts +2 -2
- package/src/utils/sdkShadow.test.ts +246 -0
- package/src/utils/sdkShadow.ts +404 -0
- package/src/utils/studioHelpers.test.ts +25 -1
- package/src/utils/studioHelpers.ts +54 -28
- package/dist/assets/index-B62bDCQv.css +0 -1
- package/dist/assets/index-DrwSRbsl.js +0 -252
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
5
|
+
|
|
6
|
+
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
|
7
|
+
id: "x",
|
|
8
|
+
label: "x",
|
|
9
|
+
start: 0,
|
|
10
|
+
duration: 1,
|
|
11
|
+
track: 0,
|
|
12
|
+
kind: "element",
|
|
13
|
+
tagName: "div",
|
|
14
|
+
compositionId: null,
|
|
15
|
+
parentCompositionId: null,
|
|
16
|
+
compositionSrc: null,
|
|
17
|
+
assetUrl: null,
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
|
22
|
+
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as TimelineElement;
|
|
23
|
+
|
|
24
|
+
describe("buildExpandedElements", () => {
|
|
25
|
+
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
|
26
|
+
// host s3 at absolute 16 → stats-panel.html; children live in that file.
|
|
27
|
+
const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
|
|
28
|
+
const manifest = [
|
|
29
|
+
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
|
30
|
+
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
|
31
|
+
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
|
32
|
+
];
|
|
33
|
+
const parentMap = new Map([
|
|
34
|
+
["stat-1", "s3"],
|
|
35
|
+
["stat-2", "s3"],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
39
|
+
const child = out.find((e) => e.domId === "stat-1")!;
|
|
40
|
+
expect(child.expandedParentStart).toBe(16);
|
|
41
|
+
expect(child.sourceFile).toBe("stats.html");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
|
45
|
+
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
|
46
|
+
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
|
47
|
+
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
|
48
|
+
const manifest = [
|
|
49
|
+
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
50
|
+
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
51
|
+
clip({ id: "C", start: 13, duration: 2 }),
|
|
52
|
+
clip({ id: "C2", start: 14, duration: 1 }),
|
|
53
|
+
];
|
|
54
|
+
const parentMap = new Map([
|
|
55
|
+
["B", "A"],
|
|
56
|
+
["C", "B"],
|
|
57
|
+
["C2", "B"],
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Expanding C's siblings: topLevel A, immediate parent B.
|
|
61
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
62
|
+
const child = out.find((e) => e.domId === "C")!;
|
|
63
|
+
expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
|
|
64
|
+
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
|
68
|
+
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
|
69
|
+
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
|
70
|
+
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
|
71
|
+
const manifest = [
|
|
72
|
+
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
73
|
+
clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
|
|
74
|
+
clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
|
|
75
|
+
clip({ id: "D", start: 13.5, duration: 1 }),
|
|
76
|
+
clip({ id: "D2", start: 14, duration: 1 }),
|
|
77
|
+
];
|
|
78
|
+
const parentMap = new Map([
|
|
79
|
+
["B", "A"],
|
|
80
|
+
["C", "B"],
|
|
81
|
+
["D", "C"],
|
|
82
|
+
["D2", "C"],
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Expanding D's siblings: topLevel A, immediate parent C.
|
|
86
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
|
|
87
|
+
const child = out.find((e) => e.domId === "D")!;
|
|
88
|
+
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
|
89
|
+
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
|
+
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
|
+
|
|
6
|
+
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
|
7
|
+
let current = parentMap.get(id);
|
|
8
|
+
if (!current) return null;
|
|
9
|
+
const visited = new Set<string>();
|
|
10
|
+
visited.add(id);
|
|
11
|
+
while (parentMap.has(current)) {
|
|
12
|
+
if (visited.has(current)) return current;
|
|
13
|
+
visited.add(current);
|
|
14
|
+
current = parentMap.get(current)!;
|
|
15
|
+
}
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractDomId(key: string): string {
|
|
20
|
+
const hashIdx = key.lastIndexOf("#");
|
|
21
|
+
return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveRawId(
|
|
25
|
+
selectedId: string | null,
|
|
26
|
+
manifest: ClipManifestClip[],
|
|
27
|
+
parentMap: Map<string, string>,
|
|
28
|
+
): string | null {
|
|
29
|
+
if (!selectedId) return null;
|
|
30
|
+
const rawId = extractDomId(selectedId);
|
|
31
|
+
if (parentMap.has(rawId)) return rawId;
|
|
32
|
+
if (parentMap.has(selectedId)) return selectedId;
|
|
33
|
+
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
|
|
34
|
+
if (clip?.id && parentMap.has(clip.id)) return clip.id;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function filterToTopLevel(
|
|
39
|
+
elements: TimelineElement[],
|
|
40
|
+
parentMap: Map<string, string>,
|
|
41
|
+
): TimelineElement[] {
|
|
42
|
+
if (parentMap.size === 0) return elements;
|
|
43
|
+
return elements.filter((el) => !parentMap.has(el.domId ?? el.id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clampChildToParent(
|
|
47
|
+
child: ClipManifestClip,
|
|
48
|
+
parentStart: number,
|
|
49
|
+
parentEnd: number,
|
|
50
|
+
): { start: number; duration: number } | null {
|
|
51
|
+
const childEnd = child.start + child.duration;
|
|
52
|
+
if (child.start >= parentEnd || childEnd <= parentStart) return null;
|
|
53
|
+
const clampedStart = Math.max(child.start, parentStart);
|
|
54
|
+
const clampedDuration = Math.min(childEnd, parentEnd) - clampedStart;
|
|
55
|
+
return clampedDuration > 0 ? { start: clampedStart, duration: clampedDuration } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DisplayBounds {
|
|
59
|
+
start: number;
|
|
60
|
+
end: number;
|
|
61
|
+
track: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// `display` bounds come from the top-level scene clip (where the expanded row is
|
|
65
|
+
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
|
|
66
|
+
// start anchors local-time edits and its compositionSrc is the file edits write to.
|
|
67
|
+
// They differ only for sub-comp-inside-sub-comp nesting.
|
|
68
|
+
function buildChildElements(
|
|
69
|
+
siblings: ClipManifestClip[],
|
|
70
|
+
display: DisplayBounds,
|
|
71
|
+
editBasis: { start: number; sourceFile: string | undefined },
|
|
72
|
+
): TimelineElement[] {
|
|
73
|
+
const result: TimelineElement[] = [];
|
|
74
|
+
for (const child of siblings) {
|
|
75
|
+
const clamped = clampChildToParent(child, display.start, display.end);
|
|
76
|
+
if (!clamped) continue;
|
|
77
|
+
const base = createTimelineElementFromManifestClip({
|
|
78
|
+
clip: child,
|
|
79
|
+
fallbackIndex: result.length,
|
|
80
|
+
});
|
|
81
|
+
result.push({
|
|
82
|
+
...base,
|
|
83
|
+
start: clamped.start,
|
|
84
|
+
duration: clamped.duration,
|
|
85
|
+
track: display.track + result.length,
|
|
86
|
+
expandedParentStart: editBasis.start,
|
|
87
|
+
domId: child.id ?? undefined,
|
|
88
|
+
selector: child.id ? `#${child.id}` : undefined,
|
|
89
|
+
sourceFile: editBasis.sourceFile,
|
|
90
|
+
timingSource: "authored" as const,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Exported for tests.
|
|
97
|
+
export function buildExpandedElements(
|
|
98
|
+
elements: TimelineElement[],
|
|
99
|
+
manifest: ClipManifestClip[],
|
|
100
|
+
parentMap: Map<string, string>,
|
|
101
|
+
topLevelId: string,
|
|
102
|
+
siblingParentId: string,
|
|
103
|
+
): TimelineElement[] {
|
|
104
|
+
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
|
105
|
+
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
|
106
|
+
|
|
107
|
+
const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId);
|
|
108
|
+
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
|
109
|
+
|
|
110
|
+
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
111
|
+
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
|
112
|
+
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
|
113
|
+
const editBasis = {
|
|
114
|
+
start: parentHost?.start ?? topLevelElement.start,
|
|
115
|
+
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
|
119
|
+
const expanded = buildChildElements(
|
|
120
|
+
siblings,
|
|
121
|
+
{
|
|
122
|
+
start: topLevelElement.start,
|
|
123
|
+
end: topLevelElement.start + topLevelElement.duration,
|
|
124
|
+
track: topLevelElement.track,
|
|
125
|
+
},
|
|
126
|
+
editBasis,
|
|
127
|
+
);
|
|
128
|
+
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
129
|
+
|
|
130
|
+
return elements
|
|
131
|
+
.filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
|
|
132
|
+
.flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function useExpandedTimelineElements(): TimelineElement[] {
|
|
136
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
137
|
+
const clipManifest = usePlayerStore((s) => s.clipManifest);
|
|
138
|
+
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
|
139
|
+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
140
|
+
|
|
141
|
+
return useMemo(() => {
|
|
142
|
+
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
|
143
|
+
return elements;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const rawId = resolveRawId(selectedElementId, clipManifest, clipParentMap);
|
|
147
|
+
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
|
148
|
+
|
|
149
|
+
const immediateParent = clipParentMap.get(rawId)!;
|
|
150
|
+
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
|
151
|
+
return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent);
|
|
152
|
+
}, [elements, clipManifest, clipParentMap, selectedElementId]);
|
|
153
|
+
}
|
|
@@ -66,6 +66,8 @@ export function useTimelineSyncCallbacks({
|
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
usePlayerStore.getState().setClipManifest(data.clips);
|
|
70
|
+
|
|
69
71
|
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
|
70
72
|
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
|
71
73
|
const filtered = data.clips.filter(
|
|
@@ -77,6 +79,26 @@ export function useTimelineSyncCallbacks({
|
|
|
77
79
|
} catch {
|
|
78
80
|
iframeDoc = null;
|
|
79
81
|
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const iframeWin = iframeRef.current?.contentWindow as
|
|
85
|
+
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
|
|
86
|
+
| null;
|
|
87
|
+
const clipTree = iframeWin?.__clipTree;
|
|
88
|
+
if (clipTree) {
|
|
89
|
+
const parentMap = new Map<string, string>();
|
|
90
|
+
const walk = (nodes: typeof clipTree.roots) => {
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
|
|
93
|
+
if (node.children.length > 0) walk(node.children);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(clipTree.roots);
|
|
97
|
+
usePlayerStore.getState().setClipParentMap(parentMap);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// cross-origin or __clipTree not available — parentMap stays empty
|
|
101
|
+
}
|
|
80
102
|
const usedHostEls = new Set<Element>();
|
|
81
103
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
82
104
|
const hostEl = iframeDoc
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
3
3
|
import type { BeatEditState } from "../../utils/beatEditing";
|
|
4
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
5
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
5
6
|
|
|
6
7
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
|
@@ -50,6 +51,13 @@ export interface TimelineElement {
|
|
|
50
51
|
timelineLocked?: boolean;
|
|
51
52
|
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
|
52
53
|
timelineRole?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
|
56
|
+
* child: the absolute master-timeline start of the sub-comp host the child
|
|
57
|
+
* lives in. Presence marks the element as expanded; edits subtract it to get
|
|
58
|
+
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
|
59
|
+
*/
|
|
60
|
+
expandedParentStart?: number;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export type ZoomMode = "fit" | "manual";
|
|
@@ -138,21 +146,22 @@ interface PlayerState {
|
|
|
138
146
|
/** Undo/redo stacks for beat edits (in-memory, session-only). */
|
|
139
147
|
beatUndo: BeatHistoryEntry[];
|
|
140
148
|
beatRedo: BeatHistoryEntry[];
|
|
141
|
-
/** Apply a beat edit and record it for undo. */
|
|
142
149
|
commitBeatEdits: (next: BeatEditState | null, label: string) => void;
|
|
143
|
-
/** Undo/redo the most recent beat edit; returns its label or null if none. */
|
|
144
150
|
undoBeatEdits: () => string | null;
|
|
145
151
|
redoBeatEdits: () => string | null;
|
|
146
|
-
/** Clear beat edit history (e.g. when the music track changes). */
|
|
147
152
|
resetBeatHistory: () => void;
|
|
148
|
-
/** Callback that persists current beats to disk; registered by the analysis hook. */
|
|
149
153
|
beatPersist: (() => void) | null;
|
|
150
154
|
setBeatPersist: (fn: (() => void) | null) => void;
|
|
155
|
+
|
|
156
|
+
clipManifest: ClipManifestClip[] | null;
|
|
157
|
+
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
|
158
|
+
clipParentMap: Map<string, string>;
|
|
159
|
+
setClipParentMap: (map: Map<string, string>) => void;
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
interface BeatHistoryEntry {
|
|
154
|
-
restore: BeatEditState | null;
|
|
155
|
-
at: number;
|
|
163
|
+
restore: BeatEditState | null;
|
|
164
|
+
at: number;
|
|
156
165
|
label: string;
|
|
157
166
|
}
|
|
158
167
|
|
|
@@ -271,6 +280,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
271
280
|
return entry.label;
|
|
272
281
|
},
|
|
273
282
|
|
|
283
|
+
clipManifest: null,
|
|
284
|
+
setClipManifest: (clips) => set({ clipManifest: clips }),
|
|
285
|
+
clipParentMap: new Map(),
|
|
286
|
+
setClipParentMap: (map) => set({ clipParentMap: map }),
|
|
287
|
+
|
|
274
288
|
setIsPlaying: (playing) => {
|
|
275
289
|
if (get().isPlaying === playing) return;
|
|
276
290
|
set({ isPlaying: playing });
|
|
@@ -338,12 +352,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
338
352
|
selectedKeyframes: new Set(),
|
|
339
353
|
selectedElementIds: new Set(),
|
|
340
354
|
keyframeCache: new Map(),
|
|
341
|
-
// Beat state is project-specific — clear it so a project switch can't
|
|
342
|
-
// apply the previous project's beats/undo/persist to the new one.
|
|
343
355
|
beatAnalysis: null,
|
|
344
356
|
beatEdits: null,
|
|
345
357
|
beatUndo: [],
|
|
346
358
|
beatRedo: [],
|
|
347
359
|
beatPersist: null,
|
|
360
|
+
clipManifest: null,
|
|
361
|
+
clipParentMap: new Map(),
|
|
348
362
|
}),
|
|
349
363
|
}));
|
|
@@ -7,7 +7,12 @@ vi.mock("./client", () => ({
|
|
|
7
7
|
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
trackStudioSessionStart,
|
|
12
|
+
trackStudioRenderStart,
|
|
13
|
+
trackStudioRazorSplit,
|
|
14
|
+
trackStudioExpandedClipEdit,
|
|
15
|
+
} = await import("./events");
|
|
11
16
|
|
|
12
17
|
describe("studio telemetry events", () => {
|
|
13
18
|
beforeEach(() => {
|
|
@@ -54,4 +59,14 @@ describe("studio telemetry events", () => {
|
|
|
54
59
|
composition: undefined,
|
|
55
60
|
});
|
|
56
61
|
});
|
|
62
|
+
|
|
63
|
+
it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => {
|
|
64
|
+
trackStudioRazorSplit({ mode: "all", count: 3 });
|
|
65
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("trackStudioExpandedClipEdit emits 'studio_expanded_clip_edit' with action", () => {
|
|
69
|
+
trackStudioExpandedClipEdit({ action: "resize" });
|
|
70
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
|
71
|
+
});
|
|
57
72
|
});
|
package/src/telemetry/events.ts
CHANGED
|
@@ -48,6 +48,21 @@ function getBrowserDoctorSummary(): string {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: number }): void {
|
|
52
|
+
trackEvent("studio_razor_split", {
|
|
53
|
+
mode: props.mode,
|
|
54
|
+
count: props.count,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Adoption signal for the inline timeline-expansion surface: edits applied to a
|
|
59
|
+
// sub-composition child clip while its parent scene is expanded.
|
|
60
|
+
export function trackStudioExpandedClipEdit(props: {
|
|
61
|
+
action: "move" | "resize" | "delete" | "split";
|
|
62
|
+
}): void {
|
|
63
|
+
trackEvent("studio_expanded_clip_edit", { action: props.action });
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
|
52
67
|
trackEvent("survey sent", {
|
|
53
68
|
$survey_id: "studio_experience",
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BlockCategory,
|
|
3
|
-
type BlockCategoryMeta,
|
|
4
3
|
BLOCK_CATEGORIES,
|
|
5
4
|
resolveBlockCategory,
|
|
6
5
|
} from "@hyperframes/core/registry";
|
|
7
6
|
|
|
8
|
-
export type { BlockCategory
|
|
7
|
+
export type { BlockCategory };
|
|
9
8
|
export { BLOCK_CATEGORIES, resolveBlockCategory };
|
|
10
9
|
|
|
11
10
|
const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
|
|
@@ -17,6 +16,7 @@ const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }
|
|
|
17
16
|
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
|
|
18
17
|
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
|
|
19
18
|
"text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
|
|
19
|
+
"code-animation": { bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export function getCategoryColors(category: BlockCategory) {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
patchOpsToSdkEditOps,
|
|
4
|
+
runShadowDelete,
|
|
5
|
+
runShadowTiming,
|
|
6
|
+
runShadowGsapTween,
|
|
7
|
+
SdkShadowMismatch,
|
|
8
|
+
} from "./sdkShadow";
|
|
9
|
+
import type { PatchOperation } from "./sourcePatcher";
|
|
10
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
11
|
+
|
|
12
|
+
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
|
|
13
|
+
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
14
|
+
vi.mock("./studioTelemetry", () => ({
|
|
15
|
+
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
16
|
+
trackedEvents.push({ event, props }),
|
|
17
|
+
}));
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
trackedEvents.length = 0;
|
|
20
|
+
});
|
|
21
|
+
const lastShadow = () =>
|
|
22
|
+
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
|
23
|
+
|
|
24
|
+
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
|
25
|
+
<html><body>
|
|
26
|
+
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
|
27
|
+
</body></html>`;
|
|
28
|
+
|
|
29
|
+
describe("patchOpsToSdkEditOps", () => {
|
|
30
|
+
it("maps inline-style ops to a single setStyle EditOp", () => {
|
|
31
|
+
const ops: PatchOperation[] = [
|
|
32
|
+
{ type: "inline-style", property: "color", value: "#00f" },
|
|
33
|
+
{ type: "inline-style", property: "opacity", value: "0.5" },
|
|
34
|
+
];
|
|
35
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
36
|
+
expect(result).toHaveLength(1);
|
|
37
|
+
expect(result[0]).toEqual({
|
|
38
|
+
type: "setStyle",
|
|
39
|
+
target: "hf-box",
|
|
40
|
+
styles: { color: "#00f", opacity: "0.5" },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maps text-content op to setText EditOp", () => {
|
|
45
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
|
|
46
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("maps attribute op to setAttribute with data- prefix", () => {
|
|
52
|
+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
|
53
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
54
|
+
expect(result).toHaveLength(1);
|
|
55
|
+
expect(result[0]).toEqual({
|
|
56
|
+
type: "setAttribute",
|
|
57
|
+
target: "hf-box",
|
|
58
|
+
name: "data-name",
|
|
59
|
+
value: "hero",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("maps html-attribute op to setAttribute without prefix", () => {
|
|
64
|
+
const ops: PatchOperation[] = [
|
|
65
|
+
{ type: "html-attribute", property: "contenteditable", value: "true" },
|
|
66
|
+
];
|
|
67
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
68
|
+
expect(result).toHaveLength(1);
|
|
69
|
+
expect(result[0]).toEqual({
|
|
70
|
+
type: "setAttribute",
|
|
71
|
+
target: "hf-box",
|
|
72
|
+
name: "contenteditable",
|
|
73
|
+
value: "true",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles null value for attribute removal", () => {
|
|
78
|
+
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
|
|
79
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
80
|
+
expect(result[0]).toEqual({
|
|
81
|
+
type: "setAttribute",
|
|
82
|
+
target: "hf-box",
|
|
83
|
+
name: "hidden",
|
|
84
|
+
value: null,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns empty array for unknown op types", () => {
|
|
89
|
+
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
|
|
90
|
+
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("sdkShadowDispatch (integration)", () => {
|
|
95
|
+
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
|
|
96
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
97
|
+
const session = await openComposition(BASE_HTML);
|
|
98
|
+
|
|
99
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
100
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
101
|
+
|
|
102
|
+
expect(result.dispatched).toBe(true);
|
|
103
|
+
expect(result.mismatches).toHaveLength(0);
|
|
104
|
+
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns dispatched:false when hfId not found in session", async () => {
|
|
108
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
109
|
+
const session = await openComposition(BASE_HTML);
|
|
110
|
+
|
|
111
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
112
|
+
const result = sdkShadowDispatch(session, "hf-missing", ops);
|
|
113
|
+
|
|
114
|
+
expect(result.dispatched).toBe(false);
|
|
115
|
+
expect(result.mismatches).toHaveLength(1);
|
|
116
|
+
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
|
117
|
+
kind: "element_not_found",
|
|
118
|
+
hfId: "hf-missing",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("applies text op and reads back via session.getElement", async () => {
|
|
123
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
124
|
+
const session = await openComposition(BASE_HTML);
|
|
125
|
+
|
|
126
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
|
|
127
|
+
sdkShadowDispatch(session, "hf-box", ops);
|
|
128
|
+
|
|
129
|
+
expect(session.getElement("hf-box")?.text).toBe("Updated");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("applies attribute op and reads back via session.getElement", async () => {
|
|
133
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
134
|
+
const session = await openComposition(BASE_HTML);
|
|
135
|
+
|
|
136
|
+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
|
137
|
+
sdkShadowDispatch(session, "hf-box", ops);
|
|
138
|
+
|
|
139
|
+
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
|
|
143
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
144
|
+
const session = await openComposition(BASE_HTML);
|
|
145
|
+
// Poison dispatch so it throws on any call
|
|
146
|
+
session.dispatch = () => {
|
|
147
|
+
throw new Error("sdk internal error");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
|
|
151
|
+
let result: ReturnType<typeof sdkShadowDispatch> | undefined;
|
|
152
|
+
expect(() => {
|
|
153
|
+
result = sdkShadowDispatch(session, "hf-box", ops);
|
|
154
|
+
}).not.toThrow();
|
|
155
|
+
|
|
156
|
+
expect(result!.dispatched).toBe(false);
|
|
157
|
+
expect(result!.mismatches).toHaveLength(1);
|
|
158
|
+
expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
|
159
|
+
kind: "dispatch_error",
|
|
160
|
+
hfId: "hf-box",
|
|
161
|
+
error: expect.stringContaining("sdk internal error"),
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const TIMING_HTML = /* html */ `<!DOCTYPE html>
|
|
167
|
+
<html><body>
|
|
168
|
+
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
|
|
169
|
+
</body></html>`;
|
|
170
|
+
|
|
171
|
+
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
|
172
|
+
<div data-hf-id="hf-box" style="opacity:0"></div>
|
|
173
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
174
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
175
|
+
window.__timelines["t"] = tl;</script>
|
|
176
|
+
</div>`;
|
|
177
|
+
|
|
178
|
+
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
|
|
179
|
+
<div data-hf-id="hf-box"></div>
|
|
180
|
+
<script>gsap.defaults({ ease: "power1.out" });
|
|
181
|
+
window.__timelines = {};</script>
|
|
182
|
+
</div>`;
|
|
183
|
+
|
|
184
|
+
describe("runShadowDelete", () => {
|
|
185
|
+
it("removes the element from the SDK session and reports parity", async () => {
|
|
186
|
+
const session = await openComposition(BASE_HTML);
|
|
187
|
+
runShadowDelete(session, "hf-box");
|
|
188
|
+
expect(session.getElement("hf-box")).toBeNull();
|
|
189
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reports no_hf_id when selection has no hf-id", async () => {
|
|
193
|
+
const session = await openComposition(BASE_HTML);
|
|
194
|
+
runShadowDelete(session, null);
|
|
195
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("reports cannot_dispatch when the element is not addressable", async () => {
|
|
199
|
+
const session = await openComposition(BASE_HTML);
|
|
200
|
+
runShadowDelete(session, "hf-missing");
|
|
201
|
+
expect(lastShadow()).toMatchObject({
|
|
202
|
+
op: "delete",
|
|
203
|
+
dispatched: false,
|
|
204
|
+
reason: "cannot_dispatch",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("runShadowTiming", () => {
|
|
210
|
+
it("applies timing and reports parity against the snapshot", async () => {
|
|
211
|
+
const session = await openComposition(TIMING_HTML);
|
|
212
|
+
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
|
|
213
|
+
const el = session.getElement("hf-clip");
|
|
214
|
+
expect(el?.start).toBe(2);
|
|
215
|
+
expect(el?.duration).toBe(3);
|
|
216
|
+
expect(el?.trackIndex).toBe(1);
|
|
217
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("runShadowGsapTween", () => {
|
|
222
|
+
it("dispatches add against a real timeline and reports success", async () => {
|
|
223
|
+
const session = await openComposition(GSAP_HTML);
|
|
224
|
+
runShadowGsapTween(session, {
|
|
225
|
+
kind: "add",
|
|
226
|
+
target: "hf-box",
|
|
227
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
228
|
+
});
|
|
229
|
+
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
|
|
233
|
+
const session = await openComposition(NO_TIMELINE_HTML);
|
|
234
|
+
runShadowGsapTween(session, {
|
|
235
|
+
kind: "add",
|
|
236
|
+
target: "hf-box",
|
|
237
|
+
tween: { method: "to", properties: { x: 100 } },
|
|
238
|
+
});
|
|
239
|
+
expect(lastShadow()).toMatchObject({
|
|
240
|
+
op: "gsap",
|
|
241
|
+
dispatched: false,
|
|
242
|
+
reason: "cannot_dispatch",
|
|
243
|
+
code: "E_NO_GSAP_TIMELINE",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|