@hyperframes/studio 0.7.64 → 0.7.66

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 (30) hide show
  1. package/dist/assets/{hyperframes-player-D4jpio27.js → hyperframes-player-BiCGkoKi.js} +1 -1
  2. package/dist/assets/{index-srObT-DA.js → index-CnL24iBm.js} +1 -1
  3. package/dist/assets/{index-CqjWmIbE.js → index-Cp6reNm_.js} +1 -1
  4. package/dist/assets/{index-BOMWJSjh.js → index-wCPvBGsm.js} +144 -144
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +2562 -2538
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/components/StudioRightPanel.tsx +2 -2
  10. package/src/components/editor/keyframeRetime.test.ts +56 -17
  11. package/src/components/editor/keyframeRetime.ts +48 -4
  12. package/src/components/nle/useTimelineEditCallbacks.test.ts +85 -0
  13. package/src/components/nle/useTimelineEditCallbacks.ts +52 -12
  14. package/src/components/panels/VariablesPanel.tsx +3 -1
  15. package/src/contexts/FileManagerContext.tsx +1 -0
  16. package/src/hooks/useGsapKeyframeOps.test.tsx +1 -0
  17. package/src/hooks/useGsapKeyframeOps.ts +2 -0
  18. package/src/hooks/useGsapPropertyDebounce.ts +1 -0
  19. package/src/hooks/useRazorSplit.ts +1 -0
  20. package/src/hooks/useTimelineEditing.test.tsx +1 -0
  21. package/src/hooks/useTimelineGroupEditing.ts +2 -0
  22. package/src/player/components/PlayerControls.test.ts +0 -17
  23. package/src/player/components/PlayerControls.tsx +8 -23
  24. package/src/player/hooks/usePlaybackKeyboard.test.ts +2 -2
  25. package/src/player/hooks/usePlaybackKeyboard.ts +1 -4
  26. package/src/player/hooks/useTimelinePlayer.seek.test.ts +2 -2
  27. package/src/player/hooks/useTimelinePlayer.ts +5 -13
  28. package/src/player/lib/timelineDOM.ts +0 -1
  29. package/src/player/lib/timelineIframeHelpers.ts +0 -4
  30. package/src/utils/sdkCutover.test.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.64",
3
+ "version": "0.7.66",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.64",
50
- "@hyperframes/player": "0.7.64",
51
- "@hyperframes/parsers": "0.7.64",
52
- "@hyperframes/studio-server": "0.7.64",
53
- "@hyperframes/sdk": "0.7.64"
49
+ "@hyperframes/core": "0.7.66",
50
+ "@hyperframes/sdk": "0.7.66",
51
+ "@hyperframes/studio-server": "0.7.66",
52
+ "@hyperframes/parsers": "0.7.66",
53
+ "@hyperframes/player": "0.7.66"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.64"
68
+ "@hyperframes/producer": "0.7.66"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -5,7 +5,7 @@ import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPane
5
5
  import { BlockParamsPanel } from "./editor/BlockParamsPanel";
6
6
  import { RenderQueue } from "./renders/RenderQueue";
7
7
  import { SlideshowPanel } from "./panels/SlideshowPanel";
8
- import { VariablesPanel } from "./panels/VariablesPanel";
8
+ import { VariablesPanel, type StudioEditPersistenceProps } from "./panels/VariablesPanel";
9
9
  import { PanelTabButton } from "./PanelTabButton";
10
10
  import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
11
11
  import type { RenderJob } from "./renders/useRenderQueue";
@@ -35,7 +35,7 @@ import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
35
35
  import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
36
36
  import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
37
37
 
