@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -7,8 +7,8 @@ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
7
7
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
8
8
  import {
9
9
  isResolverDisagreement,
10
+ sdkCutoverIneligibleReason,
10
11
  shouldDeclineTextCutoverForTarget,
11
- shouldUseSdkCutover,
12
12
  } from "./sdkCutoverEligibility";
13
13
  import {
14
14
  asCutoverError,
@@ -81,11 +81,14 @@ export async function sdkCutoverPersist(
81
81
  deps: CutoverDeps,
82
82
  options?: CutoverOptions,
83
83
  ): Promise<CutoverResult> {
84
- if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
85
- return declinedCutover("ineligible_operation", "dom");
84
+ // Name WHICH eligibility check failed: on v0.8.47 this one string covered 26 of
85
+ // 30 dom declines. The two below aren't properties of the batch, so they differ.
86
+ if (!sdkFamilyEnabled("dom")) return declinedCutover("feature_disabled", "dom");
86
87
  if (!sdkSession) return declinedCutover("session_unavailable", "dom");
87
88
  const hfId = selection.hfId;
88
- if (!hfId) return declinedCutover("target_unaddressable", "dom");
89
+ const ineligible = sdkCutoverIneligibleReason(hfId, ops);
90
+ // `!hfId` is already a reason; testing it again narrows the type for the rest.
91
+ if (ineligible || !hfId) return declinedCutover(ineligible ?? "target_unaddressable", "dom");
89
92
  const target = sdkSession.getElement(hfId);
90
93
  if (!target)
91
94
  return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
@@ -1,6 +1,27 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { PatchOperation } from "./sourcePatcher";
3
- import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
3
+ import { shouldUseSdkCutover, sdkCutoverIneligibleReason } from "./sdkCutoverEligibility";
4
+
5
+ const styleOp = (property: string, value: string): PatchOperation => ({
6
+ type: "inline-style",
7
+ property,
8
+ value,
9
+ });
10
+ const textOp = (value: string): PatchOperation => ({
11
+ type: "text-content",
12
+ property: "text",
13
+ value,
14
+ });
15
+ const attrOp = (property: string, value: string): PatchOperation => ({
16
+ type: "attribute",
17
+ property,
18
+ value,
19
+ });
20
+ const htmlAttrOp = (property: string, value: string): PatchOperation => ({
21
+ type: "html-attribute",
22
+ property,
23
+ value,
24
+ });
4
25
 
5
26
  const childStyleOp: PatchOperation = {
6
27
  type: "inline-style",
@@ -15,3 +36,77 @@ describe("shouldUseSdkCutover child-scoped operations", () => {
15
36
  expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
16
37
  });
17
38
  });
39
+
40
+ describe("sdkCutoverIneligibleReason", () => {
41
+ // `ineligible_operation` was one string covering four distinct causes, which
42
+ // made the post-flip decline telemetry unactionable: 26 of 30 declines on
43
+ // v0.8.47 landed there, with no way to separate a structural edit the SDK has
44
+ // no vocabulary for (expected, permanent) from a reserved-attribute or
45
+ // unsafe-attribute decline (narrower, and possibly fixable).
46
+ it("names an op type the SDK cannot express", () => {
47
+ expect(
48
+ sdkCutoverIneligibleReason("hf-abc", [
49
+ { type: "split", property: "x", value: "1" } as unknown as PatchOperation,
50
+ ]),
51
+ ).toBe("unsupported_op_type");
52
+ });
53
+
54
+ it("names a child-scoped op", () => {
55
+ expect(sdkCutoverIneligibleReason("hf-parent", [childStyleOp])).toBe("child_scoped_op");
56
+ });
57
+
58
+ it("names a reserved attribute", () => {
59
+ expect(sdkCutoverIneligibleReason("hf-abc", [attrOp("end", "2")])).toBe("reserved_attribute");
60
+ expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(
61
+ "reserved_attribute",
62
+ );
63
+ });
64
+
65
+ it("names an unsafe html attribute", () => {
66
+ expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
67
+ "unsafe_html_attribute",
68
+ );
69
+ expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("href", "javascript:alert(1)")])).toBe(
70
+ "unsafe_html_attribute",
71
+ );
72
+ });
73
+
74
+ it("names an empty batch and an unaddressable target", () => {
75
+ expect(sdkCutoverIneligibleReason("hf-abc", [])).toBe("no_ops");
76
+ expect(sdkCutoverIneligibleReason(null, [styleOp("color", "red")])).toBe(
77
+ "target_unaddressable",
78
+ );
79
+ });
80
+
81
+ it("returns null when the batch is eligible", () => {
82
+ expect(sdkCutoverIneligibleReason("hf-abc", [styleOp("color", "red")])).toBeNull();
83
+ expect(
84
+ sdkCutoverIneligibleReason("hf-abc", [
85
+ styleOp("color", "red"),
86
+ textOp("hello"),
87
+ attrOp("x", "1"),
88
+ htmlAttrOp("class", "foo"),
89
+ ]),
90
+ ).toBeNull();
91
+ });
92
+
93
+ // The reason function must not drift from the predicate it explains: a batch
94
+ // the gate accepts must have no reason, and one it declines must have one.
95
+ it("agrees with shouldUseSdkCutover on every case", () => {
96
+ const cases: PatchOperation[][] = [
97
+ [styleOp("color", "red")],
98
+ [textOp("hello")],
99
+ [attrOp("x", "1")],
100
+ [htmlAttrOp("class", "foo")],
101
+ [attrOp("end", "2")],
102
+ [htmlAttrOp("onclick", "alert(1)")],
103
+ [childStyleOp],
104
+ [{ type: "split", property: "x", value: "1" } as unknown as PatchOperation],
105
+ [],
106
+ ];
107
+ for (const ops of cases) {
108
+ const eligible = shouldUseSdkCutover(true, true, "hf-abc", ops);
109
+ expect(sdkCutoverIneligibleReason("hf-abc", ops) === null).toBe(eligible);
110
+ }
111
+ });
112
+ });
@@ -123,21 +123,44 @@ export function isResolverDisagreement(session: Composition, hfId: string): bool
123
123
  }
