@hyperframes/studio 0.8.87 → 0.8.88

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/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-7PLTC6SV.js";
3
+ } from "./chunk-KU4F7QZ3.js";
4
4
  import "./chunk-QZ6HNNFG.js";
5
5
  import "./chunk-S65AQH6M.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.87",
3
+ "version": "0.8.88",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.87",
64
- "@hyperframes/parsers": "0.8.87",
65
- "@hyperframes/player": "0.8.87",
66
- "@hyperframes/shader-transitions": "0.8.87",
67
- "@hyperframes/studio-server": "0.8.87",
68
- "@hyperframes/sdk": "0.8.87"
63
+ "@hyperframes/parsers": "0.8.88",
64
+ "@hyperframes/core": "0.8.88",
65
+ "@hyperframes/player": "0.8.88",
66
+ "@hyperframes/studio-server": "0.8.88",
67
+ "@hyperframes/sdk": "0.8.88",
68
+ "@hyperframes/shader-transitions": "0.8.88"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.87"
84
+ "@hyperframes/producer": "0.8.88"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -96,14 +96,25 @@ it("leaves enabled buttons without the disabled attribute", () => {
96
96
  expect(el.className).not.toContain("cursor-not-allowed");
97
97
  });
98
98
 
99
- it("orders the toolbar Undo, Redo, Select, Razor", () => {
99
+ it("orders the toolbar Undo, Redo, then the tool picker showing the active tool", () => {
100
100
  const host = mount(<TimelineToolbar />);
101
101
  const labels = Array.from(host.querySelectorAll("button"))
102
102
  .map((b) => b.getAttribute("aria-label"))
103
103
  .filter((l) => l !== null)
104
- .slice(0, 4);
104
+ .slice(0, 3);
105
105
 
106
- expect(labels).toEqual(["Undo", "Redo", "Selection tool", "Razor tool"]);
106
+ expect(labels).toEqual(["Undo", "Redo", "Timeline tool: Select"]);
107
+ });
108
+
109
+ it("shows the Split tool on the picker while the razor is active", async () => {
110
+ const { usePlayerStore } = await import("../player");
111
+ usePlayerStore.setState({ activeTool: "razor" });
112
+ try {
113
+ const host = mount(<TimelineToolbar />);
114
+ expect(host.querySelector('button[aria-label="Timeline tool: Split"]')).not.toBeNull();
115
+ } finally {
116
+ usePlayerStore.setState({ activeTool: "select" });
117
+ }
107
118
  });
108
119
 
109
120
  it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
@@ -0,0 +1,90 @@
1
+ import type { ReactNode } from "react";
2
+ import { ArrowLineLeft, ArrowLineRight, CaretDown } from "@phosphor-icons/react";
3
+ import { usePlayerStore } from "../player";
4
+ import { Scissors } from "../icons/SystemIcons";
5
+ import { Menu, MenuItem } from "./ui";
6
+ import { cn } from "./ui/cn";
7
+ import { flatIdle } from "./timelineToolbarStyles";
8
+
9
+ function SelectIcon() {
10
+ return (
11
+ <svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
12
+ <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ interface ToolRow {
18
+ label: string;
19
+ shortcut: string;
20
+ icon: ReactNode;
21
+ active: boolean;
22
+ run: () => void;
23
+ }
24
+
25
+ /** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
26
+ export function TimelineToolPicker() {
27
+ const activeTool = usePlayerStore((s) => s.activeTool);
28
+ const store = () => usePlayerStore.getState();
29
+ const rows: ToolRow[] = [
30
+ {
31
+ label: "Select",
32
+ shortcut: "V",
33
+ icon: <SelectIcon />,
34
+ active: activeTool === "select",
35
+ run: () => store().setActiveTool("select"),
36
+ },
37
+ {
38
+ label: "Split",
39
+ shortcut: "B",
40
+ icon: <Scissors size={16} />,
41
+ active: activeTool === "razor",
42
+ run: () => store().setActiveTool("razor"),
43
+ },
44
+ {
45
+ label: "Select leftward",
46
+ shortcut: "[",
47
+ icon: <ArrowLineLeft size={16} aria-hidden="true" />,
48
+ active: false,
49
+ run: () => store().selectLeftward(),
50
+ },
51
+ {
52
+ label: "Select rightward",
53
+ shortcut: "]",
54
+ icon: <ArrowLineRight size={16} aria-hidden="true" />,
55
+ active: false,
56
+ run: () => store().selectRightward(),
57
+ },
58
+ ];
59
+ const current = rows.find((row) => row.active) ?? rows[0];
60
+ return (
61
+ <Menu
62
+ aria-label="Timeline tools"
63
+ trigger={
64
+ <button
65
+ type="button"
66
+ aria-label={`Timeline tool: ${current.label}`}
67
+ className={cn(flatIdle, "gap-0.5")}
68
+ >
69
+ {current.icon}
70
+ <CaretDown size={10} weight="bold" aria-hidden="true" />
71
+ </button>
72
+ }
73
+ >
74
+ {rows.map((row) => (
75
+ <MenuItem
76
+ key={row.label}
77
+ shortcut={row.shortcut}
78
+ onClick={row.run}
79
+ data-active={row.active || undefined}
80
+ className={row.active ? "bg-neutral-800 text-white" : undefined}
81
+ >
82
+ <span className="flex items-center gap-2">
83
+ {row.icon}
84
+ {row.label}
85
+ </span>
86
+ </MenuItem>
87
+ ))}
88
+ </Menu>
89
+ );
90
+ }
@@ -26,7 +26,7 @@ import { AudioMetersIcon } from "./icons/AudioMetersIcon";
26
26
  import { RippleEditIcon } from "./icons/RippleEditIcon";
27
27
  import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
28
28
  import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
29
- import { Scissors } from "../icons/SystemIcons";
29
+ import { TimelineToolPicker } from "./TimelineToolPicker";
30
30
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
31
31
  import type { DomEditSelection } from "./editor/domEditingTypes";
32
32
  import { canSplitElement } from "../utils/timelineElementSplit";
@@ -144,8 +144,6 @@ export function TimelineToolbar({
144
144
  showAddBeat = true,
145
145
  showKeyframes = true,
146
146
  }: TimelineToolbarProps) {
147
- const activeTool = usePlayerStore((s) => s.activeTool);
148
- const setActiveTool = usePlayerStore((s) => s.setActiveTool);
149
147
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
150
148
  const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
151
149
  const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
@@ -216,30 +214,7 @@ export function TimelineToolbar({
216
214
  <div className="flex items-center justify-between px-2 py-0.5">
217
215
  <div className="flex items-center gap-0.5">
218
216
  <TimelineHistoryButtons {...history} />
219
- <Tooltip label="Selection tool (V)">
220
- <button
221
- type="button"
222
- onClick={() => setActiveTool("select")}
223
- aria-label="Selection tool"
224
- aria-pressed={activeTool === "select"}
225
- className={activeTool === "select" ? flatActive : flatIdle}
226
- >
227
- <svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
228
- <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
229
- </svg>
230
- </button>
231
- </Tooltip>
232
- <Tooltip label="Razor tool (B) — Shift+click splits all tracks">
233
- <button
234
- type="button"
235
- onClick={() => setActiveTool("razor")}
236
- aria-label="Razor tool"
237
- aria-pressed={activeTool === "razor"}
238
- className={activeTool === "razor" ? flatActive : flatIdle}
239
- >
240
- <Scissors size={16} />
241
- </button>
242
- </Tooltip>
217
+ <TimelineToolPicker />
243
218
  {/* Divider: tool-mode | editing-actions */}
244
219
  <div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
245
220
  <Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
@@ -207,6 +207,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
207
207
  return;
208
208
  }
209
209
 
210
+ if ((key === "[" || key === "]") && !event.shiftKey && !event.altKey) {
211
+ event.preventDefault();
212
+ const store = usePlayerStore.getState();
213
+ if (key === "[") store.selectLeftward();
214
+ else store.selectRightward();
215
+ return;
216
+ }
217
+
210
218
  // CapCut returns to the select tool with "A". Scoped to the razor being
211
219
  // armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
212
220
  // same activeTool to stay out of the way.
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
4
- import { usePlayerStore } from "../player/store/playerStore";
4
+ import { liveTime, usePlayerStore } from "../player/store/playerStore";
5
5
  import type { DomEditSelection } from "../components/editor/domEditing";
6
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
7
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
@@ -60,6 +60,63 @@ afterEach(() => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("dispatchPlainKey — select leftward / rightward", () => {
64
+ const clips = [
65
+ { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
66
+ { ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
67
+ { ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
68
+ ];
69
+ beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
70
+
71
+ it("[ selects every clip starting before the playhead, on every track", () => {
72
+ const event = press("[");
73
+ dispatchPlainKey(event, "[", callbacks());
74
+ expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
75
+ expect(usePlayerStore.getState().selectedElementId).toBe("early");
76
+ expect(event.defaultPrevented).toBe(true);
77
+ });
78
+
79
+ it("uses the live playhead while playing, not the time stored at play start", () => {
80
+ usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
81
+ liveTime.notify(8);
82
+ try {
83
+ dispatchPlainKey(press("["), "[", callbacks());
84
+ expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
85
+ "at",
86
+ "early",
87
+ "late",
88
+ ]);
89
+ } finally {
90
+ usePlayerStore.setState({ isPlaying: false });
91
+ liveTime.notify(0);
92
+ }
93
+ });
94
+
95
+ it("clears a clicked keyframe like any other selection change", () => {
96
+ usePlayerStore.setState({ activeKeyframePct: 50 });
97
+ dispatchPlainKey(press("]"), "]", callbacks());
98
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
99
+ });
100
+
101
+ it("] selects every clip starting at or after the playhead, on every track", () => {
102
+ dispatchPlainKey(press("]"), "]", callbacks());
103
+ const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
104
+ expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
105
+ expect(selectedElementId).toBe("at");
106
+ });
107
+
108
+ it("selects nothing when no clip is on that side", () => {
109
+ usePlayerStore.setState({
110
+ currentTime: 0,
111
+ selectedElementId: "late",
112
+ selectedElementIds: new Set(["late"]),
113
+ });
114
+ dispatchPlainKey(press("["), "[", callbacks());
115
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
116
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
117
+ });
118
+ });
119
+
63
120
  describe("dispatchPlainKey — Delete arbitration", () => {
64
121
  const selectBgm = () =>
65
122
  usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
@@ -0,0 +1,91 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ installTimelineMountEnv();
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ async function mountKeyframed(showKeyframes?: boolean, withLanes = true) {
19
+ usePlayerStore.setState({
20
+ duration: 10,
21
+ currentTime: 0,
22
+ timelineReady: true,
23
+ selectedElementId: "card",
24
+ elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 4, track: 0 }],
25
+ gsapAnimations: withLanes
26
+ ? new Map([
27
+ [
28
+ "card",
29
+ [
30
+ {
31
+ id: "card-position",
32
+ targetSelector: "#card",
33
+ method: "to",
34
+ position: 0,
35
+ duration: 2,
36
+ properties: {},
37
+ propertyGroup: "position",
38
+ keyframes: {
39
+ format: "percentage",
40
+ keyframes: [
41
+ { percentage: 0, properties: { x: 0 } },
42
+ { percentage: 50, properties: { x: 100 } },
43
+ ],
44
+ },
45
+ },
46
+ ],
47
+ ],
48
+ ])
49
+ : new Map(),
50
+ keyframeCache: new Map([
51
+ [
52
+ "card",
53
+ {
54
+ format: "percentage",
55
+ keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
56
+ },
57
+ ],
58
+ ]),
59
+ });
60
+ const host = document.createElement("div");
61
+ document.body.append(host);
62
+ const root = createRoot(host);
63
+ await act(async () => root.render(<Timeline showKeyframes={showKeyframes} />));
64
+ const diamonds = host.querySelectorAll('button[title="50%"]').length;
65
+ const named = Array.from(host.querySelectorAll("[role=rowheader]")).some((h) =>
66
+ h.textContent?.includes("Hero card"),
67
+ );
68
+ const lanes = host.textContent?.includes("Position") ?? false;
69
+ act(() => root.unmount());
70
+ return { diamonds, named, lanes };
71
+ }
72
+
73
+ describe("Timeline showKeyframes", () => {
74
+ it("draws a keyframed clip's diamonds by default", async () => {
75
+ expect((await mountKeyframed(undefined, false)).diamonds).toBeGreaterThan(0);
76
+ });
77
+
78
+ it("draws no keyframe diamonds when the host turns keyframes off", async () => {
79
+ expect((await mountKeyframed(false, false)).diamonds).toBe(0);
80
+ });
81
+
82
+ it("draws the keyframe lanes by default and none when keyframes are off", async () => {
83
+ expect((await mountKeyframed()).lanes).toBe(true);
84
+ expect((await mountKeyframed(false)).lanes).toBe(false);
85
+ });
86
+
87
+ it("keeps each track's name in the header when keyframes are off", async () => {
88
+ expect((await mountKeyframed()).named).toBe(true);
89
+ expect((await mountKeyframed(false)).named).toBe(true);
90
+ });
91
+ });
@@ -34,4 +34,5 @@ export interface TimelineProps
34
34
  canPasteClip?: () => boolean;
35
35
  theme?: Partial<TimelineTheme>;
36
36
  showAudioEffects?: boolean;
37
+ showKeyframes?: boolean;
37
38
  }
@@ -52,6 +52,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
52
52
  { key: "⌘X", label: "Cut element" },
53
53
  { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
54
54
  { key: "⇧Click", label: "Razor tool: split all tracks" },
55
+ { key: "[", label: "Select clips starting before the playhead" },
56
+ { key: "]", label: "Select clips starting at or after the playhead" },
55
57
  { key: "⌘G", label: "Group elements" },
56
58
  { key: "⌘⇧G", label: "Ungroup" },
57
59
  { key: "Del", label: "Delete selected element (no keyframe selected)" },
@@ -0,0 +1,14 @@
1
+ import { usePlayerStore, type KeyframeCacheEntry } from "../store/playerStore";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+
4
+ const HIDDEN = {
5
+ gsapAnimations: new Map<string, GsapAnimation[]>(),
6
+ keyframeCache: new Map<string, KeyframeCacheEntry>(),
7
+ };
8
+
9
+ /** The keyframe data the timeline draws, or none when the host hides keyframes. */
10
+ export function useTimelineKeyframeData(showKeyframes: boolean) {
11
+ const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
12
+ const keyframeCache = usePlayerStore((s) => s.keyframeCache);
13
+ return showKeyframes ? { gsapAnimations, keyframeCache } : HIDDEN;
14
+ }
@@ -21,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
21
21
  import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
22
22
  import { GUTTER, LABEL_COL_W } from "./timelineLayout";
23
23
  import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
24
+ import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
24
25
  import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
25
26
  import { ClipContentOnceShown } from "./timelineClipChildren";
26
27
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
@@ -68,6 +69,7 @@ export function useTimelineProviderState({
68
69
  canPasteClip,
69
70
  theme: themeOverrides,
70
71
  showAudioEffects = true,
72
+ showKeyframes = true,
71
73
  sessionEpoch = 0,
72
74
  previewIframeRef,
73
75
  onZIndexReorder,
@@ -104,8 +106,9 @@ export function useTimelineProviderState({
104
106
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
105
107
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
106
108
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
107
- const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
108
- const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
109
+ const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
110
+ const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
111
+ const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
109
112
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
110
113
  const currentTime = usePlayerStore((s) => s.currentTime);
111
114
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,7 +133,6 @@ export function useTimelineProviderState({
130
133
  () => getEffectiveTimelineDuration(duration, timelineElements),
131
134
  [duration, timelineElements],
132
135
  );
133
- const keyframeCache = usePlayerStore((s) => s.keyframeCache);
134
136
  useAutoExpandKeyframedClips(gsapAnimations);
135
137
  const {
136
138
  tracks,
@@ -23,6 +23,7 @@ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSele
23
23
  import { createTimelineResetState } from "./timelineResetState";
24
24
  export type { KeyframeCacheEntry } from "./keyframeSlice";
25
25
  export { liveTime } from "./liveTime";
26
+ import { liveTime } from "./liveTime";
26
27
  export { createTimelineResetState };
27
28
 
28
29
  import type {
@@ -96,6 +97,8 @@ interface PlayerState extends PlayerStoreSlices {
96
97
 
97
98
  activeTool: TimelineTool;
98
99
  setActiveTool: (tool: TimelineTool) => void;
100
+ selectLeftward: () => void;
101
+ selectRightward: () => void;
99
102
 
100
103
  /** Tween-relative percentage of the last-clicked keyframe diamond. Operations
101
104
  * (drag, resize, rotate) target this instead of recomputing from playhead. */
@@ -250,6 +253,17 @@ interface BeatHistoryEntry {
250
253
  label: string;
251
254
  }
252
255
 
256
+ /** Selects like the marquee: the primary first, so its resets run, then the whole set. */
257
+ function selectAroundPlayhead(
258
+ state: PlayerState,
259
+ keep: (start: number, playhead: number) => boolean,
260
+ ): void {
261
+ const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
262
+ const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
263
+ state.setSelectedElementId(ids[0] ?? null);
264
+ state.setSelectedElementIds(new Set(ids));
265
+ }
266
+
253
267
  export const usePlayerStore = create<PlayerState>((set, get) => ({
254
268
  isPlaying: false,
255
269
  currentTime: 0,
@@ -273,6 +287,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
273
287
 
274
288
  activeTool: "select",
275
289
  setActiveTool: (tool) => set({ activeTool: tool }),
290
+ selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
291
+ selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
276
292
 
277
293
  ...createKeyframeSlice(set, () => ({
278
294
  timelineProjectId: get().timelineProjectId,