@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -15,11 +15,12 @@
15
15
  * Telemetry-only — never writes to disk, never affects the user-visible edit.
16
16
  */
17
17
 
18
+ import { openComposition } from "@hyperframes/sdk";
18
19
  import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
19
20
  import type { PatchOperation } from "./sourcePatcher";
20
21
  import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
21
22
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
22
- import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
23
+ import { trackStudioEvent } from "./studioTelemetry";
23
24
 
24
25
  // ─── Types ────────────────────────────────────────────────────────────────────
25
26
 
@@ -234,95 +235,14 @@ export function sdkResolverShadowCheck(
234
235
  }
235
236
  }
236
237
 
237
- // ─── Attempt counter (denominator for the soak gate) ──────────────────────────
238
- //
239
- // The three emit functions below only fire a PostHog event on divergence —
240
- // parity is silent, by design, to avoid firing on every edit. That leaves no
241
- // way to compute a rate (divergences / attempts): we can count failures but
242
- // never attempts. This counter tracks attempts in memory and rolls them up
243
- // into ONE low-frequency event instead of firing per-attempt, which would
244
- // recreate the exact chattiness problem the divergence-only design avoids.
245
-
246
- const attemptCounts: Record<string, number> = {};
247
-
248
- /**
249
- * Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
250
- * outcome (parity or divergence). No flag check of its own — only ever called
251
- * from inside the three emit functions below, after their own
252
- * STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
253
- */
254
- export function recordAttempt(opLabel: string): void {
255
- attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
256
- ensureAttemptFlushScheduled();
257
- }
258
-
259
- /**
260
- * Return the accumulated attempt counts since the last flush (or `null` if
261
- * nothing has been recorded — no point emitting an empty rollup), and reset
262
- * the counter to empty.
263
- */
264
- export function flushAttemptCounts(): Record<string, number> | null {
265
- const keys = Object.keys(attemptCounts);
266
- if (keys.length === 0) return null;
267
- const snapshot: Record<string, number> = {};
268
- for (const key of keys) {
269
- snapshot[key] = attemptCounts[key];
270
- delete attemptCounts[key];
271
- }
272
- return snapshot;
273
- }
274
-
275
- const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
276
- let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
277
- let attemptVisibilityHandler: (() => void) | null = null;
278
-
279
- function flushAndEmitAttempts(): void {
280
- const counts = flushAttemptCounts();
281
- if (counts === null) return;
282
- trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
283
- }
284
-
285
- // Lazily starts the rollup timer + visibilitychange listener on the FIRST
286
- // attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
287
- // start, so a session that never exercises the tripwire never runs a
288
- // background timer.
289
- function ensureAttemptFlushScheduled(): void {
290
- if (!attemptFlushTimer) {
291
- attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
292
- }
293
- if (!attemptVisibilityHandler && typeof document !== "undefined") {
294
- attemptVisibilityHandler = () => {
295
- if (document.visibilityState !== "hidden") return;
296
- flushAndEmitAttempts();
297
- // studioTelemetry.ts registers its own visibilitychange listener (on
298
- // window, at module load) that drains its queue via sendBeacon. Listener
299
- // execution order between that handler and this one (on document,
300
- // registered lazily) is not something to rely on — whichever runs
301
- // first could otherwise beacon-flush before or after this rollup lands
302
- // in the queue. Forcing a beacon flush here makes delivery of this
303
- // rollup event correct regardless of that order.
304
- flushViaBeacon();
305
- };
306
- document.addEventListener("visibilitychange", attemptVisibilityHandler);
307
- }
308
- }
309
-
310
- /**
311
- * Test-only: clears the lazy timer/listener singleton state so tests can
312
- * verify the "starts on first attempt" behavior in isolation, without an
313
- * earlier test's real-timer interval (or visibilitychange listener) silently
314
- * surviving into a later test. Does NOT touch attemptCounts — only the
315
- * scheduling state. Not part of the public module contract; only imported
316
- * from sdkResolverShadow.test.ts.
317
- */
318
- export function __resetAttemptSchedulingForTests(): void {
319
- if (attemptFlushTimer) clearInterval(attemptFlushTimer);
320
- attemptFlushTimer = null;
321
- if (attemptVisibilityHandler && typeof document !== "undefined") {
322
- document.removeEventListener("visibilitychange", attemptVisibilityHandler);
323
- }
324
- attemptVisibilityHandler = null;
325
- }
238
+ // Attempt counting (the soak-gate denominator) lives in sdkResolverAttempts.ts;
239
+ // re-exported here so existing consumers/tests keep one import surface.
240
+ export {
241
+ recordAttempt,
242
+ flushAttemptCounts,
243
+ __resetAttemptSchedulingForTests,
244
+ } from "./sdkResolverAttempts";
245
+ import { recordAttempt } from "./sdkResolverAttempts";
326
246
 
