@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -29,11 +29,22 @@ export interface TimelineElement {
29
29
  start: number;
30
30
  duration: number;
31
31
  track: number;
32
+ /**
33
+ * The data-track-index as written in the source file. Set at the manifest
34
+ * translation boundary (createTimelineElementFromManifestClip) from the
35
+ * runtime clip's verbatim track, and preserved through display-lane remaps
36
+ * (normalizeToZones packs sparse authored tracks onto contiguous display
37
+ * lanes; expanded sub-comp children get synthetic display rows). Lane edits
38
+ * must persist THIS space — writing a display-lane number into a sparse file
39
+ * re-targets the wrong track. For an expanded child the value is in its OWN
40
+ * source file's coordinate space, not the host timeline's.
41
+ */
42
+ authoredTrack?: number;
32
43
  /** Resolved z-index for stacking-aware timeline ordering. */
33
44
  zIndex?: number;
34
45
  /** True when the effective z-index was authored inline or through CSS, not auto. */
35
46
  hasExplicitZIndex?: boolean;
36
- /** Stacking context this element belongs to; root clips use the root composition id. */
47
+ /** Canonical CSS stacking context this element's z-index participates in. */
37
48
  stackingContextId?: string | null;
38
49
  /** Nearest parent composition context, matching RuntimeTimelineClip. */
39
50
  parentCompositionId?: string | null;
@@ -113,6 +124,13 @@ interface PlayerState {
113
124
  zoomMode: ZoomMode;
114
125
  /** Timeline zoom percent relative to the fit width when in manual mode */
115
126
  manualZoomPercent: number;
127
+ /**
128
+ * Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
129
+ * rollback). Flashless z commits (skipReload) never reload the iframe or
130
+ * bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree)
131
+ * subscribe to this to re-read the live DOM while playback is paused.
132
+ */
133
+ zEditVersion: number;
116
134
  /** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
117
135
  inPoint: number | null;
118
136
  /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
@@ -153,6 +171,9 @@ interface PlayerState {
153
171
  /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
154
172
  timelineSnapEnabled: boolean;
155
173
  setTimelineSnapEnabled: (enabled: boolean) => void;
174
+ /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
175
+ timeDisplayMode: "time" | "frame";
176
+ setTimeDisplayMode: (mode: "time" | "frame") => void;
156
177
  /**
157
178
  * Pin the timeline zoom to its current visual scale before a duration-changing
158
179
  * edit, so a subsequent duration change (which recomputes fit-pps) stops
@@ -198,6 +219,7 @@ interface PlayerState {
198
219
  ) => void;
199
220
  setZoomMode: (mode: ZoomMode) => void;
200
221
  setManualZoomPercent: (percent: number) => void;
222
+ bumpZEditVersion: () => void;
201
223
  setInPoint: (time: number | null) => void;
202
224
  setOutPoint: (time: number | null) => void;
203
225
  reset: () => void;
@@ -210,6 +232,16 @@ interface PlayerState {
210
232
  requestSeek: (time: number) => void;
211
233
  clearSeekRequest: () => void;
212
234
 
235
+ /**
236
+ * Request the timeline to scroll a clip into view (e.g. clicking an
237
+ * already-added asset card in the sidebar). Consumed and cleared by
238
+ * useTimelineRevealClip. The nonce makes repeat requests for the same
239
+ * clip observable so a second click re-reveals after the user scrolls away.
240
+ */
241
+ clipRevealRequest: { elementId: string; nonce: number } | null;
242
+ requestClipReveal: (elementId: string) => void;
243
+ clearClipRevealRequest: () => void;
244
+
213
245
  lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
214
246
  setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
215
247
 
@@ -250,6 +282,7 @@ export interface DomClipChild {
250
282
  /** The manifest sub-comp host clip id this descendant ultimately lives under. */
251
283
  hostId: string;
252
284
  label: string;
285
+ stackingContextId: string;
253
286
  }
254
287
 
255
288
  interface BeatHistoryEntry {
@@ -284,6 +317,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
284
317
  loopEnabled: false,
285
318
  zoomMode: "fit",
286
319
  manualZoomPercent: 100,
320
+ zEditVersion: 0,
287
321
  timelinePps: 100,
288
322
  timelineFitPps: 100,
289
323
  inPoint: null,
@@ -341,6 +375,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
341
375
  requestSeek: (time) => set({ requestedSeekTime: time }),
342
376
  clearSeekRequest: () => set({ requestedSeekTime: null }),
343
377
 
378
+ clipRevealRequest: null,
379
+ requestClipReveal: (elementId) =>
380
+ set((s) => ({
381
+ clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
382
+ })),
383
+ clearClipRevealRequest: () => set({ clipRevealRequest: null }),
384
+
344
385
  lintFindingsByElement: new Map(),
345
386
  setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
346
387
 
@@ -416,6 +457,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
416
457
  writeStudioUiPreferences({ timelineSnapEnabled: enabled });
417
458
  set({ timelineSnapEnabled: enabled });
418
459
  },
460
+ timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
461
+ setTimeDisplayMode: (mode) => {
462
+ writeStudioUiPreferences({ timeDisplayMode: mode });
463
+ set({ timeDisplayMode: mode });
464
+ },
419
465
  pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
420
466
  set((s) => {
421
467
  // Already pinned (or the user manually zoomed) — never clobber that.
@@ -458,6 +504,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
458
504
  }),
459
505
  setManualZoomPercent: (percent) =>
460
506
  set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
507
+ bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
461
508
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
462
509
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
463
510
  setTimelineReady: (ready) => set({ timelineReady: ready }),
@@ -522,6 +569,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
522
569
  activeTool: "select",
523
570
  selectedKeyframes: new Set(),
524
571
  selectedElementIds: new Set(),
572
+ clipRevealRequest: null,
525
573
  keyframeCache: new Map(),
526
574
  beatAnalysis: null,
527
575
  beatEdits: null,
@@ -12,6 +12,7 @@ const {
12
12
  trackStudioRenderStart,
13
13
  trackStudioRazorSplit,
14
14
  trackStudioExpandedClipEdit,
15
+ trackStudioFeedback,
15
16
  } = await import("./events");
16
17
 
17
18
  describe("studio telemetry events", () => {
@@ -69,4 +70,13 @@ describe("studio telemetry events", () => {
69
70
  trackStudioExpandedClipEdit({ action: "resize" });
70
71
  expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
71
72
  });
73
+
74
+ it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
75
+ trackStudioFeedback({ rating });
76
+
77
+ expect(trackEvent).toHaveBeenCalledWith(
78
+ "survey sent",
79
+ expect.objectContaining({ $survey_response: rating, rating_scale: 10 }),
80
+ );
81
+ });
72
82
  });
