@hyperframes/studio 0.6.100 → 0.6.102

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.
Files changed (44) hide show
  1. package/dist/assets/index-BITwbxi-.css +1 -0
  2. package/dist/assets/{index-CKWBqyRd.js → index-BZKngETE.js} +1 -1
  3. package/dist/assets/index-BzjItfjX.js +296 -0
  4. package/dist/assets/{index-gpSohHUn.js → index-C0vMHtMH.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +5 -5
  7. package/src/App.tsx +2 -1
  8. package/src/components/editor/PropertyPanel.tsx +24 -16
  9. package/src/components/editor/manualEditingAvailability.ts +5 -3
  10. package/src/components/nle/NLELayout.tsx +89 -1
  11. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
  12. package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
  13. package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
  14. package/src/hooks/gsapScriptCommitTypes.ts +6 -0
  15. package/src/hooks/gsapTargetCache.ts +65 -0
  16. package/src/hooks/useAppHotkeys.ts +10 -0
  17. package/src/hooks/useDomEditCommits.ts +6 -5
  18. package/src/hooks/useDomEditSession.ts +6 -1
  19. package/src/hooks/useDomGeometryCommits.ts +1 -36
  20. package/src/hooks/useElementLifecycleOps.ts +5 -0
  21. package/src/hooks/useGsapAnimationOps.ts +46 -9
  22. package/src/hooks/useGsapScriptCommits.ts +22 -3
  23. package/src/hooks/useGsapTweenCache.ts +10 -12
  24. package/src/hooks/useRazorSplit.ts +3 -0
  25. package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
  26. package/src/hooks/useSdkSession.ts +15 -12
  27. package/src/hooks/useTimelineEditing.ts +23 -3
  28. package/src/player/components/Timeline.tsx +31 -18
  29. package/src/player/components/TimelineClip.tsx +3 -3
  30. package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
  31. package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
  32. package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
  33. package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
  34. package/src/player/store/playerStore.ts +22 -8
  35. package/src/telemetry/events.test.ts +16 -1
  36. package/src/telemetry/events.ts +15 -0
  37. package/src/utils/blockCategories.ts +2 -2
  38. package/src/utils/sdkShadow.test.ts +232 -2
  39. package/src/utils/sdkShadow.ts +230 -2
  40. package/src/utils/sdkShadowGsapFidelity.ts +208 -0
  41. package/src/utils/studioHelpers.test.ts +25 -1
  42. package/src/utils/studioHelpers.ts +54 -28
  43. package/dist/assets/index-B62bDCQv.css +0 -1
  44. package/dist/assets/index-BkT9VKwz.js +0 -296
@@ -102,7 +102,7 @@ export const TimelineClip = memo(function TimelineClip({
102
102
  onDoubleClick={onDoubleClick}
103
103
  onContextMenu={onContextMenu}
104
104
  >
105
- {/* Left accent stripe */}
105
+ {/* Left accent stripe — wider + brighter for expanded sub-comp children */}
106
106
  <div
107
107
  aria-hidden="true"
108
108
  style={{
@@ -110,9 +110,9 @@ export const TimelineClip = memo(function TimelineClip({
110
110
  left: 0,
111
111
  top: 0,
112
112
  bottom: 0,
113
- width: 3,
113
+ width: el.expandedParentStart !== undefined ? 4 : 3,
114
114
  background: trackStyle.accent,
115
- opacity: isSelected ? 0.7 : 0.3,
115
+ opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
116
116
  borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
117
117
  zIndex: 2,
118
118
  pointerEvents: "none",
@@ -0,0 +1,30 @@
1
+ import { useMemo } from "react";
2
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
3
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
4
+
5
+ // Props a parent (e.g. NLELayout) may pass to <Timeline> to intercept edits —
6
+ // the rest of the callback bag still comes from TimelineEditContext.
7
+ export type TimelineEditOverrides = Pick<
8
+ TimelineEditCallbacks,
9
+ "onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
10
+ >;
11
+
12
+ // Merge any prop overrides over the context callbacks. Used so NLELayout can
13
+ // wrap move/resize/split (to rebase expanded sub-comp clips) while every other
14
+ // callback falls through to the context unchanged.
15
+ export function useResolvedTimelineEditCallbacks(
16
+ overrides: TimelineEditOverrides,
17
+ ): TimelineEditCallbacks {
18
+ const ctx = useTimelineEditContext();
19
+ const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
20
+ return useMemo(
21
+ () => ({
22
+ ...ctx,
23
+ onMoveElement: onMoveElement ?? ctx.onMoveElement,
24
+ onResizeElement: onResizeElement ?? ctx.onResizeElement,
25
+ onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
26
+ onSplitElement: onSplitElement ?? ctx.onSplitElement,
27
+ }),
28
+ [ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
29
+ );
30
+ }
@@ -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; // state to restore when this entry is applied
155
- at: number; // original edit timestamp (for global undo ordering)
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 { trackStudioSessionStart, trackStudioRenderStart } = await import("./events");
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
  });
@@ -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, BlockCategoryMeta };
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) {