@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.
- package/dist/assets/{hyperframes-player-D4jpio27.js → hyperframes-player-BiCGkoKi.js} +1 -1
- package/dist/assets/{index-srObT-DA.js → index-CnL24iBm.js} +1 -1
- package/dist/assets/{index-CqjWmIbE.js → index-Cp6reNm_.js} +1 -1
- package/dist/assets/{index-BOMWJSjh.js → index-wCPvBGsm.js} +144 -144
- package/dist/index.html +1 -1
- package/dist/index.js +2562 -2538
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -2
- package/src/components/editor/keyframeRetime.test.ts +56 -17
- package/src/components/editor/keyframeRetime.ts +48 -4
- package/src/components/nle/useTimelineEditCallbacks.test.ts +85 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +52 -12
- package/src/components/panels/VariablesPanel.tsx +3 -1
- package/src/contexts/FileManagerContext.tsx +1 -0
- package/src/hooks/useGsapKeyframeOps.test.tsx +1 -0
- package/src/hooks/useGsapKeyframeOps.ts +2 -0
- package/src/hooks/useGsapPropertyDebounce.ts +1 -0
- package/src/hooks/useRazorSplit.ts +1 -0
- package/src/hooks/useTimelineEditing.test.tsx +1 -0
- package/src/hooks/useTimelineGroupEditing.ts +2 -0
- package/src/player/components/PlayerControls.test.ts +0 -17
- package/src/player/components/PlayerControls.tsx +8 -23
- package/src/player/hooks/usePlaybackKeyboard.test.ts +2 -2
- package/src/player/hooks/usePlaybackKeyboard.ts +1 -4
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.ts +5 -13
- package/src/player/lib/timelineDOM.ts +0 -1
- package/src/player/lib/timelineIframeHelpers.ts +0 -4
- 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.
|
|
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.
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
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("
|
|
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,
|
|
41
|
+
dropAbsTime: 5,
|
|
41
42
|
});
|
|
42
|
-
expect(r.kind).toBe("
|
|
43
|
-
expect(r.
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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("
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
).toBe(
|
|
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
|
-
//
|
|
80
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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":
|
|
@@ -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,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 =
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
setAudioMuted(!audioMuted);
|
|
87
|
-
}
|
|
75
|
+
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
|
76
|
+
setAudioMuted(!audioMuted);
|
|
88
77
|
}}
|
|
89
|
-
disabled={controlsDisabled
|
|
78
|
+
disabled={controlsDisabled}
|
|
90
79
|
aria-label={label}
|
|
91
|
-
aria-pressed={
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
247
|
+
muted: false,
|
|
248
248
|
}),
|
|
249
249
|
"*",
|
|
250
250
|
);
|