@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
@@ -28,7 +28,6 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits";
28
28
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
29
29
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
30
30
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
31
- import { readProjectFileContent } from "../utils/studioFileHistory";
32
31
 
33
32
  // ── Helpers ──
34
33
 
@@ -61,37 +60,143 @@ interface RecordEditInput {
61
60
  files: Record<string, { before: string; after: string }>;
62
61
  }
63
62
 
64
- async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
65
- const before = await readProjectFileContent(projectId, batch.sourceFile);
66
- const response = await fetch(
67
- `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
68
- {
69
- method: "POST",
70
- headers: { "Content-Type": "application/json" },
71
- body: JSON.stringify({ patches: batch.patches }),
72
- },
63
+ /** Human-readable identifier for a batch patch target (for the unmatched warning). */
64
+ function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
65
+ return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
66
+ }
67
+
68
+ /**
69
+ * Surface server-reported unmatched patches. The server atomically refuses the
70
+ * whole multi-file gesture; the caller uses `durable: false` to roll back and
71
+ * reload, so report the refusal without turning it into a second failure.
72
+ */
73
+ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
74
+ const unmatchedIds = batch.patches
75
+ .filter((_, index) => matched[index] === false)
76
+ .map(describeBatchPatchTarget);
77
+ if (unmatchedIds.length === 0) return;
78
+ console.warn(
79
+ `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
80
+ `${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
81
+ unmatchedIds.join(", "),
73
82
  );
74
- if (!response.ok) {
75
- const rejection = await readErrorResponseBody(response);
76
- throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
83
+ trackStudioSaveFailure({
84
+ source: "dom_edit",
85
+ error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
86
+ filePath: batch.sourceFile,
87
+ mutationType: "z-reorder-unmatched",
88
+ });
89
+ }
90
+
91
+ interface AtomicElementPatchFile {
92
+ sourceFile: string;
93
+ changed: boolean;
94
+ matched?: boolean[];
95
+ before: string;
96
+ after: string;
97
+ }
98
+
99
+ class AtomicElementPatchConvergenceError extends Error {
100
+ constructor(message: string, options?: { cause?: unknown }) {
101
+ super(message, options);
102
+ this.name = "AtomicElementPatchConvergenceError";
77
103
  }
78
- const result = (await response.json()) as {
79
- changed?: boolean;
80
- content?: string;
81
- };
82
- return {
83
- sourceFile: batch.sourceFile,
84
- changed: result.changed === true,
85
- before,
86
- after: typeof result.content === "string" ? result.content : before,
87
- };
104
+ }
105
+
106
+ // Keep the atomic response contract in one guard so callers do not compose validity.
107
+ // fallow-ignore-next-line complexity
108
+ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
109
+ return (
110
+ typeof value === "object" &&
111
+ value !== null &&
112
+ "sourceFile" in value &&
113
+ typeof value.sourceFile === "string" &&
114
+ "changed" in value &&
115
+ typeof value.changed === "boolean" &&
116
+ (!("matched" in value) ||
117
+ (Array.isArray(value.matched) &&
118
+ value.matched.every((matched) => typeof matched === "boolean"))) &&
119
+ "before" in value &&
120
+ typeof value.before === "string" &&
121
+ "after" in value &&
122
+ typeof value.after === "string" &&
123
+ value.changed === (value.before !== value.after)
124
+ );
125
+ }
126
+
127
+ // This is the single client owner for dispatching and validating the aggregate
128
+ // atomic endpoint. Splitting validation from the request would weaken that wire contract.
129
+ // fallow-ignore-next-line complexity
130
+ async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
131
+ const body = JSON.stringify({ batches });
132
+ try {
133
+ const response = await fetch(
134
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
135
+ {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body,
139
+ },
140
+ );
141
+ if (!response.ok) {
142
+ const rejection = await readErrorResponseBody(response);
143
+ throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
144
+ }
145
+ const result: unknown = await response.json().catch(() => null);
146
+ if (
147
+ typeof result !== "object" ||
148
+ result === null ||
149
+ !("durable" in result) ||
150
+ typeof result.durable !== "boolean" ||
151
+ !("files" in result) ||
152
+ !Array.isArray(result.files) ||
153
+ result.files.length !== batches.length ||
154
+ !result.files.every(isAtomicElementPatchFile) ||
155
+ (!result.durable && result.files.some((file) => file.changed))
156
+ ) {
157
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
158
+ }
159
+ const files = result.files.map((file, index) => {
160
+ const batch = batches[index];
161
+ const matched = file.matched ?? [];
162
+ if (
163
+ !batch ||
164
+ file.sourceFile !== batch.sourceFile ||
165
+ (matched.length !== 0 && matched.length !== batch.patches.length)
166
+ ) {
167
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
168
+ }
169
+ reportUnmatchedBatchPatches(batch, matched);
170
+ return {
171
+ ...file,
172
+ matched,
173
+ allMatched: matched.length === batch.patches.length && matched.every(Boolean),
174
+ };
175
+ });
176
+ return { durable: result.durable, files };
177
+ } catch (error) {
178
+ throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
179
+ }
180
+ }
181
+
182
+ /**
183
+ * A batch is reload-skippable only when it is style-only: every operation is an
184
+ * `inline-style` write. The z-reorder commit applies those exact styles to the
185
+ * live iframe DOM synchronously, so persisting them adds nothing the preview
186
+ * doesn't already show. Any other op type (attribute / text-content / …) can
187
+ * have server-side semantics the live DOM hasn't mirrored — reload for those.
188
+ */
189
+ function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
190
+ return batches.every((batch) =>
191
+ batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
192
+ );
88
193
  }
89
194
 
90
195
  export interface UseDomEditCommitsParams {
91
196
  activeCompPath: string | null;
92
197
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
93
198
  showToast: (message: string, tone?: "error" | "info") => void;
94
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
199
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
95
200
  writeProjectFile: (path: string, content: string) => Promise<void>;
96
201
  domEditSaveTimestampRef: React.MutableRefObject<number>;
97
202
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -331,37 +436,59 @@ export function useDomEditCommits({
331
436
 
332
437
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
333
438
  (batches, options) =>
334
- queueDomEditSave(async () => {
335
- const pid = projectIdRef.current;
336
- if (!pid) throw new Error("No active project");
337
- const unsafeFields = batches.flatMap((batch) =>
338
- batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
339
- );
340
- if (unsafeFields.length > 0) {
341
- showToast("Couldn't save edit because it contains invalid layout values", "error");
342
- throw new DomEditPersistUnsafeValueError(
343
- `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
344
- { alreadyToasted: true },
439
+ queueDomEditSave(
440
+ // One queued transaction owns validation, persistence, history, reload,
441
+ // and its durable result; splitting those phases risks partial commits.
442
+ // fallow-ignore-next-line complexity
443
+ async () => {
444
+ const pid = projectIdRef.current;
445
+ if (!pid) throw new Error("No active project");
446
+ const unsafeFields = batches.flatMap((batch) =>
447
+ batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
345
448
  );
346
- }
449
+ if (unsafeFields.length > 0) {
450
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
451
+ throw new DomEditPersistUnsafeValueError(
452
+ `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
453
+ { alreadyToasted: true },
454
+ );
455
+ }
347
456
 
348
- domEditSaveTimestampRef.current = Date.now();
349
- const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
350
- const files = Object.fromEntries(
351
- results
352
- .filter((result) => result.changed)
353
- .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
354
- );
355
- if (Object.keys(files).length === 0) return;
356
- await editHistory.recordEdit({
357
- label: options.label,
358
- kind: "manual",
359
- coalesceKey: options.coalesceKey,
360
- files,
361
- });
362
- forceReloadSdkSession?.();
363
- reloadPreview();
364
- }).catch((error) => {
457
+ domEditSaveTimestampRef.current = Date.now();
458
+ const atomicResult = await patchElementBatches(pid, batches);
459
+ const allMatched =
460
+ atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
461
+ const files = Object.fromEntries(
462
+ atomicResult.files
463
+ .filter((result) => result.changed)
464
+ .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
465
+ );
466
+ const changed = Object.keys(files).length > 0;
467
+ if (changed) {
468
+ await editHistory.recordEdit({
469
+ label: options.label,
470
+ kind: "manual",
471
+ coalesceKey: options.coalesceKey,
472
+ coalesceMs: options.coalesceMs,
473
+ files,
474
+ });
475
+ forceReloadSdkSession?.();
476
+ }
477
+ const durable = allMatched;
478
+ // A z-only reorder already applied its inline styles to the live iframe
479
+ // DOM (and the store) synchronously, so remounting the iframe here only
480
+ // produces a visible blink. Skip the reload when the caller asked for it
481
+ // AND the persist is provably in sync: style-only ops, every target
482
+ // matched. Any unmatched patch means the live DOM now shows state disk
483
+ // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
484
+ // reload is independently suppressed by domEditSaveTimestampRef above.)
485
+ const skipSafe =
486
+ options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable;
487
+ if (!durable || (changed && !skipSafe)) reloadPreview();
488
+ return { durable, allMatched, changed };
489
+ },
490
+ ).catch((error) => {
491
+ if (error instanceof AtomicElementPatchConvergenceError) reloadPreview();
365
492
  const alreadyToasted =
366
493
  (error instanceof StudioSaveHttpError ||
367
494
  error instanceof DomEditPersistUnsafeValueError) &&
@@ -231,7 +231,9 @@ describe("onReorderShadow source filter", () => {
231
231
  setRightPanelTab: vi.fn(),
232
232
  showToast: vi.fn(),
233
233
  refreshPreviewDocumentVersion: vi.fn(),
234
- queueDomEditSave: vi.fn(async (save: () => Promise<void>) => save()),
234
+ queueDomEditSave: vi.fn(async <T,>(save: () => Promise<T>) => save()) as <T>(
235
+ save: () => Promise<T>,
236
+ ) => Promise<T>,
235
237
  readProjectFile,
236
238
  writeProjectFile: vi.fn(async () => {}),
237
239
  updateEditingFileContent: vi.fn(),
@@ -43,7 +43,7 @@ export interface UseDomEditSessionParams {
43
43
  setRightPanelTab: (tab: RightPanelTab) => void;
44
44
  showToast: (message: string, tone?: "error" | "info") => void;
45
45
  refreshPreviewDocumentVersion: () => void;
46
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
46
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
47
47
  readProjectFile: (path: string) => Promise<string>;
48
48
  writeProjectFile: (path: string, content: string) => Promise<void>;
49
49
  updateEditingFileContent: (path: string, content: string) => void;