@hyperframes/studio 0.6.52 → 0.6.54

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 (31) hide show
  1. package/dist/assets/index-CKJCBFsG.js +138 -0
  2. package/dist/assets/index-ZdgB8MFr.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/components/StudioFeedbackBar.tsx +208 -0
  6. package/src/components/StudioPreviewArea.tsx +97 -92
  7. package/src/components/StudioRightPanel.tsx +18 -0
  8. package/src/components/editor/AnimationCard.tsx +325 -0
  9. package/src/components/editor/EaseCurveSection.tsx +213 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +112 -0
  11. package/src/components/editor/PropertyPanel.tsx +48 -18
  12. package/src/components/editor/domEditingTypes.ts +2 -0
  13. package/src/components/editor/gsapAnimationConstants.ts +130 -0
  14. package/src/components/editor/manualEditingAvailability.ts +6 -0
  15. package/src/components/editor/manualEdits.test.ts +101 -0
  16. package/src/components/editor/manualEdits.ts +22 -9
  17. package/src/components/editor/manualEditsDom.ts +22 -21
  18. package/src/components/editor/manualOffsetDrag.test.ts +35 -22
  19. package/src/components/editor/manualOffsetDrag.ts +1 -7
  20. package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
  21. package/src/contexts/DomEditContext.tsx +27 -0
  22. package/src/hooks/useDomEditSession.ts +98 -2
  23. package/src/hooks/useDomSelection.ts +8 -0
  24. package/src/hooks/useGsapScriptCommits.ts +303 -0
  25. package/src/hooks/useGsapTweenCache.ts +80 -0
  26. package/src/hooks/usePreviewPersistence.ts +1 -0
  27. package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +2 -1
  29. package/src/telemetry/events.ts +32 -0
  30. package/dist/assets/index-Bvy50smZ.js +0 -138
  31. package/dist/assets/index-SKRp8mGz.css +0 -1
@@ -279,3 +279,145 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
279
279
  expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
280
280
  });
281
281
  });