38
- export interface StudioRightPanelProps {
38
+ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
39
39
  designPanelActive: boolean;
40
40
  activeBlockParams?: {
41
41
  blockName: string;
@@ -9,6 +9,7 @@ const KEYFRAMES: RetimeKeyframe[] = [
9
9
  { percentage: 100, properties: { x: 100 }, ease: "power2.in" },
10
10
  ];
11
11
  const WINDOW = { tweenStart: 2, tweenDuration: 4 };
12
+ const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
12
13
 
13
14
  describe("resolveKeyframeRetime — move (within the tween window)", () => {
14
15
  it("re-keys an interior keyframe to the tween-% of the drop", () => {
@@ -32,15 +33,53 @@ describe("resolveKeyframeRetime — move (within the tween window)", () => {
32
33
  expect(r.kind).toBe("noop");
33
34
  });
34
35
 
35
- it("moves a flat (keyframe-less) tween without needing the keyframes array", () => {
36
+ it("shortens a flat tween when its synthesized end diamond moves left", () => {
36
37
  const r = resolveKeyframeRetime({
37
38
  ...WINDOW,
38
39
  keyframes: [],
39
40
  draggedTweenPct: 100,
40
- dropAbsTime: 5, // (5-2)/4 = 75%
41
+ dropAbsTime: 5,
41
42
  });
42
- expect(r.kind).toBe("move");
43
- expect(r.toTweenPct).toBeCloseTo(75, 5);
43
+ expect(r.kind).toBe("resize");
44
+ expect(r.position).toBe(2);
45
+ expect(r.duration).toBe(3);
46
+ expect(r.pctRemap).toEqual([]);
47
+ });
48
+
49
+ it("moves a flat tween's start while preserving its absolute end", () => {
50
+ const r = resolveKeyframeRetime({
51
+ ...WINDOW,
52
+ keyframes: [],
53
+ draggedTweenPct: 0,
54
+ dropAbsTime: 3,
55
+ });
56
+ expect(r.kind).toBe("resize");
57
+ expect(r.position).toBe(3);
58
+ expect(r.duration).toBe(3);
59
+ expect(r.pctRemap).toEqual([]);
60
+ });
61
+
62
+ it("no-ops an unexpected interior percentage on a flat tween", () => {
63
+ expect(
64
+ resolveKeyframeRetime({
65
+ ...WINDOW,
66
+ keyframes: [],
67
+ draggedTweenPct: 50,
68
+ dropAbsTime: 5,
69
+ }).kind,
70
+ ).toBe("noop");
71
+ });
72
+
73
+ it("no-ops instead of rounding a sub-10ms flat tween to zero", () => {
74
+ expect(
75
+ resolveKeyframeRetime({
76
+ tweenStart: 2,
77
+ tweenDuration: 4,
78
+ keyframes: [],
79
+ draggedTweenPct: 100,
80
+ dropAbsTime: 2.0004,
81
+ }).kind,
82
+ ).toBe("noop");
44
83
  });
45
84
  });
46
85
 
@@ -67,10 +106,9 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
67
106
 
68
107
  it("extends the FIRST keyframe before the start, shifting position earlier", () => {
69
108
  const r = resolveKeyframeRetime({
70
- ...WINDOW,
109
+ ...LEFT_BOUNDARY_DROP,
71
110
  keyframes: KEYFRAMES,
72
111
  draggedTweenPct: 0,
73
- dropAbsTime: 0.5, // before start (2) → move position back + grow duration
74
112
  });
75
113
  expect(r.kind).toBe("resize");
76
114
  expect(r.position).toBeCloseTo(0.5, 5);
@@ -102,10 +140,9 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
102
140
 
103
141
  it("resizes left before the start", () => {
104
142
  const r = resolveKeyframeRetime({
105
- ...WINDOW,
143
+ ...LEFT_BOUNDARY_DROP,
106
144
  keyframes: lone,
107
145
  draggedTweenPct: 100,
108
- dropAbsTime: 0.5,
109
146
  });
110
147
  expect(r.kind).toBe("resize");
111
148
  expect(r.position).toBeCloseTo(0.5, 5);
@@ -127,14 +164,16 @@ describe("resolveKeyframeRetime — guards", () => {
127
164
  ).toBe("noop");
128
165
  });
129
166
 
130
- it("no-ops a boundary drop on a flat tween (nothing to remap)", () => {
131
- expect(
132
- resolveKeyframeRetime({
133
- ...WINDOW,
134
- keyframes: [],
135
- draggedTweenPct: 100,
136
- dropAbsTime: 8,
137
- }).kind,
138
- ).toBe("noop");
167
+ it("extends a flat tween when its synthesized end diamond moves right", () => {
168
+ const r = resolveKeyframeRetime({
169
+ ...WINDOW,
170
+ keyframes: [],
171
+ draggedTweenPct: 100,
172
+ dropAbsTime: 8,
173
+ });
174
+ expect(r.kind).toBe("resize");
175
+ expect(r.position).toBe(2);
176
+ expect(r.duration).toBe(6);
177
+ expect(r.pctRemap).toEqual([]);
139
178
  });
140
179
  });
@@ -51,11 +51,40 @@ export interface KeyframeRetimeResult {
51
51
  const NOOP_EPSILON_PCT = 0.1;
52
52
  /** Slack (seconds) for the within-tween boundary test. */
53
53
  const EPSILON_TIME = 1e-4;
54
+ /** Smallest authored tween window; avoids sub-millisecond/round-to-zero durations. */
55
+ const MIN_TWEEN_DURATION = 0.01;
54
56
 
55
57
  const round3 = (n: number) => Math.round(n * 1000) / 1000;
56
58
  const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
57
59
  const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
58
60
 
61
+ /** Resolve timing for a flat tween's synthesized start/end diamond. */
62
+ function resolveFlatTweenBoundaryRetime(opts: {
63
+ keyframeCount: number;
64
+ draggedTweenPct: number;
65
+ tweenStart: number;
66
+ tweenEnd: number;
67
+ dropAbsTime: number;
68
+ }): KeyframeRetimeResult | null {
69
+ const { keyframeCount, draggedTweenPct, tweenStart, tweenEnd, dropAbsTime } = opts;
70
+ if (keyframeCount > 0) return null;
71
+ // Flat tweens have only synthesized boundary diamonds. Never delegate an
72
+ // unexpected interior percentage to the authored-keyframe move path.
73
+ if (draggedTweenPct !== 0 && draggedTweenPct !== 100) return { kind: "noop" };
74
+ const draggedTime = draggedTweenPct === 0 ? tweenStart : tweenEnd;
75
+ if (Math.abs(dropAbsTime - draggedTime) <= EPSILON_TIME) return { kind: "noop" };
76
+ const newStart = draggedTweenPct === 0 ? dropAbsTime : tweenStart;
77
+ const newEnd = draggedTweenPct === 100 ? dropAbsTime : tweenEnd;
78
+ const newDuration = newEnd - newStart;
79
+ if (newDuration < MIN_TWEEN_DURATION) return { kind: "noop" };
80
+ return {
81
+ kind: "resize",
82
+ position: round3(newStart),
83
+ duration: round3(newDuration),
84
+ pctRemap: [],
85
+ };
86
+ }
87
+
59
88
  /**
60
89
  * Decide move vs resize for a dragged keyframe and, for resize, return the new
61
90
  * tween window + remapped keyframes.
@@ -76,20 +105,35 @@ export function resolveKeyframeRetime(opts: {
76
105
  if (tweenDuration <= 0) return { kind: "noop" };
77
106
  const tweenEnd = tweenStart + tweenDuration;
78
107
 
79
- // Within the tween window plain move (re-key the tween-%). This branch never
80
- // touches the keyframes array, so it still works for synthesized flat tweens.
108
+ // A flat tween's synthesized diamonds are its real start/end boundaries
109
+ // there is no authored keyframe node to re-key. Moving either boundary must
110
+ // therefore resize the tween window while keeping the opposite endpoint at
111
+ // its absolute time. Returning `resize` with an empty remap lets the caller
112
+ // update the flat tween's position/duration instead of sending a keyframe
113
+ // mutation that would silently no-op.
114
+ const flatBoundary = resolveFlatTweenBoundaryRetime({
115
+ keyframeCount: keyframes.length,
116
+ draggedTweenPct,
117
+ tweenStart,
118
+ tweenEnd,
119
+ dropAbsTime,
120
+ });
121
+ if (flatBoundary) return flatBoundary;
122
+
123
+ // Within the tween window → plain move (re-key the tween-%).
81
124
  if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
82
125
  const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
83
126
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
84
127
  return { kind: "move", toTweenPct };
85
128
  }
86
129
 
87
- // Boundary resize needs the real keyframes to remap; a flat tween has none here.
130
+ // Boundary resize needs the real keyframes to remap. Flat start/end boundaries
131
+ // were handled above.
88
132
  if (keyframes.length === 0) return { kind: "noop" };
89
133
 
90
134
  const newStart = Math.min(dropAbsTime, tweenStart);
91
135
  const newEnd = Math.max(dropAbsTime, tweenEnd);
92
- const newDuration = Math.max(0.01, newEnd - newStart);
136
+ const newDuration = Math.max(MIN_TWEEN_DURATION, newEnd - newStart);
93
137
 
94
138
  // The dragged keyframe is the one whose tween-% is closest to draggedTweenPct.
95
139
  let draggedIdx = 0;
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveTimelineKeyframeTarget } from "./useTimelineEditCallbacks";
3
+
4
+ const FLAT_ANIMATION = {
5
+ id: "#title-fromTo-2900",
6
+ propertyGroup: null,
7
+ };
8
+
9
+ describe("resolveTimelineKeyframeTarget", () => {
10
+ it("resolves a synthesized diamond when one mixed flat tween is selected", () => {
11
+ expect(
12
+ resolveTimelineKeyframeTarget(
13
+ 8.75,
14
+ [{ percentage: 8.75, tweenPercentage: 100 }],
15
+ [FLAT_ANIMATION],
16
+ ),
17
+ ).toEqual({ animId: "#title-fromTo-2900", tweenPct: 100 });
18
+ });
19
+
20
+ it("keeps multiple ungrouped flat tweens unresolved instead of guessing", () => {
21
+ expect(
22
+ resolveTimelineKeyframeTarget(
23
+ 8.75,
24
+ [{ percentage: 8.75, tweenPercentage: 100 }],
25
+ [FLAT_ANIMATION, { id: "#title-to-2900", propertyGroup: null }],
26
+ ),
27
+ ).toBeNull();
28
+ });
29
+
30
+ it("keeps multiple flat tweens in the same property group unresolved", () => {
31
+ expect(
32
+ resolveTimelineKeyframeTarget(
33
+ 50,
34
+ [{ percentage: 50, tweenPercentage: 100, propertyGroup: "position" }],
35
+ [
36
+ { id: "position-a", propertyGroup: "position" },
37
+ { id: "position-b", propertyGroup: "position" },
38
+ ],
39
+ ),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it("keeps a keyframed and flat tween in the same property group unresolved", () => {
44
+ expect(
45
+ resolveTimelineKeyframeTarget(
46
+ 50,
47
+ [{ percentage: 50, tweenPercentage: 25, propertyGroup: "position" }],
48
+ [
49
+ { id: "position-keyframed", propertyGroup: "position", keyframes: {} },
50
+ { id: "position-flat", propertyGroup: "position" },
51
+ ],
52
+ ),
53
+ ).toBeNull();
54
+ });
55
+
56
+ it("keeps multiple ungrouped keyframed tweens unresolved", () => {
57
+ expect(
58
+ resolveTimelineKeyframeTarget(
59
+ 50,
60
+ [{ percentage: 50, tweenPercentage: 25 }],
61
+ [
62
+ { id: "ungrouped-a", keyframes: {} },
63
+ { id: "ungrouped-b", keyframes: {} },
64
+ ],
65
+ ),
66
+ ).toBeNull();
67
+ });
68
+
69
+ it("does not infer an animation when the rendered diamond misses the cache", () => {
70
+ expect(resolveTimelineKeyframeTarget(60, [], [FLAT_ANIMATION])).toBeNull();
71
+ });
72
+
73
+ it("resolves the sole candidate in an explicit property group", () => {
74
+ expect(
75
+ resolveTimelineKeyframeTarget(
76
+ 50,
77
+ [{ percentage: 50, tweenPercentage: 25, propertyGroup: "position" }],
78
+ [
79
+ { id: "opacity", propertyGroup: "opacity" },
80
+ { id: "position", propertyGroup: "position" },
81
+ ],
82
+ ),
83
+ ).toEqual({ animId: "position", tweenPct: 25 });
84
+ });
85
+ });
@@ -36,6 +36,41 @@ export interface TimelineEditCallbackDeps {
36
36
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
37
37
  }
38
38
 
39
+ interface TimelineKeyframeTargetAnimation {
40
+ id: string;
41
+ propertyGroup?: string | null;
42
+ keyframes?: unknown;
43
+ }
44
+
45
+ interface TimelineCachedKeyframe {
46
+ percentage: number;
47
+ tweenPercentage?: number;
48
+ propertyGroup?: string;
49
+ }
50
+
51
+ /**
52
+ * Resolve a rendered timeline diamond back to the animation that authored it.
53
+ * Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
54
+ * a property group nor real keyframes. The cache currently carries a property
55
+ * group, not an animation id, so resolution is safe only when that group has a
56
+ * single candidate. Ambiguous candidates remain unresolved rather than
57
+ * retiming an arbitrary tween.
58
+ */
59
+ export function resolveTimelineKeyframeTarget(
60
+ pct: number,
61
+ keyframes: ReadonlyArray<TimelineCachedKeyframe>,
62
+ animations: ReadonlyArray<TimelineKeyframeTargetAnimation>,
63
+ ): { animId: string; tweenPct: number } | null {
64
+ const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
65
+ if (!kf) return null;
66
+ const group = kf?.propertyGroup;
67
+ const candidates = group
68
+ ? animations.filter((animation) => animation.propertyGroup === group)
69
+ : animations.filter((animation) => !animation.propertyGroup);
70
+ const animation = candidates.length === 1 ? candidates[0] : undefined;
71
+ return animation ? { animId: animation.id, tweenPct: kf.tweenPercentage ?? pct } : null;
72
+ }
73
+
39
74
  /**
40
75
  * Builds the timeline edit callback bag (move/resize/split/razor plus the
41
76
  * keyframe-diamond callbacks) provided to `<Timeline>` via TimelineEditProvider.
@@ -76,12 +111,7 @@ export function useTimelineEditCallbacks({
76
111
  // fallow-ignore-next-line complexity
77
112
  (pct: number): { animId: string; tweenPct: number } | null => {
78
113
  const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
79
- const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
80
- const group = kf?.propertyGroup;
81
- const anim =
82
- (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
83
- selectedGsapAnimations.find((a) => a.keyframes);
84
- return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null;
114
+ return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
85
115
  },
86
116
  [domEditSelection?.id, selectedGsapAnimations],
87
117
  );
@@ -154,12 +184,22 @@ export function useTimelineEditCallbacks({
154
184
  decision.position != null &&
155
185
  decision.duration != null
156
186
  ) {
157
- handleGsapResizeKeyframedTween(
158
- target.animId,
159
- decision.position,
160
- decision.duration,
161
- decision.pctRemap,
162
- );
187
+ if (anim.keyframes) {
188
+ handleGsapResizeKeyframedTween(
189
+ target.animId,
190
+ decision.position,
191
+ decision.duration,
192
+ decision.pctRemap,
193
+ );
194
+ } else {
195
+ // resize-keyframed-tween requires an authored `keyframes` AST node
196
+ // and intentionally no-ops for a flat tween. Update its real tween
197
+ // window through the metadata writer (and SDK cutover path) instead.
198
+ handleGsapUpdateMeta(target.animId, {
199
+ position: decision.position,
200
+ duration: decision.duration,
201
+ });
202
+ }
163
203
  }
164
204
  },
165
205
  onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
@@ -31,7 +31,7 @@ function shellSingleQuote(value: string): string {
31
31
  return `'${value.replace(/'/g, `'\\''`)}'`;
32
32
  }
33
33
 
34
- interface VariablesPanelProps {
34
+ export interface StudioEditPersistenceProps {
35
35
  sdkSession: Composition | null;
36
36
  publishSdkSession: PublishSdkSession;
37
37
  reloadPreview: () => void;
@@ -43,6 +43,8 @@ interface VariablesPanelProps {
43
43
  }) => Promise<void>;
44
44
  }
45
45
 
46
+ type VariablesPanelProps = StudioEditPersistenceProps;
47
+
46
48
  function formatIssue(issue: VariableValidationIssue): string {
47
49
  switch (issue.kind) {
48
50
  case "undeclared":
@@ -17,6 +17,7 @@ export function useFileManagerContextOptional(): FileManagerValue | null {
17
17
 
18
18
  export function FileManagerProvider({
19
19
  value: {
20
+ // fallow-ignore-next-line code-duplication
20
21
  editingFile,
21
22
  setEditingFile,
22
23
  projectDir,
@@ -25,6 +25,7 @@ function renderKeyframeOps(over: {
25
25
  }) {
26
26
  const captured: { api: HookApi | null } = { api: null };
27
27
  function Probe() {
28
+ // fallow-ignore-next-line code-duplication
28
29
  captured.api = useGsapKeyframeOps({
29
30
  activeCompPath: "index.html",
30
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
@@ -1,3 +1,5 @@
1
+ // fallow-ignore-file code-duplication
2
+ // Add/remove operation-family transaction shapes stay parallel until SDK graduation.
1
3
  import { useCallback } from "react";
2
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
5
  import type { Composition } from "@hyperframes/sdk";
@@ -84,6 +84,7 @@ export function useGsapPropertyDebounce(
84
84
  const mutation = { type: "update-property", animationId, property, value };
85
85
  const label = `Edit GSAP ${property}`;
86
86
  try {
87
+ // fallow-ignore-next-line code-duplication
87
88
  const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
88
89
  if (sdkSession && sdkDeps) {
89
90
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -9,6 +9,7 @@ import type { RecordEditInput } from "./timelineEditingHelpers";
9
9
 
10
10
  interface UseRazorSplitOptions {
11
11
  projectId: string | null;
12
+ // fallow-ignore-next-line code-duplication
12
13
  activeCompPath: string | null;
13
14
  showToast: (message: string, tone?: "error" | "info") => void;
14
15
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
 
3
4
  import React, { act, useRef } from "react";
@@ -1,3 +1,5 @@
1
+ // fallow-ignore-file code-duplication
2
+ // Move/resize operation families remain parallel until SDK graduation.
1
3
  import { useCallback, type MutableRefObject, type RefObject } from "react";
2
4
  import type { Composition } from "@hyperframes/sdk";
3
5
  import type { TimelineElement } from "../player";
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { resolveSeekPercent } from "./PlayerControls";
3
- import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
4
3
 
5
4
  describe("resolveSeekPercent", () => {
6
5
  it("returns 0 when the track width is invalid", () => {
@@ -19,19 +18,3 @@ describe("resolveSeekPercent", () => {
19
18
  expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
20
19
  });
21
20
  });
22
-
23
- describe("shouldMutePreviewAudio", () => {
24
- it("mutes when the user toggled audio off", () => {
25
- expect(shouldMutePreviewAudio(true, 1)).toBe(true);
26
- });
27
-
28
- it("auto-mutes above 1x playback", () => {
29
- expect(shouldMutePreviewAudio(false, 1.5)).toBe(true);
30
- expect(shouldMutePreviewAudio(false, 2)).toBe(true);
31
- });
32
-
33
- it("keeps audio on at 1x or slower when the user has not muted it", () => {
34
- expect(shouldMutePreviewAudio(false, 1)).toBe(false);
35
- expect(shouldMutePreviewAudio(false, 0.5)).toBe(false);
36
- });
37
- });
@@ -2,7 +2,6 @@ import { useRef, useCallback, useEffect, memo } from "react";
2
2
  import gsap from "gsap";
3
3
  import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
4
4
  import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
5
- import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
6
5
  import { usePlayerStore } from "../store/playerStore";
7
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
8
7
  import { Tooltip } from "../../components/ui";
@@ -60,40 +59,30 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
60
59
 
61
60
  const MuteButton = memo(function MuteButton({
62
61
  audioMuted,
63
- audioAutoMuted,
64
- effectiveAudioMuted,
65
62
  controlsDisabled,
66
63
  setAudioMuted,
67
64
  }: {
68
65
  audioMuted: boolean;
69
- audioAutoMuted: boolean;
70
- effectiveAudioMuted: boolean;
71
66
  controlsDisabled: boolean;
72
67
  setAudioMuted: (v: boolean) => void;
73
68
  }) {
74
- const label = audioAutoMuted
75
- ? "Audio muted above 1x speed"
76
- : audioMuted
77
- ? "Unmute audio"
78
- : "Mute audio";
69
+ const label = audioMuted ? "Unmute audio" : "Mute audio";
79
70
  return (
80
71
  <Tooltip label={label}>
81
72
  <button
82
73
  type="button"
83
74
  onClick={() => {
84
- if (!audioAutoMuted) {
85
- trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
86
- setAudioMuted(!audioMuted);
87
- }
75
+ trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
76
+ setAudioMuted(!audioMuted);
88
77
  }}
89
- disabled={controlsDisabled || audioAutoMuted}
78
+ disabled={controlsDisabled}
90
79
  aria-label={label}
91
- aria-pressed={effectiveAudioMuted}
80
+ aria-pressed={audioMuted}
92
81
  className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
93
- effectiveAudioMuted
82
+ audioMuted
94
83
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
95
84
  : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
96
- } ${audioAutoMuted ? "opacity-70" : ""}`}
85
+ }`}
97
86
  >
98
87
  <svg
99
88
  width="13"
@@ -107,7 +96,7 @@ const MuteButton = memo(function MuteButton({
107
96
  aria-hidden="true"
108
97
  >
109
98
  <path d="M11 5 6 9H3v6h3l5 4V5Z" />
110
- {effectiveAudioMuted ? (
99
+ {audioMuted ? (
111
100
  <>
112
101
  <path d="m19 9-6 6" />
113
102
  <path d="m13 9 6 6" />
@@ -383,8 +372,6 @@ export const PlayerControls = memo(function PlayerControls({
383
372
  const durationRef = useRef(duration);
384
373
  durationRef.current = duration;
385
374
  const controlsDisabled = disabled || !timelineReady;
386
- const audioAutoMuted = playbackRate > 1;
387
- const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
388
375
 
389
376
  useEffect(() => {
390
377
  if (!timeDisplayRef.current) return;
@@ -486,8 +473,6 @@ export const PlayerControls = memo(function PlayerControls({
486
473
 
487
474
  <MuteButton
488
475
  audioMuted={audioMuted}
489
- audioAutoMuted={audioAutoMuted}
490
- effectiveAudioMuted={effectiveAudioMuted}
491
476
  controlsDisabled={controlsDisabled}
492
477
  setAudioMuted={setAudioMuted}
493
478
  />
@@ -189,7 +189,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
189
189
  expect(usePlayerStore.getState().audioMuted).toBe(false);
190
190
  });
191
191
 
192
- it("M does NOT toggle audioMuted above 1x playback (matches button gating)", () => {
192
+ it("M toggles audioMuted above 1x playback too", () => {
193
193
  const { dispatch } = setupHook();
194
194
  usePlayerStore.setState({ playbackRate: 2, audioMuted: false });
195
195
 
@@ -197,7 +197,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
197
197
  dispatch(keydown({ code: "KeyM", key: "m" }));
198
198
  });
199
199
 
200
- expect(usePlayerStore.getState().audioMuted).toBe(false);
200
+ expect(usePlayerStore.getState().audioMuted).toBe(true);
201
201
  });
202
202
 
203
203
  it("Shift+L toggles loopEnabled without starting forward shuttle", () => {
@@ -116,10 +116,7 @@ export function usePlaybackKeyboard({
116
116
  if (key === "m") {
117
117
  e.preventDefault();
118
118
  const state = usePlayerStore.getState();
119
- // Audio is force-muted above 1x playback — match the mute button's gating.
120
- if (state.playbackRate <= 1) {
121
- state.setAudioMuted(!state.audioMuted);
122
- }
119
+ state.setAudioMuted(!state.audioMuted);
123
120
  return;
124
121
  }
125
122
  if (key === "l" && e.shiftKey) {
@@ -211,7 +211,7 @@ describe("useTimelinePlayer seek hydration", () => {
211
211
  });
212
212
 
213
213
  describe("useTimelinePlayer audio controls (#835)", () => {
214
- it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
214
+ it("applies playback-rate changes immediately and keeps audio unmuted above 1x", () => {
215
215
  const { api, root } = renderTimelinePlayerHarness();
216
216
  const postMessage = vi.fn();
217
217
  const timeScale = vi.fn();
@@ -244,7 +244,7 @@ describe("useTimelinePlayer audio controls (#835)", () => {
244
244
  source: "hf-parent",
245
245
  type: "control",
246
246
  action: "set-muted",
247
- muted: true,
247
+ muted: false,
248
248
  }),
249
249
  "*",
250
250
  );