@hyperframes/studio 0.4.32 → 0.4.34

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
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-DxwbBcYY.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-BV9ymBm4.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DeztUnf4.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.4.32",
3
+ "version": "0.4.34",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.4.32",
36
- "@hyperframes/player": "0.4.32"
35
+ "@hyperframes/player": "0.4.34",
36
+ "@hyperframes/core": "0.4.34"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.4.32"
50
+ "@hyperframes/producer": "0.4.34"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ formatTimelineTickLabel,
3
4
  generateTicks,
4
5
  getDefaultDroppedTrack,
5
6
  getTimelineCanvasHeight,
6
7
  resolveTimelineAssetDrop,
7
8
  getTimelinePlayheadLeft,
9
+ getTimelineScrollLeftForZoomAnchor,
8
10
  getTimelineScrollLeftForZoomTransition,
9
11
  shouldHandleTimelineDeleteKey,
10
12
  shouldAutoScrollTimeline,
@@ -78,6 +80,20 @@ describe("generateTicks", () => {
78
80
  expect(major[0]).toBe(0);
79
81
  }
80
82
  });
83
+
84
+ it("uses denser major labels as timeline zoom increases", () => {
85
+ const fitTicks = generateTicks(180, 10);
86
+ const zoomedTicks = generateTicks(180, 48);
87
+ expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
88
+ expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
89
+ expect(zoomedTicks.minor).toContain(1);
90
+ expect(zoomedTicks.minor).toContain(4);
91
+ });
92
+
93
+ it("keeps labels readable instead of placing one at every tiny tick", () => {
94
+ const { major } = generateTicks(180, 80);
95
+ expect(major[1] - major[0]).toBe(2);
96
+ });
81
97
  });
82
98
 
83
99
  describe("formatTime", () => {
@@ -118,6 +134,20 @@ describe("formatTime", () => {
118
134
  });
119
135
  });
120
136
 
137
+ describe("formatTimelineTickLabel", () => {
138
+ it("uses minute-second labels for normal timeline intervals", () => {
139
+ expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
140
+ });
141
+
142
+ it("uses hour labels for long timelines", () => {
143
+ expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
144
+ });
145
+
146
+ it("shows subsecond labels when the major ruler interval is below one second", () => {
147
+ expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
148
+ });
149
+ });
150
+
121
151
  describe("shouldAutoScrollTimeline", () => {
122
152
  it("never auto-scrolls in fit mode", () => {
123
153
  expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
@@ -145,6 +175,47 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
145
175
  });
146
176
  });
147
177
 
