@hyperframes/studio 0.8.98 → 0.8.99

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 (51) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
  3. package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +542 -373
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/cropResize.test.ts +129 -0
  13. package/src/components/editor/cropResize.ts +143 -0
  14. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  15. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  17. package/src/components/editor/elementPositionOffset.ts +5 -4
  18. package/src/components/editor/manualEdits.test.ts +22 -8
  19. package/src/components/editor/manualEditsDom.ts +27 -11
  20. package/src/components/editor/manualEditsParsing.ts +0 -4
  21. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  22. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  23. package/src/hooks/domSelectionTestHarness.ts +8 -0
  24. package/src/hooks/draggedGsapPosition.ts +4 -2
  25. package/src/hooks/gsapDragCommit.ts +6 -6
  26. package/src/hooks/gsapEditOutcome.ts +6 -0
  27. package/src/hooks/gsapResizeIntercept.ts +5 -5
  28. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  29. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  30. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  32. package/src/hooks/timelineTimingSync.ts +40 -4
  33. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  34. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  35. package/src/hooks/useCropOverlay.ts +8 -2
  36. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  37. package/src/hooks/useDomEditCommits.ts +1 -0
  38. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  39. package/src/hooks/useDomEditSession.ts +2 -0
  40. package/src/hooks/useDomGeometryCommit.ts +1 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  42. package/src/hooks/useDomGeometryCommits.ts +5 -0
  43. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  44. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  45. package/src/hooks/useGsapAwareEditing.ts +37 -23
  46. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  47. package/src/hooks/useTimelineEditing.ts +13 -13
  48. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  49. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  50. package/src/utils/rounding.ts +5 -0
  51. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -19,6 +19,7 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
19
19
  import {
20
20
  captureDurationRollback,
21
21
  finishClipTimingFallback,
22
+ sdkTimingGsapSync,
22
23
  readFileContent,
23
24
  syncPreviewContentDuration,
24
25
  } from "./timelineTimingSync";
@@ -229,10 +230,8 @@ export function useTimelineEditing({
229
230
  );
230
231
  };
231
232
  const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