282
+
283
+ describe("useTimelinePlayer RAF loop wrap-around", () => {
284
+ type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
285
+
286
+ function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
287
+ const iframe = document.createElement("iframe");
288
+ let currentTime = 0;
289
+ let playing = false;
290
+ const seekCalls: SeekCall[] = [];
291
+ const adapter = {
292
+ play: vi.fn(() => {
293
+ playing = true;
294
+ }),
295
+ pause: vi.fn(() => {
296
+ playing = false;
297
+ }),
298
+ seek: vi.fn((time: number, options?: { keepPlaying?: boolean }) => {
299
+ currentTime = time;
300
+ seekCalls.push({ time, options });
301
+ }),
302
+ getTime: () => currentTime,
303
+ getDuration: () => duration,
304
+ isPlaying: () => playing,
305
+ setTime: (t: number) => {
306
+ currentTime = t;
307
+ },
308
+ };
309
+ Object.defineProperty(iframe, "contentWindow", {
310
+ value: {
311
+ __player: adapter,
312
+ postMessage: () => {},
313
+ scrollTo: () => {},
314
+ addEventListener: () => {},
315
+ removeEventListener: () => {},
316
+ },
317
+ configurable: true,
318
+ });
319
+ Object.defineProperty(iframe, "contentDocument", {
320
+ value: document.implementation.createHTMLDocument("preview"),
321
+ configurable: true,
322
+ });
323
+ act(() => {
324
+ api.iframeRef.current = iframe;
325
+ api.onIframeLoad();
326
+ });
327
+ return { adapter, seekCalls };
328
+ }
329
+
330
+ function installRafCapture(): {
331
+ flushOne: () => boolean;
332
+ restore: () => void;
333
+ } {
334
+ const callbacks: FrameRequestCallback[] = [];
335
+ const originalRAF = globalThis.requestAnimationFrame;
336
+ const originalCancel = globalThis.cancelAnimationFrame;
337
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
338
+ callbacks.push(cb);
339
+ return callbacks.length;
340
+ }) as typeof requestAnimationFrame;
341
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame;
342
+ return {
343
+ flushOne: () => {
344
+ const next = callbacks.shift();
345
+ if (!next) return false;
346
+ next(performance.now());
347
+ return true;
348
+ },
349
+ restore: () => {
350
+ globalThis.requestAnimationFrame = originalRAF;
351
+ globalThis.cancelAnimationFrame = originalCancel;
352
+ },
353
+ };
354
+ }
355
+
356
+ it("passes { keepPlaying: true } when forward playback wraps around loopEnd", () => {
357
+ const raf = installRafCapture();
358
+ try {
359
+ const { api, root } = renderTimelinePlayerHarness();
360
+ const { adapter, seekCalls } = attachInstrumentedAdapter(api);
361
+
362
+ act(() => {
363
+ usePlayerStore.getState().setInPoint(2);
364
+ usePlayerStore.getState().setOutPoint(5);
365
+ });
366
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
367
+
368
+ act(() => {
369
+ api.play();
370
+ });
371
+ adapter.seek.mockClear();
372
+ seekCalls.length = 0;
373
+
374
+ adapter.setTime(6); // past outPoint=5
375
+ act(() => {
376
+ raf.flushOne();
377
+ });
378
+
379
+ const wrapSeek = seekCalls.find((call) => call.time === 2);
380
+ expect(wrapSeek).toBeDefined();
381
+ expect(wrapSeek?.options).toEqual({ keepPlaying: true });
382
+ expect(adapter.play).toHaveBeenCalled();
383
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
384
+
385
+ unmountWithAct(root);
386
+ } finally {
387
+ raf.restore();
388
+ }
389
+ });
390
+
391
+ it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => {
392
+ const raf = installRafCapture();
393
+ try {
394
+ const { api, root } = renderTimelinePlayerHarness();
395
+ const { adapter, seekCalls } = attachInstrumentedAdapter(api);
396
+
397
+ act(() => {
398
+ usePlayerStore.getState().setLoopEnabled(false);
399
+ });
400
+
401
+ act(() => {
402
+ api.play();
403
+ });
404
+ adapter.seek.mockClear();
405
+ seekCalls.length = 0;
406
+ adapter.play.mockClear();
407
+ adapter.pause.mockClear();
408
+
409
+ adapter.setTime(adapter.getDuration() + 1); // past end
410
+ act(() => {
411
+ raf.flushOne();
412
+ });
413
+
414
+ expect(seekCalls).toHaveLength(0);
415
+ expect(adapter.pause).toHaveBeenCalled();
416
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
417
+
418
+ unmountWithAct(root);
419
+ } finally {
420
+ raf.restore();
421
+ }
422
+ });
423
+ });
@@ -229,7 +229,8 @@ export function useTimelinePlayer() {
229
229
  const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
230
230
  if (time >= loopEnd) {
231
231
  if (usePlayerStore.getState().loopEnabled && dur > 0) {
232
- adapter.seek(loopStart);
232
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
233
+ adapter.seek(loopStart, { keepPlaying: true });
233
234
  liveTime.notify(loopStart);
234
235
  adapter.play();
235
236
  setIsPlaying(true);
@@ -25,3 +25,35 @@ export function trackStudioRenderStart(props: {
25
25
  composition: props.composition,
26
26
  });
27
27
  }
28
+
29
+ function getBrowserDoctorSummary(): string {
30
+ try {
31
+ const nav = navigator as Navigator & {
32
+ deviceMemory?: number;
33
+ connection?: { effectiveType?: string };
34
+ userAgentData?: { platform?: string };
35
+ };
36
+ const platform = nav.userAgentData?.platform ?? navigator.platform ?? "unknown";
37
+ const parts = [
38
+ `ua=${platform}`,
39
+ `screen=${screen.width}x${screen.height}@${devicePixelRatio}x`,
40
+ `lang=${navigator.language}`,
41
+ ];
42
+ if (nav.deviceMemory) parts.push(`mem=${nav.deviceMemory}GB`);
43
+ if (nav.connection?.effectiveType) parts.push(`net=${nav.connection.effectiveType}`);
44
+ if (navigator.hardwareConcurrency) parts.push(`cpu=${navigator.hardwareConcurrency}cores`);
45
+ return parts.join(" ");
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
52
+ trackEvent("survey sent", {
53
+ $survey_id: "studio_experience",
54
+ $survey_response: props.rating,
55
+ ...(props.comment ? { $survey_response_2: props.comment } : {}),
56
+ doctor_summary: getBrowserDoctorSummary(),
57
+ source: "studio",
58
+ });
59
+ }