@hyperframes/studio 0.7.46 → 0.7.48

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 (110) hide show
  1. package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -0,0 +1,34 @@
1
+ import { create } from "zustand";
2
+
3
+ /**
4
+ * Ephemeral composition-variable overrides for the preview iframe.
5
+ *
6
+ * Values here are NEVER persisted to the composition — they ride the preview
7
+ * URL as `?variables=<json>` (see the studio-server preview routes), which the
8
+ * server injects as `window.__hfVariables` exactly like render-time injection,
9
+ * so what the user previews is what `hyperframes render --variables` produces.
10
+ * `null` means "preview with declared defaults".
11
+ */
12
+ interface PreviewVariablesState {
13
+ values: Record<string, unknown> | null;
14
+ setValues: (values: Record<string, unknown> | null) => void;
15
+ }
16
+
17
+ export const usePreviewVariablesStore = create<PreviewVariablesState>((set) => ({
18
+ values: null,
19
+ setValues: (values) => set({ values: values && Object.keys(values).length > 0 ? values : null }),
20
+ }));
21
+
22
+ /**
23
+ * Apply the current preview-variable overrides to a preview URL (both the
24
+ * Player's initial mount and refreshPlayer's soft reload route through this,
25
+ * so a hard remount can't silently drop the active overrides).
26
+ */
27
+ export function applyPreviewVariablesToUrl(url: URL): void {
28
+ const values = usePreviewVariablesStore.getState().values;
29
+ if (values) {
30
+ url.searchParams.set("variables", JSON.stringify(values));
31
+ } else {
32
+ url.searchParams.delete("variables");
33
+ }
34
+ }
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers";
4
+ import type { TimelineElement } from "../player/store/playerStore";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+
7
+ afterEach(() => {
8
+ usePlayerStore.getState().reset();
9
+ });
10
+
11
+ function makeIframeWith(html: string): HTMLIFrameElement {
12
+ const iframe = document.createElement("iframe");
13
+ document.body.append(iframe);
14
+ const doc = iframe.contentDocument;
15
+ if (!doc) throw new Error("expected iframe document");
16
+ doc.body.innerHTML = html;
17
+ return iframe;
18
+ }
19
+
20
+ function el(input: Partial<TimelineElement> & { id: string; tag: string }): TimelineElement {
21
+ return {
22
+ label: input.id,
23
+ start: 0,
24
+ duration: 5,
25
+ track: 0,
26
+ zIndex: 0,
27
+ hasExplicitZIndex: false,
28
+ stackingContextId: null,
29
+ ...input,
30
+ };
31
+ }
32
+
33
+ describe("applyTimelineStackingReorder", () => {
34
+ it("commits via the change's own locator even when the element is not in timelineElements", () => {
35
+ // Sub-comp children live in the preview iframe but NOT in the top-level
36
+ // timelineElements list — the intent must be self-contained.
37
+ const iframe = makeIframeWith(`<div id="chip" style="z-index: 1"></div>`);
38
+ const commit = vi.fn<(entries: unknown[]) => void>();
39
+
40
+ applyTimelineStackingReorder({
41
+ element: el({ id: "chip", tag: "div" }),
42
+ stackingReorder: {
43
+ contextKey: "scene",
44
+ placement: { type: "above", layerId: "layer:scene:x" },
45
+ zIndexChanges: [
46
+ {
47
+ key: "scenes/scene.html#chip",
48
+ zIndex: 5,
49
+ domId: "chip",
50
+ sourceFile: "scenes/scene.html",
51
+ },
52
+ ],
53
+ },
54
+ timelineElements: [], // element intentionally absent from the top-level list
55
+ iframe,
56
+ activeCompPath: "index.html",
57
+ commit,
58
+ });
59
+
60
+ expect(commit).toHaveBeenCalledTimes(1);
61
+ const entries = commit.mock.calls[0]![0] as Array<{
62
+ zIndex: number;
63
+ id?: string;
64
+ sourceFile: string;
65
+ }>;
66
+ expect(entries).toHaveLength(1);
67
+ expect(entries[0]!.zIndex).toBe(5);
68
+ expect(entries[0]!.id).toBe("chip");
69
+ expect(entries[0]!.sourceFile).toBe("scenes/scene.html");
70
+ });
71
+
72
+ it("never commits when the dragged clip is audio", () => {
73
+ const iframe = makeIframeWith(`<audio id="track"></audio>`);
74
+ const commit = vi.fn<(entries: unknown[]) => void>();
75
+
76
+ applyTimelineStackingReorder({
77
+ element: el({ id: "track", tag: "audio" }),
78
+ stackingReorder: {
79
+ contextKey: "main",
80
+ placement: { type: "above", layerId: "layer:main:x" },
81
+ zIndexChanges: [{ key: "track", zIndex: 5, domId: "track" }],
82
+ },
83
+ timelineElements: [],
84
+ iframe,
85
+ activeCompPath: "index.html",
86
+ commit,
87
+ });
88
+
89
+ expect(commit).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("extendRootDurationIfNeeded", () => {
94
+ it("extends the player duration only when the new end is larger", () => {
95
+ usePlayerStore.getState().setDuration(4);
96
+
97
+ expect(extendRootDurationIfNeeded(5)).toBe(true);
98
+ expect(usePlayerStore.getState().duration).toBe(5);
99
+
100
+ expect(extendRootDurationIfNeeded(5)).toBe(false);
101
+ expect(extendRootDurationIfNeeded(3)).toBe(false);
102
+ expect(usePlayerStore.getState().duration).toBe(5);
103
+ });
104
+ });
@@ -1,8 +1,121 @@
1
- import type { TimelineElement } from "../player/store/playerStore";
1
+ import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
2
  import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
3
- import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
3
+ import {
4
+ formatTimelineAttributeNumber,
5
+ type TimelineStackingReorderIntent,
6
+ } from "../player/components/timelineEditing";
7
+ import { getElementZIndex } from "../player/lib/layerOrdering";
8
+ import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
4
9
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
10
+ import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
5
11
  import type { EditHistoryKind } from "../utils/editHistory";
12
+ import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
13
+ import { extendRootDurationInSource } from "../utils/rootDuration";
14
+
15
+ function isHTMLElement(element: Element | null): element is HTMLElement {
16
+ if (!element) return false;
17
+ // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
18
+ // iframe, and cross-realm `element instanceof HTMLElement` (main window) is
19
+ // always false — which silently dropped every timeline z-index commit.
20
+ const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
21
+ return element instanceof Ctor;
22
+ }
23
+
24
+ /**
25
+ * Resolve a timeline vertical move to a z-index stacking reorder and commit it
26
+ * through the shared layers-panel reorder path. Reads live sibling z-index from
27
+ * the preview DOM, remaps with the dup-preserving reorder math, and writes only
28
+ * z-index (never data-track-index). No-op when the move isn't a reorder, the
29
+ * dragged clip is audio (no visual layer to restack), or the live siblings can't
30
+ * be resolved. Extracted from StudioApp's timeline hook to keep it under the
31
+ * studio 600-LOC cap.
32
+ */
33
+ // fallow-ignore-next-line complexity
34
+ export function applyTimelineStackingReorder(input: {
35
+ element: TimelineElement;
36
+ stackingReorder: TimelineStackingReorderIntent | null | undefined;
37
+ timelineElements: readonly TimelineElement[];
38
+ iframe: HTMLIFrameElement | null;
39
+ activeCompPath: string | null;
40
+ commit: TimelineZIndexReorderCommit | null | undefined;
41
+ }): Promise<void> {
42
+ // Audio has no visual stacking; a vertical drag on it must never write z-index.
43
+ if (input.element.tag === "audio") return Promise.resolve();
44
+
45
+ const intent = input.stackingReorder ?? null;
46
+ if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
47
+
48
+ // Resolve each change's live element from the change's OWN locator (the intent
49
+ // is self-contained), falling back to the top-level element list. Sub-comp
50
+ // children aren't in `timelineElements`, so a list-only lookup would miss them.
51
+ const siblingByKey = new Map(
52
+ input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
53
+ );
54
+ const doc = input.iframe?.contentDocument ?? null;
55
+ const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
56
+ if (!doc) return null;
57
+ if (domId) return doc.getElementById(domId);
58
+ if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
59
+ return null;
60
+ };
61
+ const commitEntries: Array<{
62
+ element: HTMLElement;
63
+ zIndex: number;
64
+ id?: string;
65
+ selector?: string;
66
+ selectorIndex?: number;
67
+ sourceFile: string;
68
+ key: string;
69
+ }> = [];
70
+
71
+ for (const change of intent.zIndexChanges) {
72
+ const sibling = siblingByKey.get(change.key);
73
+ const domId = change.domId ?? sibling?.domId;
74
+ const selector = change.selector ?? sibling?.selector;
75
+ const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
76
+ const element = findLive(domId, selector, selectorIndex);
77
+ if (!isHTMLElement(element)) return Promise.resolve();
78
+ if (getElementZIndex(element) === change.zIndex) continue;
79
+ commitEntries.push({
80
+ element,
81
+ zIndex: change.zIndex,
82
+ id: domId ?? sibling?.id ?? change.key,
83
+ selector,
84
+ selectorIndex,
85
+ sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
86
+ key: change.key,
87
+ });
88
+ }
89
+
90
+ if (commitEntries.length === 0) return Promise.resolve();
91
+ return input.commit?.(commitEntries) ?? Promise.resolve();
92
+ }
93
+
94
+ /**
95
+ * Remove the keyframes currently selected in the player store from the active
96
+ * element's GSAP animation. Reads selection lazily so it stays correct when
97
+ * invoked from a ref callback. Extracted from StudioApp to keep it under the
98
+ * studio 600-LOC cap.
99
+ */
100
+ export function deleteSelectedKeyframes(session: {
101
+ selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
102
+ handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
103
+ }): void {
104
+ const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
105
+ const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
106
+ if (!animation) return;
107
+ // Only the active element's keyframes; a stale cross-element selection must not delete here.
108
+ for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
109
+ session.handleGsapRemoveKeyframe(animation.id, pct);
110
+ }
111
+ }
112
+
113
+ export function extendRootDurationIfNeeded(newEnd: number): boolean {
114
+ const store = usePlayerStore.getState();
115
+ if (newEnd <= store.duration) return false;
116
+ store.setDuration(newEnd);
117
+ return true;
118
+ }
6
119
 
7
120
  // ── Types ──
8
121
 
@@ -10,6 +123,8 @@ export interface RecordEditInput {
10
123
  label: string;
11
124
  kind: EditHistoryKind;
12
125
  coalesceKey?: string;
126
+ /** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
127
+ coalesceMs?: number;
13
128
  files: Record<string, { before: string; after: string }>;
14
129
  }
15
130
 
@@ -72,8 +187,25 @@ export function patchIframeDomTiming(
72
187
  }
73
188
  }
74
189
 
190
+ function postRootDurationToPreview(
191
+ iframe: HTMLIFrameElement | null,
192
+ durationSeconds: number,
193
+ ): void {
194
+ const duration = Number(durationSeconds);
195
+ if (!Number.isFinite(duration) || duration <= 0) return;
196
+ iframe?.contentWindow?.postMessage(
197
+ {
198
+ source: "hf-parent",
199
+ type: "control",
200
+ action: "set-root-duration",
201
+ durationSeconds: duration,
202
+ },
203
+ "*",
204
+ );
205
+ }
206
+
75
207
  // fallow-ignore-next-line complexity
76
- export function resolveResizePlaybackStart(
208
+ function resolveResizePlaybackStart(
77
209
  original: string,
78
210
  target: PatchTarget,
79
211
  element: TimelineElement,
@@ -99,6 +231,47 @@ export function resolveResizePlaybackStart(
99
231
  };
100
232
  }
101
233
 
234
+ export function buildTimelineMoveTimingPatch(
235
+ original: string,
236
+ target: PatchTarget,
237
+ start: number,
238
+ duration: number,
239
+ ): string {
240
+ const patched = applyPatchByTarget(original, target, {
241
+ type: "attribute",
242
+ property: "start",
243
+ value: formatTimelineAttributeNumber(start),
244
+ });
245
+ return extendRootDurationInSource(patched, start + duration);
246
+ }
247
+
248
+ export function buildTimelineResizeTimingPatch(
249
+ original: string,
250
+ target: PatchTarget,
251
+ element: TimelineElement,
252
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
253
+ ): string {
254
+ const pbs = resolveResizePlaybackStart(original, target, element, updates);
255
+ let patched = applyPatchByTarget(original, target, {
256
+ type: "attribute",
257
+ property: "start",
258
+ value: formatTimelineAttributeNumber(updates.start),
259
+ });
260
+ patched = applyPatchByTarget(patched, target, {
261
+ type: "attribute",
262
+ property: "duration",
263
+ value: formatTimelineAttributeNumber(updates.duration),
264
+ });
265
+ if (pbs) {
266
+ patched = applyPatchByTarget(patched, target, {
267
+ type: "attribute",
268
+ property: pbs.attrName,
269
+ value: formatTimelineAttributeNumber(pbs.value),
270
+ });
271
+ }
272
+ return extendRootDurationInSource(patched, updates.start + updates.duration);
273
+ }
274
+
102
275
  export interface PersistTimelineEditInput {
103
276
  projectId: string;
104
277
  element: TimelineElement;
@@ -141,6 +314,66 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
141
314
  input.domEditSaveTimestampRef.current = Date.now();
142
315
  }
143
316
 
317
+ export interface PersistTimelineBatchChange {
318
+ element: TimelineElement;
319
+ buildPatches: (original: string, target: PatchTarget) => string;
320
+ }
321
+
322
+ export interface PersistTimelineBatchEditInput {
323
+ projectId: string;
324
+ activeCompPath: string | null;
325
+ label: string;
326
+ changes: PersistTimelineBatchChange[];
327
+ writeProjectFile: (path: string, content: string) => Promise<void>;
328
+ recordEdit: (input: RecordEditInput) => Promise<void>;
329
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
330
+ pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
331
+ coalesceKey?: string;
332
+ }
333
+
334
+ export async function persistTimelineBatchEdit(
335
+ input: PersistTimelineBatchEditInput,
336
+ ): Promise<void> {
337
+ const originals = new Map<string, string>();
338
+ const patchedByPath = new Map<string, string>();
339
+
340
+ for (const change of input.changes) {
341
+ const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
342
+ const original =
343
+ originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
344
+ originals.set(targetPath, original);
345
+
346
+ const patchTarget = buildPatchTarget(change.element);
347
+ if (!patchTarget) {
348
+ throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
349
+ }
350
+
351
+ const current = patchedByPath.get(targetPath) ?? original;
352
+ const patched = change.buildPatches(current, patchTarget);
353
+ if (patched === current) {
354
+ throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
355
+ }
356
+ patchedByPath.set(targetPath, patched);
357
+ }
358
+
359
+ const files = Object.fromEntries(patchedByPath);
360
+ for (const targetPath of Object.keys(files)) {
361
+ input.pendingTimelineEditPathRef.current.add(targetPath);
362
+ }
363
+ input.domEditSaveTimestampRef.current = Date.now();
364
+ await saveProjectFilesWithHistory({
365
+ projectId: input.projectId,
366
+ label: input.label,
367
+ kind: "timeline",
368
+ coalesceKey: input.coalesceKey,
369
+ files,
370
+ readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
371
+ writeFile: input.writeProjectFile,
372
+ recordEdit: input.recordEdit,
373
+ });
374
+ input.domEditSaveTimestampRef.current = Date.now();
375
+ }
376
+
144
377
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
145
378
  if (targetPath.includes("\0") || targetPath.includes("..")) {
146
379
  throw new Error(`Unsafe path: ${targetPath}`);
@@ -158,6 +391,94 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
158
391
  return data.content;
159
392
  }
160
393
 
394
+ export type GsapMutationStatus = { mutated: boolean };
395
+
396
+ function isRecord(value: unknown): value is Record<string, unknown> {
397
+ return typeof value === "object" && value !== null;
398
+ }
399
+
400
+ function readMutationStatus(value: unknown): GsapMutationStatus {
401
+ if (!isRecord(value)) return { mutated: false };
402
+ return { mutated: value.mutated === true || value.changed === true };
403
+ }
404
+
405
+ function readMutationError(value: unknown, fallback: string): string {
406
+ if (isRecord(value) && typeof value.error === "string") return value.error;
407
+ return fallback;
408
+ }
409
+
410
+ export async function finishTimelineTimingFallback(input: {
411
+ iframe: HTMLIFrameElement | null;
412
+ needsExtension: boolean;
413
+ rootDurationSeconds: number;
414
+ reloadPreview: () => void;
415
+ gsapMutation?: () => Promise<GsapMutationStatus>;
416
+ onGsapError: (error: unknown) => void;
417
+ }): Promise<void> {
418
+ let gsapMutated = false;
419
+ if (input.gsapMutation) {
420
+ try {
421
+ gsapMutated = (await input.gsapMutation()).mutated;
422
+ } catch (error) {
423
+ input.onGsapError(error);
424
+ return;
425
+ }
426
+ }
427
+ if (input.needsExtension) {
428
+ postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
429
+ if (gsapMutated) input.reloadPreview();
430
+ return;
431
+ }
432
+ input.reloadPreview();
433
+ }
434
+
435
+ // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
436
+ // outlast one GSAP server round-trip, never a real second edit.
437
+ const GSAP_HISTORY_COALESCE_MS = 10_000;
438
+
439
+ /**
440
+ * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
441
+ * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
442
+ * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
443
+ * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
444
+ * folding both writes into one undo step. Returns the mutation status for caller reloads.
445
+ */
446
+ export async function foldGsapMutationIntoHistory(input: {
447
+ projectId: string;
448
+ paths: string[];
449
+ label: string;
450
+ coalesceKey?: string;
451
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
452
+ gsapMutation: () => Promise<GsapMutationStatus>;
453
+ }): Promise<GsapMutationStatus> {
454
+ const uniquePaths = [...new Set(input.paths)];
455
+ const before = new Map<string, string>();
456
+ for (const path of uniquePaths) {
457
+ before.set(path, await readFileContent(input.projectId, path));
458
+ }
459
+ const status = await input.gsapMutation();
460
+ if (status.mutated) {
461
+ const files: Record<string, { before: string; after: string }> = {};
462
+ for (const path of uniquePaths) {
463
+ const priorContent = before.get(path);
464
+ const finalContent = await readFileContent(input.projectId, path);
465
+ if (priorContent !== undefined && finalContent !== priorContent) {
466
+ files[path] = { before: priorContent, after: finalContent };
467
+ }
468
+ }
469
+ if (Object.keys(files).length > 0) {
470
+ await input.recordEdit({
471
+ label: input.label,
472
+ kind: "timeline",
473
+ coalesceKey: input.coalesceKey,
474
+ coalesceMs: GSAP_HISTORY_COALESCE_MS,
475
+ files,
476
+ });
477
+ }
478
+ }
479
+ return status;
480
+ }
481
+
161
482
  /**
162
483
  * Shift all GSAP animation positions targeting a given element by a time delta.
163
484
  * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -167,8 +488,8 @@ export async function shiftGsapPositions(
167
488
  filePath: string,
168
489
  elementId: string,
169
490
  delta: number,
170
- ): Promise<void> {
171
- if (delta === 0 || !elementId) return;
491
+ ): Promise<GsapMutationStatus> {
492
+ if (delta === 0 || !elementId) return { mutated: false };
172
493
  const res = await fetch(
173
494
  `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
174
495
  {
@@ -183,8 +504,9 @@ export async function shiftGsapPositions(
183
504
  );
184
505
  if (!res.ok) {
185
506
  const err = await res.json().catch(() => null);
186
- throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
507
+ throw new Error(readMutationError(err, "shift-positions failed"));
187
508
  }
509
+ return readMutationStatus(await res.json().catch(() => null));
188
510
  }
189
511
 
190
512
  export async function scaleGsapPositions(
@@ -195,9 +517,9 @@ export async function scaleGsapPositions(
195
517
  oldDuration: number,
196
518
  newStart: number,
197
519
  newDuration: number,
198
- ): Promise<void> {
199
- if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
200
- if (oldStart === newStart && oldDuration === newDuration) return;
520
+ ): Promise<GsapMutationStatus> {
521
+ if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
522
+ if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
201
523
  const res = await fetch(
202
524
  `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
203
525
  {
@@ -215,8 +537,62 @@ export async function scaleGsapPositions(
215
537
  );
216
538
  if (!res.ok) {
217
539
  const err = await res.json().catch(() => null);
218
- throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
540
+ throw new Error(readMutationError(err, "scale-positions failed"));
219
541
  }
542
+ return readMutationStatus(await res.json().catch(() => null));
543
+ }
544
+
545
+ /** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
546
+ export function foldedShiftGsapMutation(input: {
547
+ projectId: string;
548
+ targetPath: string;
549
+ domId: string;
550
+ delta: number;
551
+ label: string;
552
+ coalesceKey?: string;
553
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
554
+ }): () => Promise<GsapMutationStatus> {
555
+ return () =>
556
+ foldGsapMutationIntoHistory({
557
+ projectId: input.projectId,
558
+ paths: [input.targetPath],
559
+ label: input.label,
560
+ coalesceKey: input.coalesceKey,
561
+ recordEdit: input.recordEdit,
562
+ gsapMutation: () =>
563
+ shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
564
+ });
565
+ }
566
+
567
+ /** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
568
+ export function foldedScaleGsapMutation(input: {
569
+ projectId: string;
570
+ targetPath: string;
571
+ domId: string;
572
+ from: { start: number; duration: number };
573
+ to: { start: number; duration: number };
574
+ label: string;
575
+ coalesceKey?: string;
576
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
577
+ }): () => Promise<GsapMutationStatus> {
578
+ return () =>
579
+ foldGsapMutationIntoHistory({
580
+ projectId: input.projectId,
581
+ paths: [input.targetPath],
582
+ label: input.label,
583
+ coalesceKey: input.coalesceKey,
584
+ recordEdit: input.recordEdit,
585
+ gsapMutation: () =>
586
+ scaleGsapPositions(
587
+ input.projectId,
588
+ input.targetPath,
589
+ input.domId,
590
+ input.from.start,
591
+ input.from.duration,
592
+ input.to.start,
593
+ input.to.duration,
594
+ ),
595
+ });
220
596
  }
221
597
 
222
598
  // Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef } from "react";
11
11
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
12
  import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
13
13
  import { usePlayerStore } from "../player";
14
+ import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
14
15
  import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
15
16
  import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
16
17
  import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
@@ -170,13 +171,14 @@ export function useDomEditWiring({
170
171
 
171
172
  useEffect(() => {
172
173
  if (!domEditSelection?.id) return;
173
- const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
174
- const matchKey = elements.find(
175
- (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
176
- );
177
- const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
178
- if (key && key !== selectedElementId) setSelectedElementId(key);
179
- }, [domEditSelection?.id]);
174
+ const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState();
175
+ // Resolve through the canonical resolver (source-file + ancestor + active-comp
176
+ // fallback) rather than a narrow domId/id match, so a sub-composition selection
177
+ // maps to the same clip the rest of the selection pipeline picks. Use the
178
+ // anchor-only setter: this is a DOM->store echo and must not collapse a group.
179
+ const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
180
+ if (key && key !== selectedElementId) setSelectionAnchor(key);
181
+ }, [domEditSelection, activeCompPath]);
180
182
 
181
183
  // ── GSAP cache sync ──
182
184