@hyperframes/studio 0.6.106 → 0.6.108

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 (49) hide show
  1. package/dist/assets/{hyperframes-player-DgsMQSvV.js → hyperframes-player-67pq7USK.js} +2 -2
  2. package/dist/assets/index-BVqybwMG.css +1 -0
  3. package/dist/assets/index-BgbVhDYd.js +296 -0
  4. package/dist/assets/{index-CBS_7p5W.js → index-Btg-jqxS.js} +1 -1
  5. package/dist/assets/{index-CRCE5Dz_.js → index-tn9M43-U.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +5 -5
  8. package/src/App.tsx +1 -0
  9. package/src/components/StudioRightPanel.tsx +161 -75
  10. package/src/components/editor/PropertyPanel.tsx +26 -1
  11. package/src/components/editor/domEditOverlayGeometry.ts +2 -11
  12. package/src/components/editor/domEditingDom.ts +21 -0
  13. package/src/components/editor/domEditingElement.ts +2 -11
  14. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  15. package/src/components/editor/manualEditingAvailability.ts +6 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +475 -0
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +355 -0
  18. package/src/components/editor/propertyPanelHelpers.ts +2 -1
  19. package/src/components/editor/propertyPanelPrimitives.tsx +32 -37
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/contexts/PanelLayoutContext.tsx +6 -0
  22. package/src/hooks/gsapScriptCommitTypes.ts +11 -0
  23. package/src/hooks/serializeByKey.test.ts +83 -0
  24. package/src/hooks/serializeByKey.ts +33 -0
  25. package/src/hooks/useDomEditCommits.ts +7 -0
  26. package/src/hooks/useDomEditSession.ts +2 -2
  27. package/src/hooks/useDomEditTextCommits.ts +82 -26
  28. package/src/hooks/useDomEditWiring.ts +1 -0
  29. package/src/hooks/useDomSelection.ts +2 -10
  30. package/src/hooks/useGsapAnimationOps.ts +5 -1
  31. package/src/hooks/useGsapKeyframeOps.ts +21 -1
  32. package/src/hooks/useGsapScriptCommits.ts +33 -5
  33. package/src/hooks/usePanelLayout.ts +26 -1
  34. package/src/hooks/useStudioContextValue.ts +8 -3
  35. package/src/icons/SystemIcons.tsx +4 -0
  36. package/src/player/lib/timelineDOM.test.ts +36 -0
  37. package/src/player/lib/timelineDOM.ts +2 -0
  38. package/src/player/lib/timelineElementHelpers.ts +18 -1
  39. package/src/styles/studio.css +81 -0
  40. package/src/utils/mediaTypes.ts +1 -0
  41. package/src/utils/sdkShadow.test.ts +187 -1
  42. package/src/utils/sdkShadow.ts +67 -10
  43. package/src/utils/sdkShadowGsapFidelity.ts +46 -18
  44. package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
  45. package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
  46. package/src/utils/sdkShadowNumeric.ts +11 -0
  47. package/src/utils/studioHelpers.ts +6 -0
  48. package/dist/assets/index-BrhJl2JY.css +0 -1
  49. package/dist/assets/index-koqvg-_0.js +0 -296
@@ -203,6 +203,7 @@ export function useDomEditSession({
203
203
  resolveImportedFontAsset,
204
204
  handleDomStyleCommit,
205
205
  handleDomAttributeCommit,
206
+ handleDomAttributeLiveCommit,
206
207
  handleDomHtmlAttributeCommit,
207
208
  handleDomTextCommit,
208
209
  handleDomTextFieldStyleCommit,
@@ -265,8 +266,6 @@ export function useDomEditSession({
265
266
  handleGsapRemoveAllKeyframes,
266
267
  handleResetSelectedElementKeyframes,
267
268
  } = useDomEditWiring({
268
- // Pre-existing prop-drilling clone (same param set forwarded to
269
- // useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
270
269
  // fallow-ignore-next-line code-duplication
271
270
  projectId,
272
271
  activeCompPath,
@@ -374,6 +373,7 @@ export function useDomEditSession({
374
373
  clearDomSelection,
375
374
  handleDomStyleCommit,
376
375
  handleDomAttributeCommit,
376
+ handleDomAttributeLiveCommit,
377
377
  handleDomHtmlAttributeCommit,
378
378
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
379
379
  handleDomGroupPathOffsetCommit,
@@ -43,6 +43,33 @@ export interface UseDomEditTextCommitsParams {
43
43
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
44
44
  }
45
45
 
46
+ function applyPreviewAttribute(
47
+ doc: Document | null | undefined,
48
+ selection: DomEditSelection,
49
+ activeCompPath: string | null,
50
+ attr: string,
51
+ value: string | null,
52
+ options: { prefixData?: boolean; removeFalse?: boolean } = {},
53
+ ): void {
54
+ if (!doc) return;
55
+ const el = findElementForSelection(doc, selection, activeCompPath);
56
+ if (!el) return;
57
+ const fullAttr = options.prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
58
+ if (value === null || value === "" || (options.removeFalse && value === "false")) {
59
+ el.removeAttribute(fullAttr);
60
+ } else {
61
+ el.setAttribute(fullAttr, value);
62
+ }
63
+ }
64
+
65
+ interface DataAttributeCommitOptions {
66
+ label: string;
67
+ coalescePrefix: string;
68
+ skipRefresh: boolean;
69
+ warningMessage: string;
70
+ refreshAfter?: boolean;
71
+ }
72
+
46
73
  // ── Hook ──
47
74
 
48
75
  export function useDomEditTextCommits({
@@ -114,29 +141,33 @@ export function useDomEditTextCommits({
114
141
  ],
115
142
  );
116
143
 
117
- const handleDomAttributeCommit = useCallback(
118
- async (attr: string, value: string) => {
144
+ const commitDataAttribute = useCallback(
145
+ async (attr: string, value: string | null, options: DataAttributeCommitOptions) => {
119
146
  if (!domEditSelection) return;
120
147
  const iframe = previewIframeRef.current;
121
- const doc = iframe?.contentDocument;
122
- if (doc) {
123
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
124
- if (el) el.setAttribute(`data-${attr}`, value);
125
- }
148
+ applyPreviewAttribute(
149
+ iframe?.contentDocument,
150
+ domEditSelection,
151
+ activeCompPath,
152
+ attr,
153
+ value,
154
+ {
155
+ prefixData: true,
156
+ },
157
+ );
126
158
  const op: PatchOperation = { type: "attribute", property: attr, value };
127
159
  try {
128
160
  await persistDomEditOperations(domEditSelection, [op], {
129
- label: `Edit ${attr.replace(/-/g, " ")}`,
130
- coalesceKey: `attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
131
- skipRefresh: false,
161
+ label: options.label,
162
+ coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
163
+ skipRefresh: options.skipRefresh,
132
164
  });
133
165
  } catch (err) {
134
- console.warn(
135
- "[Studio] Attribute persist failed:",
136
- err instanceof Error ? err.message : err,
137
- );
166
+ console.warn(options.warningMessage, err instanceof Error ? err.message : err);
167
+ }
168
+ if (options.refreshAfter) {
169
+ refreshDomEditSelectionFromPreview(domEditSelection);
138
170
  }
139
- refreshDomEditSelectionFromPreview(domEditSelection);
140
171
  },
141
172
  [
142
173
  activeCompPath,
@@ -147,21 +178,45 @@ export function useDomEditTextCommits({
147
178
  ],
148
179
  );
149
180
 
181
+ const handleDomAttributeCommit = useCallback(
182
+ async (attr: string, value: string) => {
183
+ await commitDataAttribute(attr, value, {
184
+ label: `Edit ${attr.replace(/-/g, " ")}`,
185
+ coalescePrefix: "attr",
186
+ skipRefresh: false,
187
+ warningMessage: "[Studio] Attribute persist failed:",
188
+ refreshAfter: true,
189
+ });
190
+ },
191
+ [commitDataAttribute],
192
+ );
193
+
194
+ const handleDomAttributeLiveCommit = useCallback(
195
+ async (attr: string, value: string | null) => {
196
+ await commitDataAttribute(attr, value, {
197
+ label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
198
+ coalescePrefix: "attr-live",
199
+ skipRefresh: true,
200
+ warningMessage: "[Studio] Live attribute persist failed:",
201
+ });
202
+ },
203
+ [commitDataAttribute],
204
+ );
205
+
150
206
  const handleDomHtmlAttributeCommit = useCallback(
151
207
  async (attr: string, value: string | null) => {
152
208
  if (!domEditSelection) return;
153
209
  const iframe = previewIframeRef.current;
154
- const doc = iframe?.contentDocument;
155
- if (doc) {
156
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
157
- if (el) {
158
- if (value === null || value === "" || value === "false") {
159
- el.removeAttribute(attr);
160
- } else {
161
- el.setAttribute(attr, value);
162
- }
163
- }
164
- }
210
+ applyPreviewAttribute(
211
+ iframe?.contentDocument,
212
+ domEditSelection,
213
+ activeCompPath,
214
+ attr,
215
+ value,
216
+ {
217
+ removeFalse: true,
218
+ },
219
+ );
165
220
  const op: PatchOperation = { type: "html-attribute", property: attr, value };
166
221
  try {
167
222
  await persistDomEditOperations(domEditSelection, [op], {
@@ -395,6 +450,7 @@ export function useDomEditTextCommits({
395
450
  return {
396
451
  handleDomStyleCommit,
397
452
  handleDomAttributeCommit,
453
+ handleDomAttributeLiveCommit,
398
454
  handleDomHtmlAttributeCommit,
399
455
  handleDomTextCommit,
400
456
  commitDomTextFields,
@@ -98,6 +98,7 @@ export interface UseDomEditWiringParams {
98
98
 
99
99
  // fallow-ignore-next-line complexity
100
100
  export function useDomEditWiring({
101
+ // fallow-ignore-next-line code-duplication
101
102
  projectId,
102
103
  activeCompPath,
103
104
  domEditSelection,
@@ -176,9 +176,7 @@ export function useDomSelection({
176
176
  if (nextSelection) {
177
177
  if (options?.revealPanel !== false) {
178
178
  setRightCollapsed(false);
179
- if (rightPanelTab !== "layers") {
180
- setRightPanelTab("design");
181
- }
179
+ setRightPanelTab("design");
182
180
  }
183
181
  const nextSelectedTimelineId = findMatchingTimelineElementId(
184
182
  nextSelection,
@@ -190,13 +188,7 @@ export function useDomSelection({
190
188
 
191
189
  setSelectedTimelineElementId(null);
192
190
  },
193
- [
194
- setSelectedTimelineElementId,
195
- timelineElements,
196
- setRightCollapsed,
197
- setRightPanelTab,
198
- rightPanelTab,
199
- ],
191
+ [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
200
192
  );
201
193
 
202
194
  const clearDomSelection = useCallback(() => {
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
40
40
  animationId,
41
41
  properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
42
42
  };
43
+ // coalesceKey groups rapid meta edits into one history entry. Request
44
+ // serialization is now handled per-file at the commitMutation chokepoint
45
+ // (useGsapScriptCommits), so no per-op serializeKey is needed here.
46
+ const metaKey = `gsap:${animationId}:meta`;
43
47
  commitMutationSafely(
44
48
  selection,
45
49
  { type: "update-meta", animationId, updates },
46
- { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
50
+ { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
47
51
  );
48
52
  if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
49
53
  },
@@ -1,5 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { executeOptimistic } from "../utils/optimisticUpdate";
5
6
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
58
59
  percentage,
59
60
  properties: { [property]: value },
60
61
  };
62
+ // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
63
+ const shadowKeyframeOp: ShadowKeyframeOp = {
64
+ kind: "add",
65
+ animationId,
66
+ percentage,
67
+ properties: { [property]: value },
68
+ };
61
69
  void executeOptimisticKeyframeCacheUpdate({
62
70
  sourceFile,
63
71
  elementId: selection.id,
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
71
79
  commitMutation(selection, mutation, {
72
80
  label: `Add keyframe at ${percentage}%`,
73
81
  softReload: true,
82
+ shadowKeyframeOp,
74
83
  }),
75
84
  }).catch((error) => {
76
85
  trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
86
95
  percentage: number,
87
96
  properties: Record<string, number | string>,
88
97
  ) => {
98
+ const shadowKeyframeOp: ShadowKeyframeOp = {
99
+ kind: "add",
100
+ animationId,
101
+ percentage,
102
+ properties,
103
+ };
89
104
  return commitMutation(
90
105
  selection,
91
106
  { type: "add-keyframe", animationId, percentage, properties },
92
- { label: `Add keyframe at ${percentage}%`, softReload: true },
107
+ { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
93
108
  );
94
109
  },
95
110
  [commitMutation],
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
99
114
  (selection: DomEditSelection, animationId: string, percentage: number) => {
100
115
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
101
116
  const mutation = { type: "remove-keyframe", animationId, percentage };
117
+ // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
118
+ // so the runner resolves percentage → keyframeIndex against the pre-op
119
+ // script and no-ops on ambiguity (duplicate-percentage keyframes).
120
+ const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
102
121
  void executeOptimisticKeyframeCacheUpdate({
103
122
  sourceFile,
104
123
  elementId: selection.id,
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
112
131
  commitMutation(selection, mutation, {
113
132
  label: `Remove keyframe at ${percentage}%`,
114
133
  softReload: true,
134
+ shadowKeyframeOp,
115
135
  }),
116
136
  }).catch((error) => {
117
137
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
@@ -1,9 +1,11 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { applySoftReload } from "../utils/gsapSoftReload";
5
5
  import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
6
+ import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
6
7
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
8
+ import { createKeyedSerializer } from "./serializeByKey";
7
9
  import {
8
10
  GsapMutationHttpError,
9
11
  formatGsapMutationRejectionToast,
@@ -45,10 +47,16 @@ async function mutateGsapScript(
45
47
  // oxfmt-ignore
46
48
  // fallow-ignore-next-line complexity
47
49
  export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
50
+ // Serializer for per-key commits (options.serializeKey). Keyed by
51
+ // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
52
+ // the same animationId so their POSTs can't interleave — which is what made
53
+ // the shadow fidelity diff pair an op with a stale server result and report
54
+ // false ease mismatches. Held in a ref so the chain survives re-renders.
55
+ const serializerRef = useRef(createKeyedSerializer());
48
56
  // Pre-existing complexity (server mutate + history + reload branches); this PR
49
57
  // adds only a guarded shadow-fidelity dispatch.
50
58
  // fallow-ignore-next-line complexity
51
- const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
59
+ const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
52
60
  const pid = projectIdRef.current;
53
61
  if (!pid) return;
54
62
  const unsafeFields = findUnsafeMutationValues(mutation);
@@ -70,9 +78,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
70
78
  domEditSaveTimestampRef.current = Date.now();
71
79
  // Shadow value fidelity: diff the SDK's GSAP writer output against the
72
80
  // server's, from the same pre-op file. Fire-and-forget; server authoritative.
73
- // Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via
74
- // useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce,
75
- // useGsapKeyframeOps) intentionally don't synthesize one yet deferred follow-up.
81
+ // Meta-level ops carry shadowGsapOp (add / update-meta / delete via
82
+ // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
83
+ // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
84
+ // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
76
85
  // scriptText is null when the composition has no GSAP script; nothing to diff.
77
86
  const fidelityArgs = resolveGsapFidelityArgs(
78
87
  sdkSession,
@@ -83,6 +92,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
83
92
  if (fidelityArgs) {
84
93
  void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
85
94
  }
95
+ // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
96
+ // the SDK has no keyframe reader so there is no live-existence path — the diff
97
+ // is the only signal. Guarded on a live session + both scripts to diff.
98
+ if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
99
+ void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
100
+ }
86
101
  if (result.before != null && result.after != null) {
87
102
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
88
103
  }
@@ -97,6 +112,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
97
112
  }
98
113
  onCacheInvalidate();
99
114
  }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
115
+ // Every GSAP-script commit is a read-modify-write of one file. Overlapping
116
+ // commits to the SAME file (any op type, any animation) interleave server-side
117
+ // and make the shadow fidelity diff pair an op with a stale server result —
118
+ // the false ease/value mismatches this serializer exists to prevent. So
119
+ // serialize per target file by default; an explicit serializeKey overrides.
120
+ const commitMutation = useCallback(
121
+ (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
122
+ const file = selection.sourceFile || activeCompPath || "index.html";
123
+ const key = options.serializeKey ?? `gsap-file:${file}`;
124
+ return serializerRef.current(key, () => runCommit(selection, mutation, options));
125
+ },
126
+ [runCommit, activeCompPath],
127
+ );
100
128
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
101
129
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
102
130
  const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
@@ -1,5 +1,9 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
- import type { RightPanelTab } from "../utils/studioHelpers";
2
+ import type {
3
+ RightInspectorPane,
4
+ RightInspectorPanes,
5
+ RightPanelTab,
6
+ } from "../utils/studioHelpers";
3
7
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
4
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
5
9
 
@@ -8,6 +12,11 @@ export interface InitialPanelLayoutState {
8
12
  rightPanelTab?: RightPanelTab | null;
9
13
  }
10
14
 
15
+ function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
16
+ if (tab === "layers") return { layers: true, design: false };
17
+ return { layers: false, design: true };
18
+ }
19
+
11
20
  export function usePanelLayout(initialState?: InitialPanelLayoutState) {
12
21
  const [leftWidth, setLeftWidth] = useState(240);
13
22
  const [rightWidth, setRightWidth] = useState(400);
@@ -18,6 +27,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
18
27
  const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
19
28
  initialState?.rightPanelTab ?? "renders",
20
29
  );
30
+ const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
31
+ getInitialRightInspectorPanes(initialState?.rightPanelTab),
32
+ );
21
33
  const panelDragRef = useRef<{
22
34
  side: "left" | "right";
23
35
  startX: number;
@@ -67,12 +79,23 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
67
79
 
68
80
  const trackedSetRightPanelTab = useCallback(
69
81
  (tab: RightPanelTab) => {
82
+ if (tab === "design" || tab === "layers") {
83
+ setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
84
+ }
70
85
  setRightPanelTab(tab);
71
86
  trackStudioEvent("tab_switch", { panel: "right_panel", tab });
72
87
  },
73
88
  [setRightPanelTab],
74
89
  );
75
90
 
91
+ const toggleRightInspectorPane = useCallback((pane: RightInspectorPane) => {
92
+ setRightInspectorPanes((panes) => {
93
+ const next = { ...panes, [pane]: !panes[pane] };
94
+ if (!next.design && !next.layers) return panes;
95
+ return next;
96
+ });
97
+ }, []);
98
+
76
99
  return {
77
100
  leftWidth,
78
101
  setLeftWidth,
@@ -83,6 +106,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
83
106
  setRightCollapsed,
84
107
  rightPanelTab,
85
108
  setRightPanelTab: trackedSetRightPanelTab,
109
+ rightInspectorPanes,
110
+ toggleRightInspectorPane,
86
111
  toggleLeftSidebar,
87
112
  handlePanelResizeStart,
88
113
  handlePanelResizeMove,
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
2
2
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
+ import type { RightInspectorPanes } from "../utils/studioHelpers";
4
5
 
5
6
  interface StudioContextInput {
6
7
  projectId: string;
@@ -70,14 +71,18 @@ export interface InspectorState {
70
71
 
71
72
  export function useInspectorState(
72
73
  rightPanelTab: string,
74
+ rightInspectorPanes: RightInspectorPanes,
73
75
  rightCollapsed: boolean,
74
76
  isPlaying: boolean,
75
77
  isGestureRecording?: boolean,
76
78
  ): InspectorState {
77
79
  // fallow-ignore-next-line complexity
78
80
  return useMemo(() => {
79
- const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "layers";
80
- const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
81
+ const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
82
+ const layersPanelActive =
83
+ STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.layers;
84
+ const designPanelActive =
85
+ STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.design;
81
86
  const inspectorPanelActive = layersPanelActive || designPanelActive;
82
87
  return {
83
88
  layersPanelActive,
@@ -88,7 +93,7 @@ export function useInspectorState(
88
93
  shouldShowSelectedDomBounds:
89
94
  inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
90
95
  };
91
- }, [rightPanelTab, rightCollapsed, isPlaying, isGestureRecording]);
96
+ }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
92
97
  }
93
98
 
94
99
  // fallow-ignore-next-line complexity
@@ -8,8 +8,10 @@ import {
8
8
  ArrowsOutCardinal,
9
9
  MusicNote,
10
10
  Palette as PhPalette,
11
+ Minus as PhMinus,
11
12
  Plus as PhPlus,
12
13
  Square as PhSquare,
14
+ SquareSplitVertical as PhSquareSplitVertical,
13
15
  TextT,
14
16
  X as PhX,
15
17
  Lightning,
@@ -43,8 +45,10 @@ export const MessageSquare = makeIcon(ChatCenteredText);
43
45
  export const Move = makeIcon(ArrowsOutCardinal);
44
46
  export const Music = makeIcon(MusicNote);
45
47
  export const Palette = makeIcon(PhPalette);
48
+ export const Minus = makeIcon(PhMinus);
46
49
  export const Plus = makeIcon(PhPlus);
47
50
  export const Square = makeIcon(PhSquare);
51
+ export const Compare = makeIcon(PhSquareSplitVertical);
48
52
  export const Type = makeIcon(TextT);
49
53
  export const X = makeIcon(PhX);
50
54
  export const Zap = makeIcon(Lightning);
@@ -36,6 +36,25 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
36
36
  expect(plain).toBeDefined();
37
37
  expect(plain?.hfId).toBeUndefined();
38
38
  });
39
+
40
+ it("ignores runtime-owned color grading canvases with timing attributes", () => {
41
+ const doc = makeDoc(`
42
+ <div data-composition-id="root">
43
+ <img id="photo" class="clip" data-start="0" data-duration="5" />
44
+ <canvas
45
+ class="__hf_color_grading_canvas__"
46
+ data-hf-color-grading-canvas="true"
47
+ data-hyperframes-ignore
48
+ data-start="0"
49
+ data-duration="5"
50
+ ></canvas>
51
+ </div>
52
+ `);
53
+
54
+ const elements = parseTimelineFromDOM(doc, 10);
55
+
56
+ expect(elements.map((el) => el.tag)).toEqual(["img"]);
57
+ });
39
58
  });
40
59
 
41
60
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
@@ -52,4 +71,21 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
52
71
  expect(layer).toBeDefined();
53
72
  expect(layer?.hfId).toBe("hf-xyz789");
54
73
  });
74
+
75
+ it("ignores runtime-owned color grading canvases as implicit layers", () => {
76
+ const doc = makeDoc(`
77
+ <div data-composition-id="root" data-duration="5">
78
+ <img id="photo" class="clip" data-start="0" data-duration="5" />
79
+ <canvas
80
+ class="__hf_color_grading_canvas__"
81
+ data-hf-color-grading-canvas="true"
82
+ data-hyperframes-ignore
83
+ ></canvas>
84
+ </div>
85
+ `);
86
+
87
+ const layers = createImplicitTimelineLayersFromDOM(doc, 5);
88
+
89
+ expect(layers).toEqual([]);
90
+ });
55
91
  });
@@ -22,6 +22,7 @@ import {
22
22
  buildTimelineElementKey,
23
23
  buildTimelineElementIdentity,
24
24
  getTimelineElementIdentity,
25
+ isTimelineIgnoredElement,
25
26
  } from "./timelineElementHelpers";
26
27
 
27
28
  // Re-export helpers that were previously public from this module so that
@@ -230,6 +231,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
230
231
 
231
232
  nodes.forEach((node) => {
232
233
  if (node === rootComp) return;
234
+ if (isTimelineIgnoredElement(node)) return;
233
235
  const el = node as HTMLElement;
234
236
  const startStr = el.getAttribute("data-start");
235
237
  if (startStr == null) return;
@@ -23,6 +23,19 @@ function readDurationAttribute(el: Element | null | undefined): number {
23
23
  return isFinitePositive(duration) ? duration : 0;
24
24
  }
25
25
 
26
+ export function isTimelineIgnoredElement(el: Element): boolean {
27
+ return Boolean(
28
+ el.closest(
29
+ [
30
+ "[data-hyperframes-ignore]",
31
+ "[data-hyperframes-picker-ignore]",
32
+ "[data-hf-ignore]",
33
+ "[data-hf-color-grading-canvas]",
34
+ ].join(","),
35
+ ),
36
+ );
37
+ }
38
+
26
39
  export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
27
40
  if (!doc) return 0;
28
41
  const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
@@ -30,6 +43,7 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
30
43
 
31
44
  let maxEnd = 0;
32
45
  for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
46
+ if (isTimelineIgnoredElement(node)) continue;
33
47
  const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
34
48
  const duration = readDurationAttribute(node);
35
49
  if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
@@ -241,7 +255,9 @@ export function getTimelineElementIdentity(element: TimelineElement): string {
241
255
 
242
256
  function getTimelineDomNodes(doc: Document): Element[] {
243
257
  const rootComp = doc.querySelector("[data-composition-id]");
244
- return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
258
+ return Array.from(doc.querySelectorAll("[data-start]")).filter(
259
+ (node) => node !== rootComp && !isTimelineIgnoredElement(node),
260
+ );
245
261
  }
246
262
 
247
263
  function numbersNearlyEqual(a: number, b: number): boolean {
@@ -295,6 +311,7 @@ export function findTimelineDomNodeForClip(
295
311
 
296
312
  export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
297
313
  if (!isHtmlElement(el)) return false;
314
+ if (isTimelineIgnoredElement(el)) return false;
298
315
  if (el.parentElement !== root) return false;
299
316
  const tagName = el.tagName.toLowerCase();
300
317
  if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;