@@ -67,6 +67,7 @@ export function trackStudioFeedback(props: { rating: number; comment?: string })
67
67
  trackEvent("survey sent", {
68
68
  $survey_id: "studio_experience",
69
69
  $survey_response: props.rating,
70
+ rating_scale: 10,
70
71
  ...(props.comment ? { $survey_response_2: props.comment } : {}),
71
72
  doctor_summary: getBrowserDoctorSummary(),
72
73
  source: "studio",
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
3
+
4
+ describe("shouldDismissAssetPreview", () => {
5
+ const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
6
+
7
+ it("keeps the preview open while nothing moves", () => {
8
+ expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
9
+ });
10
+
11
+ it("tolerates sub-epsilon float noise in currentTime echoes", () => {
12
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
13
+ });
14
+
15
+ it("dismisses when playback starts", () => {
16
+ expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
17
+ });
18
+
19
+ it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
20
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
21
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
22
+ });
23
+
24
+ it("dismisses on a pending out-of-loop seek request", () => {
25
+ expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
26
+ });
27
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
3
+ * the preview is a transient "look at this asset" state, so any playhead
4
+ * activity — starting playback, or seeking/scrubbing away from where the
5
+ * playhead sat when the preview opened — hands focus back to the canvas and
6
+ * closes it.
7
+ *
8
+ * Pure — unit-tested. The overlay captures `openedTime` when the preview
9
+ * opens and feeds every subsequent player-store snapshot through this.
10
+ */
11
+
12
+ export interface AssetPreviewDismissSnapshot {
13
+ isPlaying: boolean;
14
+ currentTime: number;
15
+ /** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
16
+ requestedSeekTime: number | null;
17
+ }
18
+
19
+ /** Tolerance for float noise in currentTime echoes (well under one frame). */
20
+ const TIME_EPSILON_S = 1e-6;
21
+
22
+ export function shouldDismissAssetPreview(
23
+ openedTime: number,
24
+ snapshot: AssetPreviewDismissSnapshot,
25
+ ): boolean {
26
+ if (snapshot.isPlaying) return true;
27
+ if (snapshot.requestedSeekTime !== null) return true;
28
+ return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
29
+ }
@@ -13,7 +13,7 @@ interface DomEditSaveQueueOptions {
13
13
  }
14
14
 
15
15
  export interface DomEditSaveQueue {
16
- enqueue: (save: () => Promise<void>) => Promise<void>;
16
+ enqueue: <T>(save: () => Promise<T>) => Promise<T>;
17
17
  waitForIdle: () => Promise<void>;
18
18
  reset: () => void;
19
19
  destroy: () => void;
@@ -52,10 +52,11 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
52
52
  });
53
53
  };
54
54
 
55
- const run = async (save: () => Promise<void>) => {
55
+ const run = async <T>(save: () => Promise<T>): Promise<T> => {
56
56
  try {
57
- await save();
57
+ const result = await save();
58
58
  if (!breakerOpen) consecutiveFailures = 0;
59
+ return result;
59
60
  } catch (error) {
60
61
  consecutiveFailures += 1;
61
62
  if (consecutiveFailures >= failureThreshold) open(error);
@@ -277,6 +277,88 @@ describe("edit history", () => {
277
277
  expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
278
278
  });
279
279
 
280
+ it("keeps timing-only files when a multi-file GSAP follow-up touches one path", () => {
281
+ const timing = buildEditHistoryEntry({
282
+ projectId: "project-1",
283
+ label: "Move timeline clips",
284
+ kind: "timeline",
285
+ coalesceKey: "timeline-group-move:a,b",
286
+ files: {
287
+ "index.html": { before: "index-before", after: "index-timing" },
288
+ "scene.html": { before: "scene-before", after: "scene-timing" },
289
+ },
290
+ now: 0,
291
+ id: "timing",
292
+ });
293
+ const gsap = buildEditHistoryEntry({
294
+ projectId: "project-1",
295
+ label: "Move timeline clips",
296
+ kind: "timeline",
297
+ coalesceKey: "timeline-group-move:a,b",
298
+ coalesceMs: 10_000,
299
+ files: {
300
+ "index.html": { before: "index-timing", after: "index-timing+gsap" },
301
+ },
302
+ now: 2000,
303
+ id: "gsap",
304
+ });
305
+
306
+ const state = pushEditHistoryEntry(
307
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
308
+ gsap,
309
+ );
310
+
311
+ expect(state.undo).toHaveLength(1);
312
+ expect(Object.keys(state.undo[0].files)).toEqual(["index.html", "scene.html"]);
313
+ expect(state.undo[0].files).toMatchObject({
314
+ "index.html": { before: "index-before", after: "index-timing+gsap" },
315
+ "scene.html": { before: "scene-before", after: "scene-timing" },
316
+ });
317
+ });
318
+
319
+ it("keeps a discontinuous GSAP follow-up separate and preserves foreign bytes on undo", () => {
320
+ const timing = buildEditHistoryEntry({
321
+ projectId: "project-1",
322
+ label: "Move timeline clip",
323
+ kind: "timeline",
324
+ coalesceKey: "timeline-move:clip",
325
+ files: { "index.html": { before: "A", after: "B" } },
326
+ now: 0,
327
+ id: "timing",
328
+ });
329
+ const gsapAfterForeignWrite = buildEditHistoryEntry({
330
+ projectId: "project-1",
331
+ label: "Move timeline clip",
332
+ kind: "timeline",
333
+ coalesceKey: "timeline-move:clip",
334
+ coalesceMs: 10_000,
335
+ files: { "index.html": { before: "F", after: "G" } },
336
+ now: 2000,
337
+ id: "gsap",
338
+ });
339
+
340
+ const state = pushEditHistoryEntry(
341
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
342
+ gsapAfterForeignWrite,
343
+ );
344
+
345
+ expect(state.undo.map((entry) => entry.id)).toEqual(["timing", "gsap"]);
346
+ const undoGsap = undoEditHistory(state, { "index.html": hashEditHistoryContent("G") }, 3000);
347
+ expect(undoGsap.ok).toBe(true);
348
+ expect(undoGsap.filesToWrite).toEqual({ "index.html": "F" });
349
+
350
+ const undoTiming = undoEditHistory(
351
+ undoGsap.state,
352
+ { "index.html": hashEditHistoryContent("F") },
353
+ 4000,
354
+ );
355
+ expect(undoTiming).toMatchObject({
356
+ ok: false,
357
+ reason: "content-mismatch",
358
+ filesToWrite: {},
359
+ });
360
+ });
361
+
280
362
  it("does not merge a slow follow-up without the coalesceMs override", () => {
281
363
  const timing = buildEditHistoryEntry({
282
364
  projectId: "project-1",
@@ -126,9 +126,21 @@ export function pushEditHistoryEntry(
126
126
  previous &&
127
127
  previous.coalesceKey &&
128
128
  previous.coalesceKey === entry.coalesceKey &&
129
- entry.createdAt - previous.createdAt <= coalesceMs
129
+ entry.createdAt - previous.createdAt <= coalesceMs &&
130
+ // A shared key/window is not enough: bytes from an external writer may
131
+ // have landed between the two Studio-owned edits. Only fold a path when
132
+ // the follow-up starts from the exact output the previous entry recorded.
133
+ // Otherwise keep both entries so undo restores the foreign bytes first and
134
+ // the older entry's content check safely blocks at that ownership boundary.
135
+ Object.entries(entry.files).every(([path, snapshot]) => {
136
+ const previousSnapshot = previous.files[path];
137
+ return !previousSnapshot || previousSnapshot.after === snapshot.before;
138
+ })
130
139
  ) {
131
- const files: Record<string, EditHistoryFileSnapshot> = {};
140
+ // A follow-up may touch only a subset of the files in the original edit
141
+ // (for example, only one file in a multi-file timing move has GSAP). Keep
142
+ // previous-only paths in the single coalesced undo entry.
143
+ const files: Record<string, EditHistoryFileSnapshot> = { ...previous.files };
132
144
  for (const [path, snapshot] of Object.entries(entry.files)) {
133
145
  const previousSnapshot = previous.files[path];
134
146
  files[path] = previousSnapshot
@@ -3,9 +3,8 @@
3
3
  import { describe, it, expect, vi } from "vitest";
4
4
  import {
5
5
  applySoftReload,
6
+ applySoftReloadFinalization,
6
7
  ensureMotionPathPluginLoaded,
7
- diffSoftReloadableRestore,
8
- applyUndoRestoreToPreview,
9
8
  } from "./gsapSoftReload";
10
9
 
11
10
  const SCRIPT_TEXT = `
@@ -66,6 +65,7 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
66
65
  iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
67
66
  contentWindow,
68
67
  mockTimeline,
68
+ container,
69
69
  };
70
70
  }
71
71
 
@@ -288,6 +288,58 @@ describe("applySoftReload", () => {
288
288
  });
289
289
  });
290
290
 
291
+ // ── Finalization-only path: seek → rebind → manual edits, with NO script
292
+ // execution — the flashless sync for timing edits that changed no script.
293
+ describe("applySoftReloadFinalization", () => {
294
+ it("seeks, rebinds, and reapplies manual edits without touching any script", () => {
295
+ const { iframe, contentWindow, container, mockTimeline } = buildMockIframe();
296
+ const scriptsBefore = container.querySelectorAll("script").length;
297
+
298
+ expect(applySoftReloadFinalization(iframe, 2.0)).toBe(true);
299
+
300
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
301
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
302
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
303
+ // No script executed or removed; the live timeline was never killed.
304
+ expect(container.querySelectorAll("script").length).toBe(scriptsBefore);
305
+ expect(mockTimeline.kill).not.toHaveBeenCalled();
306
+ expect(contentWindow.__timelines.root).toBe(mockTimeline);
307
+ });
308
+
309
+ it("runs inside __hfSuppressSceneMutations when the runtime provides it", () => {
310
+ const suppress = vi.fn(<T>(fn: () => T): T => fn());
311
+ const { iframe, contentWindow } = buildMockIframe({
312
+ __hfSuppressSceneMutations: suppress,
313
+ });
314
+
315
+ expect(applySoftReloadFinalization(iframe, 1.5)).toBe(true);
316
+
317
+ expect(suppress).toHaveBeenCalledTimes(1);
318
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it("does NOT require gsap — a script-less runtime with the rebind hook works", () => {
322
+ const { iframe, contentWindow } = buildMockIframe({ gsap: undefined });
323
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(true);
324
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
325
+ });
326
+
327
+ it("returns false when the iframe or the rebind hook is unavailable", () => {
328
+ expect(applySoftReloadFinalization(null, 0)).toBe(false);
329
+ const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
330
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
331
+ });
332
+
333
+ it("returns false when the rebind throws (caller full-reloads)", () => {
334
+ const { iframe } = buildMockIframe({
335
+ __hfForceTimelineRebind: vi.fn(() => {
336
+ throw new Error("runtime mid-teardown");
337
+ }),
338
+ });
339
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
340
+ });
341
+ });
342
+
291
343
  function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
292
344
  const head = document.createElement("div");
293
345
  const appendedScripts: HTMLScriptElement[] = [];
@@ -452,116 +504,3 @@ describe("applySoftReload authored-opacity restore", () => {
452
504
  expect(restoreOpacity(el)).toBe("");
453
505
  });
454
506
  });
455
-
456
- // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
457
-
458
- const wrap = (body: string) => `<html><body>${body}</body></html>`;
459
-
460
- describe("diffSoftReloadableRestore", () => {
461
- it("reports the changed id for an attribute/inline-style-only diff", () => {
462
- const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
463
- const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
464
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
465
- });
466
-
467
- it("treats a structural change (added element) as NOT soft-reloadable", () => {
468
- const prev = wrap(`<div id="a">t</div>`);
469
- const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
470
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
471
- });
472
-
473
- it("treats an element text/child change as NOT soft-reloadable", () => {
474
- const prev = wrap(`<div id="a">one</div>`);
475
- const next = wrap(`<div id="a">two</div>`);
476
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
477
- });
478
-
479
- it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
480
- const prev = wrap(
481
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
482
- );
483
- const next = wrap(
484
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
485
- );
486
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
487
- });
488
- });
489
-
490
- function buildLiveIframe(bodyHtml: string) {
491
- const doc = document.implementation.createHTMLDocument("");
492
- doc.body.innerHTML = bodyHtml;
493
- const contentWindow = {
494
- gsap: { timeline: () => {} },
495
- __hfForceTimelineRebind: () => {},
496
- __timelines: {} as Record<string, unknown>,
497
- __player: { getTime: () => 3, seek: vi.fn() },
498
- __hfStudioManualEditsApply: vi.fn(),
499
- };
500
- return {
501
- iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
502
- contentWindow,
503
- doc,
504
- };
505
- }
506
-
507
- describe("applyUndoRestoreToPreview", () => {
508
- const ROOT = "index.html";
509
-
510
- it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
511
- const { iframe, contentWindow, doc } = buildLiveIframe(
512
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
513
- );
514
- const reloadPreview = vi.fn();
515
- const files = {
516
- [ROOT]: {
517
- previous: wrap(
518
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
519
- ),
520
- restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
521
- },
522
- };
523
- const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
524
- expect(outcome).toBe("soft");
525
- expect(reloadPreview).not.toHaveBeenCalled();
526
- // Live element reverted to the restored inline style.
527
- expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
528
- // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
529
- expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
530
- expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
531
- });
532
-
533
- it("full-reloads a multi-file restore", () => {
534
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
535
- const reloadPreview = vi.fn();
536
- const files = {
537
- [ROOT]: {
538
- previous: wrap(`<div id="a" style="x">t</div>`),
539
- restored: wrap(`<div id="a">t</div>`),
540
- },
541
- "scenes/intro.html": { previous: "a", restored: "b" },
542
- };
543
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
544
- expect(reloadPreview).toHaveBeenCalledTimes(1);
545
- });
546
-
547
- it("full-reloads a structural restore (split/delete undo)", () => {
548
- const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
549
- const reloadPreview = vi.fn();
550
- const files = {
551
- [ROOT]: {
552
- previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
553
- restored: wrap(`<div id="a">t</div>`),
554
- },
555
- };
556
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
557
- expect(reloadPreview).toHaveBeenCalledTimes(1);
558
- });
559
-
560
- it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
561
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
562
- const reloadPreview = vi.fn();
563
- const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
564
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
565
- expect(reloadPreview).toHaveBeenCalledTimes(1);
566
- });
567
- });