124
124
  }
125
125
 
126
+ /**
127
+ * Why a batch cannot take the SDK cutover path, or null when it can.
128
+ *
129
+ * `ineligible_operation` used to be a single decline reason covering all of
130
+ * these, which made the post-flip telemetry unactionable — a structural edit the
131
+ * SDK has no vocabulary for (expected and permanent) was indistinguishable from
132
+ * a reserved-attribute decline (narrow, and possibly worth fixing). The order
133
+ * mirrors `shouldUseSdkCutover`'s conjunction, so the first failing check names
134
+ * the reason.
135
+ */
136
+ export type SdkCutoverIneligibleReason =
137
+ | "target_unaddressable"
138
+ | "no_ops"
139
+ | "unsupported_op_type"
140
+ | "child_scoped_op"
141
+ | "reserved_attribute"
142
+ | "unsafe_html_attribute";
143
+
144
+ export function sdkCutoverIneligibleReason(
145
+ hfId: string | null | undefined,
146
+ ops: PatchOperation[],
147
+ ): SdkCutoverIneligibleReason | null {
148
+ if (!hfId) return "target_unaddressable";
149
+ if (ops.length === 0) return "no_ops";
150
+ if (!ops.every((o) => CUTOVER_OP_TYPES.has(o.type))) return "unsupported_op_type";
151
+ if (hasChildScopedOp(ops)) return "child_scoped_op";
152
+ if (ops.some(mapsToReservedAttr)) return "reserved_attribute";
153
+ if (hasUnsafeHtmlAttributeOp(ops)) return "unsafe_html_attribute";
154
+ return null;
155
+ }
156
+
126
157
  export function shouldUseSdkCutover(
127
158
  flagEnabled: boolean,
128
159
  hasSession: boolean,
129
160
  hfId: string | null | undefined,
130
161
  ops: PatchOperation[],
131
162
  ): boolean {
132
- return (
133
- flagEnabled &&
134
- hasSession &&
135
- !!hfId &&
136
- ops.length > 0 &&
137
- ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
138
- // SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
139
- !hasChildScopedOp(ops) &&
140
- !ops.some(mapsToReservedAttr) &&
141
- !hasUnsafeHtmlAttributeOp(ops)
142
- );
163
+ // Derived from the reason function so the two can never disagree about which
164
+ // batches are eligible.
165
+ return flagEnabled && hasSession && sdkCutoverIneligibleReason(hfId, ops) === null;
143
166
  }
@@ -5,6 +5,7 @@ import {
5
5
  findMatchingTimelineElementId,
6
6
  findTimelineIdByAncestor,
7
7
  resolveDroppedAssetDimensions,
8
+ resolveElementTrack,
8
9
  resolveTimelineIdForSelection,
9
10
  resolveTimelineSelectionSeekTime,
10
11
  } from "./studioHelpers";
@@ -42,6 +43,13 @@ describe("findMatchingTimelineElementId", () => {
42
43
  expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
43
44
  });
44
45
 