178
+ describe("getTimelineScrollLeftForZoomAnchor", () => {
179
+ it("preserves the time under the pointer when zooming in", () => {
180
+ expect(
181
+ getTimelineScrollLeftForZoomAnchor({
182
+ pointerX: 300,
183
+ currentScrollLeft: 200,
184
+ gutter: 32,
185
+ currentPixelsPerSecond: 10,
186
+ nextPixelsPerSecond: 20,
187
+ duration: 120,
188
+ }),
189
+ ).toBe(668);
190
+ });
191
+
192
+ it("clamps negative scroll targets", () => {
193
+ expect(
194
+ getTimelineScrollLeftForZoomAnchor({
195
+ pointerX: 300,
196
+ currentScrollLeft: 0,
197
+ gutter: 32,
198
+ currentPixelsPerSecond: 20,
199
+ nextPixelsPerSecond: 5,
200
+ duration: 120,
201
+ }),
202
+ ).toBe(0);
203
+ });
204
+
205
+ it("preserves current scroll when inputs are invalid", () => {
206
+ expect(
207
+ getTimelineScrollLeftForZoomAnchor({
208
+ pointerX: 300,
209
+ currentScrollLeft: 120,
210
+ gutter: 32,
211
+ currentPixelsPerSecond: 0,
212
+ nextPixelsPerSecond: 20,
213
+ duration: 120,
214
+ }),
215
+ ).toBe(120);
216
+ });
217
+ });
218
+
148
219
  describe("getTimelinePlayheadLeft", () => {
149
220
  it("converts time to a pixel offset from the gutter", () => {
150
221
  expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
@@ -26,7 +26,7 @@ import {
26
26
  type TimelineTrackStyle,
27
27
  type TimelineTheme,
28
28
  } from "./timelineTheme";
29
- import { getTimelinePixelsPerSecond } from "./timelineZoom";
29
+ import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
30
30
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
31
31
 
32
32
  /* ── Layout ─────────────────────────────────────────────────────── */
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
88
88
  }
89
89
 
90
90
  /* ── Tick Generation ────────────────────────────────────────────── */
91
- export function generateTicks(duration: number): { major: number[]; minor: number[] } {
91
+ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
92
+ const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
93
+ if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
94
+ const targetMajorPx = 128;
95
+ return (
96
+ zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
97
+ );
98
+ }
99
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
100
+ const target = duration / 6;
101
+ return durationIntervals.find((interval) => interval >= target) ?? 60;
102
+ }
103
+
104
+ function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
105
+ let interval = majorInterval / 2;
106
+ if (majorInterval >= 30) interval = majorInterval / 6;
107
+ else if (majorInterval >= 15) interval = majorInterval / 3;
108
+ else if (majorInterval >= 5) interval = majorInterval / 5;
109
+ else if (majorInterval >= 1) interval = majorInterval / 4;
110
+
111
+ if (
112
+ Number.isFinite(pixelsPerSecond) &&
113
+ (pixelsPerSecond ?? 0) > 0 &&
114
+ interval * (pixelsPerSecond ?? 0) < 20
115
+ ) {
116
+ return Math.max(0.25, majorInterval / 2);
117
+ }
118
+ return Math.max(0.25, interval);
119
+ }
120
+
121
+ export function generateTicks(
122
+ duration: number,
123
+ pixelsPerSecond?: number,
124
+ ): { major: number[]; minor: number[] } {
92
125
  if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
93
126
  return { major: [], minor: [] };
94
- const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
95
- const target = duration / 6;
96
- const majorInterval = intervals.find((i) => i >= target) ?? 60;
97
- const minorInterval = Math.max(0.25, majorInterval / 2);
127
+ const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
128
+ const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
98
129
  const major: number[] = [];
99
130
  const minor: number[] = [];
100
- const maxTicks = 500; // Safety cap to prevent infinite loop
131
+ const maxTicks = 2000; // Safety cap to prevent runaway tick generation
101
132
  for (
102
133
  let t = 0;
103
134
  t <= duration + 0.001 && major.length + minor.length < maxTicks;
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
113
144
  return { major, minor };
114
145
  }
115
146
 
147
+ export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
148
+ if (!Number.isFinite(time)) return "0:00";
149
+ const safeTime = Math.max(0, time);
150
+ if (majorInterval < 1) {
151
+ const totalTenths = Math.round(safeTime * 10);
152
+ const wholeSeconds = Math.floor(totalTenths / 10);
153
+ const tenth = totalTenths % 10;
154
+ return `${formatTime(wholeSeconds)}.${tenth}`;
155
+ }
156
+ if (duration >= 3600 || safeTime >= 3600) {
157
+ const totalSeconds = Math.floor(safeTime);
158
+ const hours = Math.floor(totalSeconds / 3600);
159
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
160
+ const seconds = totalSeconds % 60;
161
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
162
+ }
163
+ return formatTime(safeTime);
164
+ }
165
+
116
166
  export function shouldAutoScrollTimeline(
117
167
  zoomMode: ZoomMode,
118
168
  scrollWidth: number,
@@ -132,6 +182,31 @@ export function getTimelineScrollLeftForZoomTransition(
132
182
  return currentScrollLeft;
133
183
  }
134
184
 
185
+ export function getTimelineScrollLeftForZoomAnchor(input: {
186
+ pointerX: number;
187
+ currentScrollLeft: number;
188
+ gutter: number;
189
+ currentPixelsPerSecond: number;
190
+ nextPixelsPerSecond: number;
191
+ duration: number;
192
+ }): number {
193
+ const currentPps = Math.max(0, input.currentPixelsPerSecond);
194
+ const nextPps = Math.max(0, input.nextPixelsPerSecond);
195
+ if (
196
+ !Number.isFinite(input.pointerX) ||
197
+ !Number.isFinite(input.currentScrollLeft) ||
198
+ !Number.isFinite(input.duration) ||
199
+ input.duration <= 0 ||
200
+ currentPps <= 0 ||
201
+ nextPps <= 0
202
+ ) {
203
+ return Math.max(0, input.currentScrollLeft);
204
+ }
205
+ const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
206
+ const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
207
+ return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
208
+ }
209
+
135
210
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
136
211
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
137
212
  return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
@@ -306,6 +381,8 @@ export const Timeline = memo(function Timeline({
306
381
  const currentTime = usePlayerStore((s) => s.currentTime);
307
382
  const zoomMode = usePlayerStore((s) => s.zoomMode);
308
383
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
384
+ const setZoomMode = usePlayerStore((s) => s.setZoomMode);
385
+ const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
309
386
  const playheadRef = useRef<HTMLDivElement>(null);
310
387
  const containerRef = useRef<HTMLDivElement>(null);
311
388
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -435,7 +512,11 @@ export const Timeline = memo(function Timeline({
435
512
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
436
513
  const zoomModeRef = useRef(zoomMode);
437
514
  zoomModeRef.current = zoomMode;
515
+ const manualZoomPercentRef = useRef(manualZoomPercent);
516
+ manualZoomPercentRef.current = manualZoomPercent;
438
517
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
518
+ const fitPpsRef = useRef(fitPps);
519
+ fitPpsRef.current = fitPps;
439
520
 
440
521
  const durationRef = useRef(effectiveDuration);
441
522
  durationRef.current = effectiveDuration;
@@ -925,7 +1006,12 @@ export const Timeline = memo(function Timeline({
925
1006
  cancelAnimationFrame(dragScrollRaf.current);
926
1007
  }, []);
927
1008
 
928
- const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
1009
+ const { major, minor } = useMemo(
1010
+ () => generateTicks(effectiveDuration, pps),
1011
+ [effectiveDuration, pps],
1012
+ );
1013
+ const majorTickInterval =
1014
+ major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
929
1015
  const getPreviewElement = useCallback(
930
1016
  (element: TimelineElement): TimelineElement => {
931
1017
  if (resizingClip?.element.id === element.id) {
@@ -1011,6 +1097,57 @@ export const Timeline = memo(function Timeline({
1011
1097
  [onAssetDrop, onFileDrop],
1012
1098
  );
1013
1099
 
1100
+ const handlePinchWheel = useCallback(
1101
+ (e: WheelEvent) => {
1102
+ if (!e.ctrlKey) return;
1103
+ const scroll = scrollRef.current;
1104
+ if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
1105
+ return;
1106
+ }
1107
+
1108
+ e.preventDefault();
1109
+ e.stopPropagation();
1110
+
1111
+ const rect = scroll.getBoundingClientRect();
1112
+ const pointerX = e.clientX - rect.left;
1113
+ const nextZoomPercent = getPinchTimelineZoomPercent(
1114
+ e.deltaY,
1115
+ zoomModeRef.current,
1116
+ manualZoomPercentRef.current,
1117
+ );
1118
+ if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
1119
+ return;
1120
+ }
1121
+
1122
+ const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
1123
+ const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
1124
+ pointerX,
1125
+ currentScrollLeft: scroll.scrollLeft,
1126
+ gutter: GUTTER,
1127
+ currentPixelsPerSecond: ppsRef.current,
1128
+ nextPixelsPerSecond: nextPps,
1129
+ duration: durationRef.current,
1130
+ });
1131
+
1132
+ setZoomMode("manual");
1133
+ setManualZoomPercent(nextZoomPercent);
1134
+ requestAnimationFrame(() => {
1135
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
1136
+ scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
1137
+ });
1138
+ },
1139
+ [setManualZoomPercent, setZoomMode],
1140
+ );
1141
+
1142
+ useEffect(() => {
1143
+ const scroll = scrollRef.current;
1144
+ if (!scroll) return;
1145
+ scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
1146
+ return () => {
1147
+ scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
1148
+ };
1149
+ }, [handlePinchWheel, timelineReady, elements.length]);
1150
+
1014
1151
  if (!timelineReady || elements.length === 0) {
1015
1152
  return (
1016
1153
  <div
@@ -1242,7 +1379,7 @@ export const Timeline = memo(function Timeline({
1242
1379
  className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
1243
1380
  style={{ color: theme.tickText }}
1244
1381
  >
1245
- {formatTime(t)}
1382
+ {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
1246
1383
  </span>
1247
1384
  <div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
1248
1385
  </div>
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  clampTimelineZoomPercent,
4
4
  getNextTimelineZoomPercent,
5
+ getPinchTimelineZoomPercent,
5
6
  getTimelinePixelsPerSecond,
6
7
  getTimelineZoomPercent,
7
8
  MAX_TIMELINE_ZOOM_PERCENT,
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
60
61
  );
61
62
  });
62
63
  });
64
+
65
+ describe("getPinchTimelineZoomPercent", () => {
66
+ it("zooms in for upward pinch wheel deltas", () => {
67
+ expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
68
+ });
69
+
70
+ it("zooms out for downward pinch wheel deltas", () => {
71
+ expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
72
+ });
73
+
74
+ it("keeps the current zoom for zero or invalid deltas", () => {
75
+ expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
76
+ expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
77
+ });
78
+
79
+ it("clamps pinch zoom to the supported range", () => {
80
+ expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
81
+ expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
82
+ });
83
+ });
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
4
4
  export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
5
5
  const ZOOM_OUT_FACTOR = 0.8;
6
6
  const ZOOM_IN_FACTOR = 1.25;
7
+ const PINCH_ZOOM_SENSITIVITY = 0.0035;
7
8
 
8
9
  export function clampTimelineZoomPercent(percent: number): number {
9
10
  if (!Number.isFinite(percent)) return 100;
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
36
37
  const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
37
38
  return clampTimelineZoomPercent(next);
38
39
  }
40
+
41
+ export function getPinchTimelineZoomPercent(
42
+ deltaY: number,
43
+ zoomMode: ZoomMode,
44
+ manualZoomPercent: number,
45
+ ): number {
46
+ const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
47
+ if (!Number.isFinite(deltaY) || deltaY === 0) return current;
48
+ return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
49
+ }