327
247
  // ─── Telemetry ────────────────────────────────────────────────────────────────
328
248
 
@@ -380,15 +300,41 @@ function reportEmptySession(session: Composition, opLabel: string): boolean {
380
300
  return true;
381
301
  }
382
302
 
303
+ /** Shape shared by every resolver-shadow entry point's optional path pair. */
304
+ export interface ResolverShadowPaths {
305
+ targetPath?: string;
306
+ compositionPath?: string | null;
307
+ }
308
+
309
+ /**
310
+ * Cross-file edit: the session models ONLY the active composition, so a
311
+ * target living in another file is structurally unresolvable — the cutover
312
+ * gates decline it (wrongCompositionFile) and it can never cut over. Every
313
+ * tripwire entry point skips entirely (no event, no attempt): the op belongs
314
+ * in neither the divergence count nor the attempt denominator of the soak
315
+ * rate. PostHog 0.7.41: one cross-file editing session emitted 479 false
316
+ * element_not_found events before this rule existed. Callers that pass no
317
+ * paths (isolated consumers/tests) run as before.
318
+ */
319
+ function isCrossFileEdit(paths?: ResolverShadowPaths): boolean {
320
+ return (
321
+ paths?.targetPath !== undefined &&
322
+ paths.compositionPath != null &&
323
+ paths.targetPath !== paths.compositionPath
324
+ );
325
+ }
326
+
383
327
  export function runResolverShadow(
384
328
  session: Composition,
385
329
  hfId: string | null | undefined,
386
330
  ops: PatchOperation[],
387
331
  sourceContent?: string,
332
+ paths?: ResolverShadowPaths,
388
333
  ): void {
389
334
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
390
335
  if (!hfId) return;
391
336
  try {
337
+ if (isCrossFileEdit(paths)) return;
392
338
  if (reportEmptySession(session, "dom-edit")) return;
393
339
  recordAttempt("dom-edit");
394
340
  const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
@@ -434,15 +380,41 @@ export function runResolverShadow(
434
380
  *
435
381
  * No-op when the shadow flag is off; never throws; never mutates the session.
436
382
  */
383
+ /**
384
+ * Source-truth check for a missed hf-id: read the target file and decide
385
+ * whether the miss is a runtime-generated node (id absent from source —
386
+ * suppress, the SDK cannot model it by design) or a reportable divergence
387
+ * (id present → strict attribute count for the event; read failure → fail
388
+ * open, tagged). Mirrors checkAnimationIdOnDisk's error discipline.
389
+ */
390
+ async function checkHfIdInSource(
391
+ hfId: string,
392
+ readSource?: () => Promise<string | undefined>,
393
+ ): Promise<{ suppress: boolean; strictCount?: number; sourceReadFailed: boolean }> {
394
+ if (!readSource) return { suppress: false, sourceReadFailed: false };
395
+ let source: string | undefined;
396
+ try {
397
+ source = await readSource();
398
+ } catch {
399
+ return { suppress: false, sourceReadFailed: true }; // fail-open
400
+ }
401
+ if (source === undefined) return { suppress: false, sourceReadFailed: false };
402
+ // Loose substring match — biased toward keeping signal (see sourceLooseMatchOnly).
403
+ if (!source.includes(hfId)) return { suppress: true, sourceReadFailed: false };
404
+ return { suppress: false, strictCount: countHfIdInSource(source, hfId), sourceReadFailed: false };
405
+ }
406
+
437
407
  export async function recordResolverParity(
438
408
  session: Composition | null | undefined,
439
409
  hfId: string | null | undefined,
440
410
  opLabel: string,
441
411
  readSource?: () => Promise<string | undefined>,
412
+ paths?: ResolverShadowPaths,
442
413
  ): Promise<void> {
443
414
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
444
415
  if (!session || !hfId) return;
445
416
  try {
417
+ if (isCrossFileEdit(paths)) return;
446
418
  if (reportEmptySession(session, opLabel)) return;
447
419
  recordAttempt(opLabel);
448
420
  if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
@@ -453,19 +425,10 @@ export async function recordResolverParity(
453
425
  // state this field exists to diagnose.
454
426
  const sessionElementCount = session.getElements().length;
455
427
  // Cheap check passed above, so the source read only runs on a real divergence.
456
- let source: string | undefined;
457
- let sourceReadFailed = false;
458
- if (readSource) {
459
- try {
460
- source = await readSource();
461
- } catch {
462
- source = undefined; // fail-open: a read error must not drop a real divergence
463
- sourceReadFailed = true;
464
- }
465
- }
466
- // Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
467
- if (source !== undefined && !source.includes(hfId)) return;
468
- const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
428
+ // Runtime-generated node the static parse can't model → suppressed inside.
429
+ const verdict = await checkHfIdInSource(hfId, readSource);
430
+ if (verdict.suppress) return;
431
+ const { strictCount, sourceReadFailed } = verdict;
469
432
  trackStudioEvent("sdk_resolver_shadow", {
470
433
  hfId,
471
434
  opLabel,
@@ -505,24 +468,94 @@ export async function recordResolverParity(
505
468
  *
506
469
  * No-op when the shadow flag is off; never throws; never mutates the session.
507
470
  */
508
- export function recordAnimationResolverParity(
471
+ /**
472
+ * Disk-truth disambiguation for a missed animationId: the GSAP panel computes
473
+ * animationIds from the CURRENT on-disk script (the server read path re-reads
474
+ * per render), while the session's parsed id space reflects the last session
475
+ * reload. An edit that shifts tween positions shifts every
476
+ * `selector-method-position` id, so a panel op landing before the reload
477
+ * targets an id the session has never seen — a sync gap, not a resolver bug
478
+ * (the 0.7.48 keyframe-op class). `staleSession` = a fresh parse of the file
479
+ * resolves the id (suppress); `diskChecked` = the fresh parse ran and ALSO
480
+ * missed (a trustworthy real divergence); read errors fail open.
481
+ */
482
+ async function checkAnimationIdOnDisk(
483
+ animationId: string,
484
+ sourcePromise: Promise<string | undefined>,
485
+ ): Promise<{ staleSession: boolean; diskChecked: boolean; sourceReadFailed: boolean }> {
486
+ let source: string | undefined;
487
+ try {
488
+ source = await sourcePromise;
489
+ } catch {
490
+ return { staleSession: false, diskChecked: false, sourceReadFailed: true };
491
+ }
492
+ if (source === undefined) {
493
+ return { staleSession: false, diskChecked: false, sourceReadFailed: false };
494
+ }
495
+ // A parse failure (malformed script the user just introduced) means the
496
+ // source can't serve as ground truth — treat it exactly like a read failure
497
+ // (fail open, tagged) instead of letting the outer never-propagate catch
498
+ // swallow the whole divergence event.
499
+ let disk: Composition;
500
+ try {
501
+ disk = await openComposition(source, { history: false });
502
+ } catch {
503
+ return { staleSession: false, diskChecked: false, sourceReadFailed: true };
504
+ }
505
+ try {
506
+ return {
507
+ staleSession: disk.getAllAnimationIds().has(animationId),
508
+ diskChecked: true,
509
+ sourceReadFailed: false,
510
+ };
511
+ } finally {
512
+ disk.dispose();
513
+ }
514
+ }
515
+
516
+ export async function recordAnimationResolverParity(
509
517
  session: Composition | null | undefined,
510
518
  animationId: string,
511
519
  opLabel: string,
512
- ): void {
520
+ readSource?: () => Promise<string | undefined>,
521
+ paths?: ResolverShadowPaths,
522
+ ): Promise<void> {
513
523
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
514
524
  if (!session || !animationId) return;
515
525
  try {
526
+ if (isCrossFileEdit(paths)) return;
516
527
  recordAttempt(opLabel);
517
528
  const elements = session.getElements();
518
529
  const resolves =
519
530
  elements.some((el) => el.animationIds.includes(animationId)) ||
520
531
  session.getAllAnimationIds().has(animationId);
521
532
  if (resolves) return; // SDK locates the animation — parity
533
+ // Capture BEFORE any await (fire-and-forget caller mutates right after).
534
+ const sessionElementCount = elements.length;
535
+ let diskChecked = false;
536
+ let sourceReadFailed = false;
537
+ if (readSource) {
538
+ // Start the read SYNCHRONOUSLY, before returning control to the caller:
539
+ // the caller's cutover persist writes the new content to this same file
540
+ // moments later, and a read dispatched after that write would check the
541
+ // POST-edit disk (e.g. a remove op's target legitimately gone → false
542
+ // "genuine divergence"). Dispatching the request here, in the same sync
543
+ // prologue as the miss check, orders it ahead of the caller's write.
544
+ const sourcePromise = (async () => readSource())();
545
+ const verdict = await checkAnimationIdOnDisk(animationId, sourcePromise);
546
+ if (verdict.staleSession) return; // sync gap, not a resolver bug — suppress
547
+ diskChecked = verdict.diskChecked;
548
+ sourceReadFailed = verdict.sourceReadFailed;
549
+ }
522
550
  trackStudioEvent("sdk_resolver_shadow", {
523
551
  animationId,
524
552
  opLabel,
525
- sessionElementCount: elements.length,
553
+ sessionElementCount,
554
+ // The id missed a fresh parse of the on-disk file too — this is a real
555
+ // resolver divergence, not session staleness. Absent when no reader was
556
+ // wired or the read failed (see sourceReadFailed).
557
+ ...(diskChecked ? { diskChecked: true } : {}),
558
+ ...(sourceReadFailed ? { sourceReadFailed: true } : {}),
526
559
  mismatchCount: 1,
527
560
  mismatches: JSON.stringify([
528
561
  { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
@@ -74,11 +74,8 @@ describe("persistSlideshowManifest — op construction", () => {
74
74
  const deps = makeDeps(writeProjectFile);
75
75
  const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
76
76
 
77
- const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
78
-
79
77
  await persistSlideshowManifest({
80
78
  manifest: { slides: [{ sceneId: "scene-2" }] },
81
- sdkSession: mockSession as never,
82
79
  originalContent: originalHtml,
83
80
  targetPath: "/proj/comp.html",
84
81
  deps,
@@ -96,11 +93,8 @@ describe("persistSlideshowManifest — op construction", () => {
96
93
  const baseHtml = "<html><head></head><body></body></html>";
97
94
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
98
95
  const deps = makeDeps(writeProjectFile);
99
- const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
100
-
101
96
  await persistSlideshowManifest({
102
97
  manifest: { slides: [{ sceneId: "new-scene" }] },
103
- sdkSession: mockSession as never,
104
98
  originalContent: baseHtml,
105
99
  targetPath: "/proj/comp.html",
106
100
  deps,
@@ -122,11 +116,8 @@ describe("persistSlideshowManifest — op construction", () => {
122
116
 
123
117
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
124
118
  const deps = makeDeps(writeProjectFile);
125
- const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
126
-
127
119
  await persistSlideshowManifest({
128
120
  manifest: { slides: [{ sceneId: "fresh" }] },
129
- sdkSession: mockSession as never,
130
121
  originalContent: twoIslandHtml,
131
122
  targetPath: "/proj/comp.html",
132
123
  deps,
@@ -5,7 +5,7 @@
5
5
  * embedded in the composition HTML. Because <script> nodes are not tracked by
6
6
  * the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
7
7
  * op. Instead we:
8
- * 1. Call `sdkSession.serialize()` to get the current HTML.
8
+ * 1. Acquire the per-file transaction and read the current on-disk HTML.
9
9
  * 2. Replace or insert the island with the new manifest JSON.
10
10
  * 3. Write via `persistSdkSerialize` — the same low-level writer used by the
11
11
  * other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
@@ -23,7 +23,6 @@ import {
23
23
  parseSlideshowManifest,
24
24
  slideshowIslandRegex,
25
25
  } from "@hyperframes/core/slideshow";
26
- import type { Composition } from "@hyperframes/sdk";
27
26
  import type { CutoverDeps } from "./sdkCutover";
28
27
  import { persistSdkSerialize } from "./sdkCutover";
29
28
 
@@ -46,8 +45,6 @@ export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
46
45
 
47
46
  export interface PersistSlideshowArgs {
48
47
  manifest: SlideshowManifest;
49
- /** Live SDK Composition session — used only to read the current serialized HTML. */
50
- sdkSession: Pick<Composition, "serialize">;
51
48
  /** Exact on-disk bytes for the undo-history `before` baseline. */
52
49
  originalContent: string;
53
50
  targetPath: string;
@@ -62,7 +59,7 @@ export interface PersistSlideshowArgs {
62
59
  }
63
60
 
64
61
  export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
65
- const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
62
+ const { manifest, originalContent, targetPath, deps, label, coalesceKey } = args;
66
63
 
67
64
  const islandHtml = buildSlideshowIslandHtml(manifest);
68
65
 
@@ -77,26 +74,22 @@ export async function persistSlideshowManifest(args: PersistSlideshowArgs): Prom
77
74
  throw new Error(`refusing to persist invalid slideshow manifest: ${(err as Error).message}`);
78
75
  }
79
76
 
80
- const current = sdkSession.serialize();
81
-
82
- // Strip ALL existing islands (handles the case where two stale islands
83
- // accumulated) then insert exactly one fresh island.
84
- const stripped = current.replace(ISLAND_RE, "");
85
-
86
- let after: string;
87
- const bodyClose = stripped.lastIndexOf("</body>");
88
- if (bodyClose !== -1) {
89
- after = stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose);
90
- } else {
91
- after = stripped + "\n" + islandHtml;
92
- }
93
-
94
- // No-op gate: if the rewritten HTML is byte-identical to the current serialized
95
- // HTML, skip the write avoids a spurious disk write and a no-op undo entry.
96
- if (after === current) return;
97
-
98
- await persistSdkSerialize(after, targetPath, originalContent, deps, {
99
- label: label ?? "Edit slideshow",
100
- ...(coalesceKey ? { coalesceKey } : {}),
101
- });
77
+ await persistSdkSerialize(
78
+ (current) => {
79
+ // Strip ALL existing islands (handles the case where two stale islands
80
+ // accumulated) then insert exactly one fresh island.
81
+ const stripped = current.replace(ISLAND_RE, "");
82
+ const bodyClose = stripped.lastIndexOf("</body>");
83
+ return bodyClose !== -1
84
+ ? stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose)
85
+ : stripped + "\n" + islandHtml;
86
+ },
87
+ targetPath,
88
+ originalContent,
89
+ deps,
90
+ {
91
+ label: label ?? "Edit slideshow",
92
+ ...(coalesceKey ? { coalesceKey } : {}),
93
+ },
94
+ );
102
95
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
3
+ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
3
4
 
4
5
  describe("saveProjectFilesWithHistory", () => {
5
6
  it("reads before content, writes after content, and records a history entry", async () => {
@@ -153,4 +154,44 @@ describe("saveProjectFilesWithHistory", () => {
153
154
  ["index.html", "index-before"],
154
155
  ]);
155
156
  });
157
+
158
+ it("reads and writes after an earlier same-file mutation completes", async () => {
159
+ let disk = "before";
160
+ let release!: () => void;
161
+ const blocked = new Promise<void>((resolve) => {
162
+ release = resolve;
163
+ });
164
+ const writeFile = vi.fn(async (_path: string, content: string) => {
165
+ disk = content;
166
+ });
167
+ const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
168
+ await blocked;
169
+ disk = "sdk-after";
170
+ });
171
+ const readFile = vi.fn(async () => disk);
172
+ const recordEdit = vi.fn();
173
+
174
+ const save = saveProjectFilesWithHistory({
175
+ projectId: "project-1",
176
+ label: "Edit source",
177
+ kind: "source",
178
+ files: { "index.html": "editor-after" },
179
+ readFile,
180
+ writeFile,
181
+ recordEdit,
182
+ });
183
+
184
+ await Promise.resolve();
185
+ expect(readFile).not.toHaveBeenCalled();
186
+ release();
187
+ await priorMutation;
188
+ await save;
189
+
190
+ expect(disk).toBe("editor-after");
191
+ expect(recordEdit).toHaveBeenCalledWith(
192
+ expect.objectContaining({
193
+ files: { "index.html": { before: "sdk-after", after: "editor-after" } },
194
+ }),
195
+ );
196
+ });
156
197
  });
@@ -1,5 +1,6 @@
1
1
  import type { MutableRefObject } from "react";
2
2
  import type { EditHistoryKind } from "./editHistory";
3
+ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
3
4
  import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
4
5
 
5
6
  export interface RecordEditInput {
@@ -55,37 +56,39 @@ export async function saveProjectFilesWithHistory({
55
56
  writeFile,
56
57
  recordEdit,
57
58
  }: SaveProjectFilesWithHistoryInput): Promise<string[]> {
58
- const snapshots: Record<string, { before: string; after: string }> = {};
59
- for (const [path, after] of Object.entries(files)) {
60
- const before = await readFile(path);
61
- if (before !== after) {
62
- snapshots[path] = { before, after };
59
+ return serializeStudioFileMutations(writeFile, Object.keys(files), async () => {
60
+ const snapshots: Record<string, { before: string; after: string }> = {};
61
+ for (const [path, after] of Object.entries(files)) {
62
+ const before = await readFile(path);
63
+ if (before !== after) {
64
+ snapshots[path] = { before, after };
65
+ }
63
66
  }
64
- }
65
67
 
66
- const changedPaths = Object.keys(snapshots);
67
- if (changedPaths.length === 0) return [];
68
-
69
- const writtenPaths: string[] = [];
70
- try {
71
- for (const path of changedPaths) {
72
- await writeFile(path, snapshots[path].after);
73
- writtenPaths.push(path);
74
- }
68
+ const changedPaths = Object.keys(snapshots);
69
+ if (changedPaths.length === 0) return [];
75
70
 
76
- await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
77
- } catch (error) {
71
+ const writtenPaths: string[] = [];
78
72
  try {
79
- for (const path of writtenPaths.reverse()) {
80
- await writeFile(path, snapshots[path].before);
73
+ for (const path of changedPaths) {
74
+ await writeFile(path, snapshots[path].after);
75
+ writtenPaths.push(path);
81
76
  }
82
- } catch (rollbackError) {
83
- throw new AggregateError(
84
- [error, rollbackError],
85
- "Failed to save project files and rollback did not complete",
86
- );
77
+
78
+ await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
79
+ } catch (error) {
80
+ try {
81
+ for (const path of writtenPaths.reverse()) {
82
+ await writeFile(path, snapshots[path].before);
83
+ }
84
+ } catch (rollbackError) {
85
+ throw new AggregateError(
86
+ [error, rollbackError],
87
+ "Failed to save project files and rollback did not complete",
88
+ );
89
+ }
90
+ throw error;
87
91
  }
88
- throw error;
89
- }
90
- return changedPaths;
92
+ return changedPaths;
93
+ });
91
94
  }
@@ -0,0 +1,45 @@
1
+ export type StudioProjectFileWriter = (path: string, content: string) => Promise<void>;
2
+
3
+ // A writer is bound to one project (see useFileManager), so writer identity plus
4
+ // path is the complete durable-file identity. Sharing this queue across SDK and
5
+ // legacy mutation paths prevents independent read-modify-write transactions from
6
+ // cloning the same stale bytes and overwriting one another.
7
+ const mutationQueues = new WeakMap<StudioProjectFileWriter, Map<string, Promise<unknown>>>();
8
+
9
+ export function serializeStudioFileMutation<T>(
10
+ writer: StudioProjectFileWriter,
11
+ targetPath: string,
12
+ task: () => Promise<T>,
13
+ ): Promise<T> {
14
+ let queues = mutationQueues.get(writer);
15
+ if (!queues) {
16
+ queues = new Map();
17
+ mutationQueues.set(writer, queues);
18
+ }
19
+ const prior = queues.get(targetPath) ?? Promise.resolve();
20
+ const next = prior.then(task, task);
21
+ queues.set(targetPath, next);
22
+ void next.then(
23
+ () => {
24
+ if (queues?.get(targetPath) === next) queues.delete(targetPath);
25
+ },
26
+ () => {
27
+ if (queues?.get(targetPath) === next) queues.delete(targetPath);
28
+ },
29
+ );
30
+ return next;
31
+ }
32
+
33
+ export function serializeStudioFileMutations<T>(
34
+ writer: StudioProjectFileWriter,
35
+ targetPaths: readonly string[],
36
+ task: () => Promise<T>,
37
+ ): Promise<T> {
38
+ const paths = [...new Set(targetPaths)].sort();
39
+ const acquire = (index: number): Promise<T> => {
40
+ const path = paths[index];
41
+ if (path === undefined) return task();
42
+ return serializeStudioFileMutation(writer, path, () => acquire(index + 1));
43
+ };
44
+ return acquire(0);
45
+ }
@@ -48,6 +48,51 @@ describe("findMatchingTimelineElementId", () => {
48
48
  it("returns null for an unmatched element in index.html", () => {
49
49
  expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
50
50
  });
51
+
52
+ it("resolves the correct repeated composition host by domId, not the first host sharing the same compositionSrc", () => {
53
+ // Two hosts import the SAME sub-composition — only compositionSrc alone
54
+ // can't tell them apart, but each still has its own domId (as any
55
+ // element does). Selecting the SECOND host must not collapse to the
56
+ // first one just because an earlier, unrelated element also happens to
57
+ // share its compositionSrc.
58
+ const els = [
59
+ el({ id: "host-a", domId: "host-a", sourceFile: "index.html", compositionSrc: "scene.html" }),
60
+ el({ id: "host-b", domId: "host-b", sourceFile: "index.html", compositionSrc: "scene.html" }),
61
+ ];
62
+ expect(
63
+ findMatchingTimelineElementId(
64
+ {
65
+ id: "host-b",
66
+ sourceFile: "index.html",
67
+ compositionSrc: "scene.html",
68
+ isCompositionHost: true,
69
+ },
70
+ els,
71
+ ),
72
+ ).toBe("host-b");
73
+ });
74
+
75
+ it("falls back to compositionSrc-only matching when the host selection has neither a domId nor a selector", () => {
76
+ const els = [
77
+ el({
78
+ id: "host-only",
79
+ domId: undefined,
80
+ sourceFile: "index.html",
81
+ compositionSrc: "scene.html",
82
+ }),
83
+ ];
84
+ expect(
85
+ findMatchingTimelineElementId(
86
+ {
87
+ id: undefined,
88
+ sourceFile: "index.html",
89
+ compositionSrc: "scene.html",
90
+ isCompositionHost: true,
91
+ },
92
+ els,
93
+ ),
94
+ ).toBe("host-only");
95
+ });
51
96
  });
52
97
 
53
98
  describe("findTimelineIdByAncestor", () => {