@hyperframes/studio 0.6.108 → 0.6.110

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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-BgbVhDYd.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BVqybwMG.css">
8
+ <script type="module" crossorigin src="/assets/index-D-3sGz65.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-1C8oFiPi.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.108",
3
+ "version": "0.6.110",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,9 +33,9 @@
33
33
  "@phosphor-icons/react": "^2.1.10",
34
34
  "bpm-detective": "^2.0.5",
35
35
  "mediabunny": "^1.45.3",
36
- "@hyperframes/player": "0.6.108",
37
- "@hyperframes/core": "0.6.108",
38
- "@hyperframes/sdk": "0.6.108"
36
+ "@hyperframes/sdk": "0.6.110",
37
+ "@hyperframes/core": "0.6.110",
38
+ "@hyperframes/player": "0.6.110"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/react": "19",
@@ -49,7 +49,7 @@
49
49
  "vite": "^6.4.2",
50
50
  "vitest": "^3.2.4",
51
51
  "zustand": "^5.0.0",
52
- "@hyperframes/producer": "0.6.108"
52
+ "@hyperframes/producer": "0.6.110"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "19",
@@ -20,6 +20,12 @@ export const PROP_LABELS: Record<string, string> = {
20
20
  width: "Width",
21
21
  height: "Height",
22
22
  rotation: "Rotate",
23
+ z: "Move Z",
24
+ rotationX: "Rotate X",
25
+ rotationY: "Rotate Y",
26
+ rotationZ: "Rotate Z",
27
+ perspective: "Perspective",
28
+ transformOrigin: "Transform Origin",
23
29
  opacity: "Opacity",
24
30
  scale: "Scale",
25
31
  scaleX: "Scale X",
@@ -46,6 +52,12 @@ export const PROP_UNITS: Record<string, string> = {
46
52
  width: "px",
47
53
  height: "px",
48
54
  rotation: "°",
55
+ z: "px",
56
+ rotationX: "°",
57
+ rotationY: "°",
58
+ rotationZ: "°",
59
+ perspective: "px",
60
+ transformOrigin: "",
49
61
  opacity: "%",
50
62
  scale: "×",
51
63
  scaleX: "×",
@@ -62,6 +74,13 @@ export const PROP_TOOLTIPS: Record<string, string> = {
62
74
  scaleX: "Horizontal stretch (1 = normal)",
63
75
  scaleY: "Vertical stretch (1 = normal)",
64
76
  rotation: "Spin angle (360 = full rotation)",
77
+ z: "Move forward/back along the Z axis",
78
+ rotationX: "Rotate around the horizontal X axis",
79
+ rotationY: "Rotate around the vertical Y axis",
80
+ rotationZ: "Rotate around the screen-facing Z axis",
81
+ perspective:
82
+ "3D depth context for child elements; set it on a parent when rotating children in 3D",
83
+ transformOrigin: "Pivot point for transforms, for example center center or 50% 50%",
65
84
  width: "Element width",
66
85
  height: "Element height",
67
86
  autoAlpha: "Like opacity but hides element completely at 0",
@@ -147,6 +166,11 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
147
166
  scaleX: { min: -10, max: 10, step: 0.01 },
148
167
  scaleY: { min: -10, max: 10, step: 0.01 },
149
168
  rotation: { step: 1 },
169
+ z: { step: 1 },
170
+ rotationX: { step: 1 },
171
+ rotationY: { step: 1 },
172
+ rotationZ: { step: 1 },
173
+ perspective: { min: 0, step: 1 },
150
174
  skewX: { min: -90, max: 90, step: 1 },
151
175
  skewY: { min: -90, max: 90, step: 1 },
152
176
  width: { min: 0, step: 1 },
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
2
3
  import { buildTweenSummary } from "./gsapAnimationHelpers";
4
+ import { PROP_LABELS } from "./gsapAnimationConstants";
3
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
6
 
5
7
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
@@ -23,6 +25,27 @@ describe("buildTweenSummary", () => {
23
25
  expect(s).toContain("move x");
24
26
  });
25
27
 
28
+ it("describes 3D transform tweens with labels and units", () => {
29
+ const s = buildTweenSummary(
30
+ anim({
31
+ properties: {
32
+ z: 120,
33
+ rotationX: 45,
34
+ rotationY: -30,
35
+ rotationZ: 90,
36
+ perspective: 800,
37
+ transformOrigin: "50% 50%",
38
+ },
39
+ }),
40
+ );
41
+ expect(s).toContain("move z to 120px");
42
+ expect(s).toContain("rotate x to 45°");
43
+ expect(s).toContain("rotate y to -30°");
44
+ expect(s).toContain("rotate z to 90°");
45
+ expect(s).toContain("perspective to 800px");
46
+ expect(s).toContain("transform origin to 50% 50%");
47
+ });
48
+
26
49
  it("describes a from tween", () => {
27
50
  const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
28
51
  expect(s).toContain("enters from");
@@ -65,3 +88,9 @@ describe("buildTweenSummary", () => {
65
88
  expect(s).toContain("no properties yet");
66
89
  });
67
90
  });
91
+
92
+ describe("PROP_LABELS", () => {
93
+ it("provides labels for every inspector-supported GSAP property", () => {
94
+ expect(SUPPORTED_PROPS.filter((prop) => !PROP_LABELS[prop])).toEqual([]);
95
+ });
96
+ });
@@ -366,13 +366,6 @@ export function usePopulateKeyframeCacheForFile(
366
366
  const { setKeyframeCache } = usePlayerStore.getState();
367
367
  clearKeyframeCacheForFile(sf);
368
368
  const { elements } = usePlayerStore.getState();
369
- console.log(
370
- "[kf:static] elements in store:",
371
- elements
372
- .map((e) => e.domId)
373
- .filter(Boolean)
374
- .join(", "),
375
- );
376
369
  const mergedByElement = new Map<string, GsapKeyframesData>();
377
370
  for (const anim of parsed.animations) {
378
371
  const id = extractIdFromSelector(anim.targetSelector);
@@ -415,12 +408,6 @@ export function usePopulateKeyframeCacheForFile(
415
408
  mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
416
409
  }
417
410
  }
418
- console.log(
419
- "[kf:static] merged elements:",
420
- [...mergedByElement.keys()].join(", "),
421
- "kf counts:",
422
- [...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
423
- );
424
411
  for (const [id, kfData] of mergedByElement) {
425
412
  setKeyframeCache(`${sf}#${id}`, kfData);
426
413
  setKeyframeCache(id, kfData);
@@ -454,12 +441,6 @@ export function usePopulateKeyframeCacheForFile(
454
441
  if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
455
442
  }
456
443
  const scanned = scanAllRuntimeKeyframes(iframe, clipById);
457
- console.log(
458
- "[kf:runtime] scanned",
459
- scanned.size,
460
- "elements:",
461
- [...scanned.keys()].join(", "),
462
- );
463
444
  if (scanned.size === 0) return false;
464
445
  const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
465
446
  for (const [id, data] of scanned) {
@@ -470,14 +451,6 @@ export function usePopulateKeyframeCacheForFile(
470
451
  if (alreadyCached) {
471
452
  continue;
472
453
  }
473
- console.log(
474
- "[kf:runtime] adding runtime entry:",
475
- id,
476
- "kfs:",
477
- data.keyframes.length,
478
- "arc:",
479
- !!data.arcPath,
480
- );
481
454
  const entry = {
482
455
  format: "percentage" as const,
483
456
  keyframes: data.keyframes,
@@ -5,10 +5,10 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
5
  import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
6
6
  import { trackStudioRazorSplit } from "../telemetry/events";
7
7
  import {
8
- canSplitElement,
8
+ canSplitElementAt,
9
+ selectSplittableElements,
9
10
  buildPatchTarget,
10
11
  readFileContent,
11
- isSplitTimeWithinBounds,
12
12
  } from "../utils/timelineElementSplit";
13
13
  import type { RecordEditInput } from "./timelineEditingHelpers";
14
14
 
@@ -170,11 +170,7 @@ export function useRazorSplit({
170
170
  }
171
171
 
172
172
  const pid = projectIdRef.current;
173
- if (!pid || !canSplitElement(element)) return;
174
-
175
- if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
176
- return;
177
- }
173
+ if (!pid || !canSplitElementAt(element, splitTime)) return;
178
174
 
179
175
  try {
180
176
  const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
@@ -232,9 +228,7 @@ export function useRazorSplit({
232
228
  const pid = projectIdRef.current;
233
229
  if (!pid) return;
234
230
  const { elements } = usePlayerStore.getState();
235
- const splittable = elements.filter(
236
- (el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
237
- );
231
+ const splittable = selectSplittableElements(elements, splitTime);
238
232
  if (splittable.length === 0) return;
239
233
 
240
234
  try {
@@ -1,5 +1,22 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { SPLIT_BOUNDARY_EPSILON_S, isSplitTimeWithinBounds } from "./timelineElementSplit";
2
+ import type { TimelineElement } from "../player/store/playerStore";
3
+ import {
4
+ SPLIT_BOUNDARY_EPSILON_S,
5
+ canSplitElementAt,
6
+ isSplitTimeWithinBounds,
7
+ selectSplittableElements,
8
+ } from "./timelineElementSplit";
9
+
10
+ function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
11
+ return {
12
+ id: "el-1",
13
+ tag: "div",
14
+ start: 1,
15
+ duration: 4,
16
+ track: 0,
17
+ ...overrides,
18
+ };
19
+ }
3
20
 
4
21
  describe("isSplitTimeWithinBounds", () => {
5
22
  const start = 1;
@@ -48,3 +65,46 @@ describe("isSplitTimeWithinBounds", () => {
48
65
  expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
49
66
  });
50
67
  });
68
+
69
+ describe("canSplitElementAt", () => {
70
+ it("accepts a splittable element at an interior time", () => {
71
+ expect(canSplitElementAt(element({ start: 1, duration: 4 }), 3)).toBe(true);
72
+ });
73
+
74
+ it("rejects a time inside the boundary epsilon", () => {
75
+ expect(
76
+ canSplitElementAt(element({ start: 1, duration: 4 }), 1 + SPLIT_BOUNDARY_EPSILON_S / 2),
77
+ ).toBe(false);
78
+ });
79
+
80
+ it("rejects locked, implicit and sub-composition elements", () => {
81
+ expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
82
+ expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
83
+ expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("selectSplittableElements", () => {
88
+ it("excludes a clip shorter than two epsilons even when the time is inside it", () => {
89
+ // Regression: split-all used raw start < t < end, so a clip too short for
90
+ // the epsilon margin was still selected and produced a degenerate slice.
91
+ const tiny = element({ id: "tiny", start: 1, duration: SPLIT_BOUNDARY_EPSILON_S + 0.01 });
92
+ const interiorTime = tiny.start + tiny.duration / 2;
93
+ expect(interiorTime).toBeGreaterThan(tiny.start);
94
+ expect(interiorTime).toBeLessThan(tiny.start + tiny.duration);
95
+ expect(selectSplittableElements([tiny], interiorTime)).toEqual([]);
96
+ });
97
+
98
+ it("keeps only the elements whose epsilon-bounded range contains the time", () => {
99
+ const inside = element({ id: "inside", start: 0, duration: 4 });
100
+ const outside = element({ id: "outside", start: 5, duration: 4 });
101
+ const locked = element({ id: "locked", start: 0, duration: 4, timelineLocked: true });
102
+ const result = selectSplittableElements([inside, outside, locked], 2);
103
+ expect(result.map((el) => el.id)).toEqual(["inside"]);
104
+ });
105
+
106
+ it("accepts an element at the exact lower clamp boundary", () => {
107
+ const el = element({ start: 2, duration: 4 });
108
+ expect(selectSplittableElements([el], 2 + SPLIT_BOUNDARY_EPSILON_S)).toEqual([el]);
109
+ });
110
+ });
@@ -30,3 +30,21 @@ export function canSplitElement(el: TimelineElement): boolean {
30
30
  Number.isFinite(el.duration)
31
31
  );
32
32
  }
33
+
34
+ /**
35
+ * True when `el` can be split AND `splitTime` lies within its boundary epsilon.
36
+ * Shared by the single-clip and split-all razor paths so both honor the same
37
+ * minimum-distance rule (split-all previously used raw `>`/`<`, letting cuts
38
+ * land inside the epsilon margin and produce a degenerate slice).
39
+ */
40
+ export function canSplitElementAt(el: TimelineElement, splitTime: number): boolean {
41
+ return canSplitElement(el) && isSplitTimeWithinBounds(splitTime, el.start, el.duration);
42
+ }
43
+
44
+ /** Elements that the split-all razor action can cut at `splitTime`. */
45
+ export function selectSplittableElements(
46
+ elements: TimelineElement[],
47
+ splitTime: number,
48
+ ): TimelineElement[] {
49
+ return elements.filter((el) => canSplitElementAt(el, splitTime));
50
+ }