46
+ it("matches by hfId when the selection has no domId, so an element with no authored id can still be found", () => {
47
+ const els = [el({ id: "hf-1", domId: undefined, hfId: "hf-1", sourceFile: "index.html" })];
48
+ expect(
49
+ findMatchingTimelineElementId({ id: undefined, hfId: "hf-1", sourceFile: "index.html" }, els),
50
+ ).toBe("hf-1");
51
+ });
52
+
45
53
  it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
46
54
  const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
47
55
  expect(
@@ -199,3 +207,13 @@ describe("resolveDroppedAssetDimensions", () => {
199
207
  expect(load).toHaveBeenCalledOnce();
200
208
  });
201
209
  });
210
+
211
+ describe("resolveElementTrack", () => {
212
+ it("rounds an authored track", () => {
213
+ expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
214
+ });
215
+
216
+ it("falls back to the resolved track, rounded, when nothing was authored", () => {
217
+ expect(resolveElementTrack({ authoredTrack: undefined, track: 3.6 })).toBe(4);
218
+ });
219
+ });
@@ -126,9 +126,15 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
126
126
  return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
127
127
  }
128
128
 
129
- type ElementMatchSelection = Pick<
129
+ export type ElementMatchSelection = Pick<
130
130
  DomEditSelection,
131
- "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
131
+ | "id"
132
+ | "hfId"
133
+ | "selector"
134
+ | "selectorIndex"
135
+ | "sourceFile"
136
+ | "compositionSrc"
137
+ | "isCompositionHost"
132
138
  >;
133
139
 
134
140
  function matchesByDomId(
@@ -142,6 +148,17 @@ function matchesByDomId(
142
148
  );
143
149
  }
144
150
 
151
+ function matchesByHfId(
152
+ selection: ElementMatchSelection,
153
+ element: TimelineElement,
154
+ selectionSourceFile: string,
155
+ ): boolean {
156
+ if (!selection.hfId) return false;
157
+ return (
158
+ element.hfId === selection.hfId && (element.sourceFile || "index.html") === selectionSourceFile
159
+ );
160
+ }
161
+
145
162
  function matchesByCompositionHost(
146
163
  selection: ElementMatchSelection,
147
164
  element: TimelineElement,
@@ -172,14 +189,22 @@ export function findMatchingTimelineElementId(
172
189
  // scan let `.find()` stop at an EARLIER, unrelated host that merely shares
173
190
  // the compositionSrc, before the scan ever reached the correct id/selector
174
191
  // match further down the list — collapsing every repeated host to the
175
- // first one. Try id, then selector, across the WHOLE list first; only fall
176
- // back to the coarser compositionSrc-only match when neither identifies a
177
- // specific element.
192
+ // first one. Try id, then hfId, then selector, across the WHOLE list
193
+ // first; only fall back to the coarser compositionSrc-only match when
194
+ // none of them identifies a specific element.
178
195
  const byId = selection.id
179
196
  ? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile))
180
197
  : undefined;
181
198
  if (byId) return byId.key ?? byId.id;
182
199
 
200
+ // hfId is the stable content-hash id every element gets regardless of
201
+ // whether it has a real DOM id — the only correlator for an element like
202
+ // an ungroup child that has neither an authored id nor a selector.
203
+ const byHfId = selection.hfId
204
+ ? elements.find((el) => matchesByHfId(selection, el, selectionSourceFile))
205
+ : undefined;
206
+ if (byHfId) return byHfId.key ?? byHfId.id;
207
+
183
208
  const bySelector = selection.selector
184
209
  ? elements.find((el) => matchesBySelector(selection, el))
185
210
  : undefined;
@@ -198,6 +223,17 @@ export function findMatchingTimelineElementId(
198
223
  return null;
199
224
  }
200
225
 
226
+ // The element's track: authored if given, else the runtime's already-resolved
227
+ // fallback — always rounded to an integer index either way. Shared by the
228
+ // group/ungroup and razor-split flows so they can't drift out of sync again.
229
+ export function resolveElementTrack(
230
+ element: Pick<TimelineElement, "authoredTrack" | "track">,
231
+ ): number {
232
+ return Math.round(
233
+ Number.isFinite(element.authoredTrack) ? (element.authoredTrack as number) : element.track,
234
+ );
235
+ }
236
+
201
237
  /**
202
238
  * A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
203
239
  * inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
@@ -73,6 +73,20 @@ describe("timelineSnapEnabled preference", () => {
73
73
  });
74
74
  });
75
75
 
76
+ describe("rippleEditEnabled preference", () => {
77
+ it("round-trips through storage", () => {
78
+ const storage = createStorage();
79
+ writeStudioUiPreferences({ rippleEditEnabled: false }, storage);
80
+ expect(readStudioUiPreferences(storage).rippleEditEnabled).toBe(false);
81
+ });
82
+
83
+ it("ignores non-boolean values", () => {
84
+ const storage = createStorage();
85
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ rippleEditEnabled: "yes" }));
86
+ expect(readStudioUiPreferences(storage).rippleEditEnabled).toBeUndefined();
87
+ });
88
+ });
89
+
76
90
  describe("thumbnailMode preference", () => {
77
91
  it("round-trips the adaptive mode", () => {
78
92
  const storage = createStorage();
@@ -24,6 +24,9 @@ export interface StudioUiPreferences {
24
24
  snapToGrid?: boolean;
25
25
  /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
26
26
  timelineSnapEnabled?: boolean;
27
+ /** Keeps the main track gapless: deleting a clip closes the gap. Distinct
28
+ * from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
29
+ rippleEditEnabled?: boolean;
27
30
  /** Transport + ruler readout mode: timecode or frame number. */