232
- const finishMoveGsapSync = () =>
233
- // Every timing writer converges the same GSAP positions after its
234
- // durable clip-start commit. The SDK owns the attribute write; this
235
- // sync owns only the dependent animation rewrite and preview refresh.
233
+ const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
234
+ // One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
236
235
  finishClipTimingFallback({
237
236
  iframe: previewIframeRef.current,
238
237
  reloadPreview,
@@ -244,15 +243,15 @@ export function useTimelineEditing({
244
243
  recordEdit,
245
244
  writeProjectFile,
246
245
  edit: { kind: "shift", delta: updates.start - element.start },
246
+ sdkGsap,
247
247
  }).finally(() => invalidateGsapCache?.());
248
248
  const moveFallback = () =>
249
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
250
- finishMoveGsapSync,
249
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
250
+ finishMoveGsapSync(),
251
251
  );
252
252
  return reorderDone
253
253
  .then(() => {
254
- // The SDK setTiming path writes start only — a lane change must take
255
- // the fallback, whose patch builder writes data-track-index too.
254
+ // SDK setTiming writes start only; a lane change needs the fallback's track patch.
256
255
  if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
257
256
  return sdkTimingPersist(
258
257
  element.hfId,
@@ -272,7 +271,7 @@ export function useTimelineEditing({
272
271
  { label: "Move timeline clip", coalesceKey, skipRefresh: true },
273
272
  ).then((result) => {
274
273
  if (!cutoverCommittedOrThrow(result)) return moveFallback();
275
- return finishMoveGsapSync();
274
+ return finishMoveGsapSync(sdkTimingGsapSync(result));
276
275
  });
277
276
  }
278
277
  return moveFallback();
@@ -337,7 +336,7 @@ export function useTimelineEditing({
337
336
  // script (timing-only resize) — same no-flash path as move; full reload is
338
337
  // the fallback.
339
338
  const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
340
- const finishResizeGsapSync = () =>
339
+ const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
341
340
  finishClipTimingFallback({
342
341
  iframe: previewIframeRef.current,
343
342
  reloadPreview,
@@ -353,10 +352,11 @@ export function useTimelineEditing({
353
352
  from: { start: toCompositionTime(element, element.start), duration: element.duration },
354
353
  to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
355
354
  },
355
+ sdkGsap,
356
356
  }).finally(() => invalidateGsapCache?.());
357
357
  const resizeFallback = () =>
358
- enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
359
- finishResizeGsapSync,
358
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
359
+ finishResizeGsapSync(),
360
360
  );
361
361
  const persistDone =
362
362
  sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
@@ -378,7 +378,7 @@ export function useTimelineEditing({
378
378
  { label: "Resize timeline clip", coalesceKey, skipRefresh: true },
379
379
  ).then((result) => {
380
380
  if (!cutoverCommittedOrThrow(result)) return resizeFallback();
381
- return finishResizeGsapSync();
381
+ return finishResizeGsapSync(sdkTimingGsapSync(result));
382
382
  })
383
383
  : resizeFallback();
384
384
  return persistDone.catch((error) => {
@@ -24,11 +24,13 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
24
24
  import {
25
25
  captureDurationRollback,
26
26
  finishGroupTimingGsapFallback,
27
+ sdkTimingGsapSync,
27
28
  readFileContent,
28
29
  scaleGsapPositions,
29
30
  shiftGsapPositions,
30
31
  syncPreviewContentDuration,
31
32
  } from "./timelineTimingSync";
33
+ import type { GsapMutationStatus } from "./gsapMutationClient";
32
34
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
33
35
 
34
36
  export interface TimelineGroupMoveChange {
@@ -186,8 +188,8 @@ export function useTimelineGroupEditing({
186
188
 
187
189
  // Shared SDK fast path for group move/resize: eligible when nothing needs the
188
190
  // server (no root-duration growth, one shared file, every change SDK-addressable
189
- // and `eligible` per the caller's own gate). Returns whether the SDK handled it;
190
- // false → caller falls through to the server batch persist.
191
+ // and `eligible` per the caller's own gate). Returns the SDK's GSAP sync (null: the
192
+ // server syncs) when it handled the edit; null → caller falls through to the server.
191
193
  const trySdkBatchPersist = useCallback(
192
194
  async (input: {
193
195
  changes: readonly { element: TimelineElement }[];
@@ -200,14 +202,14 @@ export function useTimelineGroupEditing({
200
202
  label: string;
201
203
  coalesceKey: string;
202
204
  coalesceMs?: number;
203
- }): Promise<boolean> => {
205
+ }): Promise<{ sdkGsap: GsapMutationStatus | null } | null> => {
204
206
  const sharedPath = allChangesSharePath(input.changes, activeCompPath);
205
207
  const canUseSdk =
206
208
  !input.needsExtension &&
207
209
  sharedPath !== null &&
208
210
  input.eligible &&
209
211
  input.sdkChanges.every((change) => change !== null);
210
- if (!canUseSdk) return false;
212
+ if (!canUseSdk) return null;
211
213
  const result = await sdkTimingBatchPersist(
212
214
  input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
213
215
  sharedPath,
@@ -227,7 +229,7 @@ export function useTimelineGroupEditing({
227
229
  skipRefresh: true,
228
230
  },
229
231
  );
230
- return cutoverCommittedOrThrow(result);
232
+ return cutoverCommittedOrThrow(result) ? { sdkGsap: sdkTimingGsapSync(result) } : null;
231
233
  },
232
234
  [
233
235
  activeCompPath,
@@ -289,7 +291,7 @@ export function useTimelineGroupEditing({
289
291
  const label = options?.label ?? "Move timeline clips";
290
292
  return enqueueGroupOperation(label, async (projectId) => {
291
293
  await options?.beforeTiming;
292
- const handledBySdk = await trySdkBatchPersist({
294
+ const sdk = await trySdkBatchPersist({
293
295
  changes,
294
296
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
295
297
  start: toAuthoredStart(change.element, change.start),
@@ -300,7 +302,7 @@ export function useTimelineGroupEditing({
300
302
  coalesceKey,
301
303
  coalesceMs,
302
304
  });
303
- if (!handledBySdk) {
305
+ if (!sdk) {
304
306
  await persistServerBatch(
305
307
  projectId,
306
308
  label,
@@ -339,6 +341,7 @@ export function useTimelineGroupEditing({
339
341
  writeProjectFile,
340
342
  activeCompPath,
341
343
  changes,
344
+ sdkGsap: sdk?.sdkGsap,
342
345
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
343
346
  mutateChange: (change, changePath) => {
344
347
  const delta = change.start - change.element.start;
@@ -408,7 +411,7 @@ export function useTimelineGroupEditing({
408
411
  const coalesceMs = options?.coalesceMs;
409
412
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
410
413
  await options?.beforeTiming;
411
- const handledBySdk = await trySdkBatchPersist({
414
+ const sdk = await trySdkBatchPersist({
412
415
  changes,
413
416
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
414
417
  start: toAuthoredStart(change.element, change.start),
@@ -420,7 +423,7 @@ export function useTimelineGroupEditing({
420
423
  coalesceKey,
421
424
  coalesceMs,
422
425
  });
423
- if (!handledBySdk) {
426
+ if (!sdk) {
424
427
  await persistServerBatch(
425
428
  projectId,
426
429
  "Resize timeline clips",
@@ -451,6 +454,7 @@ export function useTimelineGroupEditing({
451
454
  writeProjectFile,
452
455
  activeCompPath,
453
456
  changes,
457
+ sdkGsap: sdk?.sdkGsap,
454
458
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
455
459
  mutateChange: (change, changePath) => {
456
460
  const domId = change.element.domId;
@@ -2,7 +2,13 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ elementScroll,
7
+ observeElementOffset,
8
+ observeElementRect,
9
+ Virtualizer,
10
+ } from "@tanstack/react-virtual";
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
12
  import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
7
13
  import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
8
14
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
@@ -203,4 +209,27 @@ describe("useTimelineVirtualRows", () => {
203
209
  expect(rows.some((row) => row.index === 500)).toBe(false);
204
210
  act(() => root.unmount());
205
211
  });
212
+
213
+ // A scroll-end timer left running fires into the torn-down test window ("window is not defined").
214
+ it("gets no virtualizer change after unmount from a scroll just before it", async () => {
215
+ const scroll = createScrollElement(0);
216
+ const onChange = vi.fn();
217
+ const virtualizer = new Virtualizer<HTMLDivElement, Element>({
218
+ count: 100,
219
+ getScrollElement: () => scroll,
220
+ estimateSize: () => 48,
221
+ scrollToFn: elementScroll,
222
+ observeElementRect,
223
+ observeElementOffset,
224
+ onChange,
225
+ });
226
+ const unmount = virtualizer._didMount();
227
+ virtualizer._willUpdate();
228
+ scroll.dispatchEvent(new Event("scroll"));
229
+ unmount();
230
+ onChange.mockClear();
231
+
232
+ await new Promise((settle) => setTimeout(settle, 200));
233
+ expect(onChange).not.toHaveBeenCalled();
234
+ });
206
235
  });
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
3
3
  return Math.round(val * 1000) / 1000;
4
4
  }
5
5
 
6
+ /** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
7
+ export function roundToLayoutPx(val: number): number {
8
+ return Math.round(val);
9
+ }
10
+
6
11
  /** Round to 2 decimal places (centisecond precision for timeline values). */
7
12
  export function roundToCenti(val: number): number {
8
13
  return Math.round(val * 100) / 100;
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  export type CutoverResult =
13
13
  | { status: "declined"; reason: string }
14
- | { status: "committed"; version: string }
14
+ | { status: "committed"; version: string; before: string; after: string }
15
15
  | { status: "failed"; error: Error };
16
16
 
17
17
  export interface SdkSessionPublication {
@@ -143,7 +143,9 @@ export function failedCutover(
143
143
  }
144
144
 
145
145
  /** Only an explicit decline may enter the legacy mutation backend. */
146
- export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
146
+ export function cutoverCommittedOrThrow(
147
+ result: CutoverResult,
148
+ ): result is Extract<CutoverResult, { status: "committed" }> {
147
149
  if (result.status === "failed") throw result.error;
148
150
  return result.status === "committed";
149
151
  }
@@ -300,7 +302,12 @@ async function commitCandidateEdit(
300
302
  });
301
303
  }
302
304
  if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
303
- return { status: "committed", version: hashContent(edit.after) };
305
+ return {
306
+ status: "committed",
307
+ version: hashContent(edit.after),
308
+ before: originalContent,
309
+ after: edit.after,
310
+ };
304
311
  }
305
312
 
306
313
  export async function persistSdkCandidateMutation(