28
31
  timeDisplayMode?: TimelineTimeDisplayMode;
29
32
  /**
@@ -136,6 +139,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
136
139
  if (typeof parsed.timelineSnapEnabled === "boolean") {
137
140
  preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
138
141
  }
142
+ if (typeof parsed.rippleEditEnabled === "boolean") {
143
+ preferences.rippleEditEnabled = parsed.rippleEditEnabled;
144
+ }
139
145
  if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
140
146
  preferences.timeDisplayMode = parsed.timeDisplayMode;
141
147
  }
@@ -31,16 +31,24 @@ export interface StudioUrlState {
31
31
  const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
32
32
 
33
33
  /**
34
- * The composition a schema-level panel (Variables / Slideshow) targets on the
35
- * master view, where there is no explicit `activeCompPath`. Prefer the
36
- * `index.html` convention, but fall back to the first `.html` in the file tree
34
+ * The composition auto-open and a schema-level panel (Variables / Slideshow)
35
+ * target on the master view, where there is no explicit `activeCompPath`.
36
+ * Prefer the `index.html` convention, but fall back to the first entry
37
37
  * (composition-browser order) so projects whose entry file is `card.html`,
38
38
  * `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
39
- * Returns null when the project carries no composition file at all.
39
+ * Takes the server's filtered composition list, never the raw file tree —
40
+ * a non-composition `.html` (a vendored preset, one with no
41
+ * `data-composition-id`) must never become the target. Returns null when the
42
+ * project carries no composition file at all.
40
43
  */
41
- export function resolveMasterCompositionPath(fileTree: string[]): string | null {
42
- if (fileTree.includes("index.html")) return "index.html";
43
- return fileTree.find((p) => p.endsWith(".html")) ?? null;
44
+ export function resolveMasterCompositionPath(compositions: string[]): string | null {
45
+ if (compositions.includes("index.html")) return "index.html";
46
+ return compositions.find((p) => p.endsWith(".html")) ?? null;
47
+ }
48
+
49
+ /** A URL with no `activeCompPath` needs no hydration step — there's nothing to apply. */
50
+ export function isHydratedFromUrlState(urlState: StudioUrlState): boolean {
51
+ return urlState.activeCompPath == null;
44
52
  }
45
53
 
46
54
  export function normalizeStudioUrlPanelTab(tab: RightPanelTab | null): RightPanelTab | null {
@@ -3,6 +3,7 @@ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
3
3
  import type { RecordEditInput } from "./studioFileHistory";
4
4
  import { buildProjectApiPath } from "./projectRouting";
5
5
  import { studioWriteHeaders } from "./studioFileVersion";
6
+ import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
6
7
 
7
8
  interface TimelineCompositionInsertionResult {
8
9
  path: string;
@@ -76,7 +77,7 @@ export async function commitTimelineCompositionInsertion(input: {
76
77
  await input.writeFile(input.targetPath, result.before, result.after);
77
78
  throw error;
78
79
  }
79
- input.selectHost(`${input.targetPath}#${result.hostId}`);
80
+ input.selectHost(deriveTimelineStoreKeyForDomId(result.hostId, input.targetPath));
80
81
  try {
81
82
  input.resync?.();
82
83
  } catch (error) {
@@ -34,6 +34,16 @@ export function isAudioTimelineElement(
34
34
  return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
35
35
  }
36
36
 
37
+ /** The two tags the property panel lets you put a volume automation lane on.
38
+ * Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
39
+ * `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
40
+ export function isAudioOrVideoTimelineElement(
41
+ element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
42
+ ): boolean {
43
+ if (!element) return false;
44
+ return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
45
+ }
46
+
37
47
  /** True for the music track: an audio element with data-timeline-role="music",
38
48
  * or — when no role is set — an id matching the music regex. Voiceover/other
39
49
  * audio (explicit non-music role) is excluded. */