@hyperframes/studio 0.8.53 → 0.8.55
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-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
- package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
- package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
- package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
- package/dist/index.d.ts +440 -10
- package/dist/index.html +1 -1
- package/dist/index.js +1891 -1544
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +481 -4
- package/src/hooks/useTimelineEditing.ts +151 -27
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +40 -2
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.55",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/sdk": "0.8.
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.55",
|
|
55
|
+
"@hyperframes/sdk": "0.8.55",
|
|
56
|
+
"@hyperframes/parsers": "0.8.55",
|
|
57
|
+
"@hyperframes/studio-server": "0.8.55",
|
|
58
|
+
"@hyperframes/player": "0.8.55"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.55"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
|
-
import type { TimelineElement } from "../../player";
|
|
3
|
+
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
4
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
5
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
6
6
|
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
@@ -77,6 +77,9 @@ export interface TimelinePaneProps {
|
|
|
77
77
|
timelineToolbar?: ReactNode;
|
|
78
78
|
/** Slot rendered below the timeline tracks */
|
|
79
79
|
timelineFooter?: ReactNode;
|
|
80
|
+
/** Slot for a host's own overlay spanning the whole timeline area (e.g. a draft/ghost
|
|
81
|
+
* clip), positioned absolutely the same way PreviewPane's previewOverlay is. */
|
|
82
|
+
timelineOverlay?: ReactNode;
|
|
80
83
|
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
|
81
84
|
renderClipContent?: (
|
|
82
85
|
element: TimelineElement,
|
|
@@ -101,6 +104,7 @@ export interface TimelinePaneProps {
|
|
|
101
104
|
) => Promise<void> | void;
|
|
102
105
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
103
106
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
107
|
+
onRangeSelect?: (range: TimelineTimeRange | null) => void;
|
|
104
108
|
/** Copy/paste/duplicate act on the store's own selection, not a passed
|
|
105
109
|
* element, so unlike onDeleteElement they need no composition-basis wrapper. */
|
|
106
110
|
onCopyClip?: () => boolean;
|
|
@@ -113,6 +117,7 @@ export interface TimelinePaneProps {
|
|
|
113
117
|
export function TimelinePane({
|
|
114
118
|
timelineToolbar,
|
|
115
119
|
timelineFooter,
|
|
120
|
+
timelineOverlay,
|
|
116
121
|
renderClipContent,
|
|
117
122
|
onFileDrop,
|
|
118
123
|
onDeleteElement,
|
|
@@ -121,6 +126,7 @@ export function TimelinePane({
|
|
|
121
126
|
onCompositionDrop,
|
|
122
127
|
onBlockedEditAttempt,
|
|
123
128
|
onSelectTimelineElement,
|
|
129
|
+
onRangeSelect,
|
|
124
130
|
onCopyClip,
|
|
125
131
|
onPasteClip,
|
|
126
132
|
onDuplicateClip,
|
|
@@ -278,6 +284,7 @@ export function TimelinePane({
|
|
|
278
284
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
279
285
|
onSplitElement={handleSplitElement}
|
|
280
286
|
onSelectElement={onSelectTimelineElement}
|
|
287
|
+
onRangeSelect={onRangeSelect}
|
|
281
288
|
onCopyClip={onCopyClip}
|
|
282
289
|
onPasteClip={onPasteClip}
|
|
283
290
|
onDuplicateClip={onDuplicateClip}
|
|
@@ -285,6 +292,9 @@ export function TimelinePane({
|
|
|
285
292
|
/>
|
|
286
293
|
</div>
|
|
287
294
|
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
|
|
295
|
+
{timelineOverlay && (
|
|
296
|
+
<div className="pointer-events-none absolute inset-0 z-20">{timelineOverlay}</div>
|
|
297
|
+
)}
|
|
288
298
|
{timelineDisabled && (
|
|
289
299
|
<div
|
|
290
300
|
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
@@ -135,7 +135,7 @@ export async function materializeIfDynamic(
|
|
|
135
135
|
void iframe;
|
|
136
136
|
void commitMutation;
|
|
137
137
|
void selection;
|
|
138
|
-
throw new GsapEditBlockedError("source-uneditable");
|
|
138
|
+
throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { buildStudioSaveFailureProperties } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
import {
|
|
5
|
+
GsapEditBlockedError,
|
|
6
|
+
assertGsapEditPersisted,
|
|
7
|
+
directEditOutcomeForProperties,
|
|
8
|
+
} from "./gsapEditOutcome";
|
|
9
|
+
|
|
10
|
+
function blockDetailFor(error: unknown): unknown {
|
|
11
|
+
return buildStudioSaveFailureProperties({ source: "gsap_commit", error }).block_detail;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Minimal animation that writes `x`, so the editability check actually runs. */
|
|
15
|
+
function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
16
|
+
return {
|
|
17
|
+
id: "a1",
|
|
18
|
+
targetSelector: "#box",
|
|
19
|
+
method: "to",
|
|
20
|
+
position: 0,
|
|
21
|
+
properties: { x: 100 },
|
|
22
|
+
...overrides,
|
|
23
|
+
} as GsapAnimation;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const POSITION = new Set(["x"]);
|
|
27
|
+
|
|
28
|
+
describe("a blocked GSAP edit names which of the nine causes it hit", () => {
|
|
29
|
+
// The three causes inside assertGsapAnimationDirectlyEditable share one
|
|
30
|
+
// message, so only the detail distinguishes them. Asserted through the
|
|
31
|
+
// exported wrapper because that is the path a real edit takes — and the
|
|
32
|
+
// wrapper used to rebuild the outcome from `reason` alone, dropping it.
|
|
33
|
+
it("reports a runtime-dynamic provenance", () => {
|
|
34
|
+
const outcome = directEditOutcomeForProperties(
|
|
35
|
+
[animation({ provenance: { kind: "runtime-dynamic" } })],
|
|
36
|
+
POSITION,
|
|
37
|
+
);
|
|
38
|
+
expect(outcome).toEqual({
|
|
39
|
+
status: "blocked",
|
|
40
|
+
reason: "source-uneditable",
|
|
41
|
+
detail: "provenance-runtime-dynamic",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports unresolved keyframes separately from provenance", () => {
|
|
46
|
+
const outcome = directEditOutcomeForProperties(
|
|
47
|
+
[animation({ hasUnresolvedKeyframes: true })],
|
|
48
|
+
POSITION,
|
|
49
|
+
);
|
|
50
|
+
expect(outcome).toEqual({
|
|
51
|
+
status: "blocked",
|
|
52
|
+
reason: "source-uneditable",
|
|
53
|
+
detail: "unresolved-keyframes",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reports an unresolved selector separately from provenance", () => {
|
|
58
|
+
const outcome = directEditOutcomeForProperties(
|
|
59
|
+
[animation({ hasUnresolvedSelector: true })],
|
|
60
|
+
POSITION,
|
|
61
|
+
);
|
|
62
|
+
expect(outcome).toEqual({
|
|
63
|
+
status: "blocked",
|
|
64
|
+
reason: "source-uneditable",
|
|
65
|
+
detail: "unresolved-selector",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("still routes a helper or loop to unroll, which has its own message", () => {
|
|
70
|
+
const outcome = directEditOutcomeForProperties(
|
|
71
|
+
[animation({ provenance: { kind: "loop" } })],
|
|
72
|
+
POSITION,
|
|
73
|
+
);
|
|
74
|
+
expect(outcome).toEqual({ status: "blocked", reason: "unroll-required", detail: undefined });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("leaves a literal tween editable", () => {
|
|
78
|
+
expect(directEditOutcomeForProperties([animation()], POSITION)).toEqual({
|
|
79
|
+
status: "persisted",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("carries the detail through a returned outcome, not just a thrown one", () => {
|
|
84
|
+
// gsapRuntimeBridge returns blocked outcomes rather than throwing.
|
|
85
|
+
expect(() =>
|
|
86
|
+
assertGsapEditPersisted({
|
|
87
|
+
status: "blocked",
|
|
88
|
+
reason: "source-uneditable",
|
|
89
|
+
detail: "no-position-tween",
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(
|
|
92
|
+
expect.objectContaining({ name: "GsapEditBlockedError", detail: "no-position-tween" }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("surfaces the detail on the telemetry payload", () => {
|
|
97
|
+
expect(
|
|
98
|
+
blockDetailFor(
|
|
99
|
+
new GsapEditBlockedError("source-uneditable", "live-rotation-no-source-tween"),
|
|
100
|
+
),
|
|
101
|
+
).toBe("live-rotation-no-source-tween");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("omits the detail for a block that has no specific cause", () => {
|
|
105
|
+
expect(blockDetailFor(new GsapEditBlockedError("no-selector"))).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores a detail-shaped field on an error that is not a block", () => {
|
|
109
|
+
// Guards the name check: any error can carry a `detail`, and reporting a
|
|
110
|
+
// save failure's detail as a block cause would be a silent mislabel.
|
|
111
|
+
const impostor = Object.assign(new Error("network down"), { detail: "unresolved-selector" });
|
|
112
|
+
expect(blockDetailFor(impostor)).toBeUndefined();
|
|
113
|
+
expect(blockDetailFor("not an error")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -2,6 +2,27 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
|
|
|
2
2
|
|
|
3
3
|
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Which of the nine situations produced a block. The user-facing `reason` stays
|
|
7
|
+
* coarse — three messages — but "source-uneditable" alone covers nine distinct
|
|
8
|
+
* causes, and `edit_blocked` telemetry could not tell them apart. That matters
|
|
9
|
+
* because the copy ("This animation is computed at runtime") is only literally
|
|
10
|
+
* true for `provenance-runtime-dynamic`; the others are parser or source-match
|
|
11
|
+
* limits, where the animation may well be plain authored source.
|
|
12
|
+
*
|
|
13
|
+
* Telemetry only. Nothing branches on it.
|
|
14
|
+
*/
|
|
15
|
+
export type GsapEditBlockDetail =
|
|
16
|
+
| "provenance-runtime-dynamic"
|
|
17
|
+
| "unresolved-keyframes"
|
|
18
|
+
| "unresolved-selector"
|
|
19
|
+
| "geometry-unresolved-source"
|
|
20
|
+
| "live-position-no-source-tween"
|
|
21
|
+
| "no-position-tween"
|
|
22
|
+
| "live-rotation-no-source-tween"
|
|
23
|
+
| "live-resize-no-source-tween"
|
|
24
|
+
| "zero-duration-tween";
|
|
25
|
+
|
|
5
26
|
export type GsapEditOutcome =
|
|
6
27
|
| {
|
|
7
28
|
status: "persisted";
|
|
@@ -22,7 +43,7 @@ export type GsapEditOutcome =
|
|
|
22
43
|
*/
|
|
23
44
|
ownsDragOffset?: boolean;
|
|
24
45
|
}
|
|
25
|
-
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
46
|
+
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
|
|
26
47
|
|
|
27
48
|
const COPY: Record<GsapEditBlockReason, string> = {
|
|
28
49
|
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
@@ -32,25 +53,32 @@ const COPY: Record<GsapEditBlockReason, string> = {
|
|
|
32
53
|
};
|
|
33
54
|
|
|
34
55
|
export class GsapEditBlockedError extends Error {
|
|
35
|
-
constructor(
|
|
56
|
+
constructor(
|
|
57
|
+
readonly reason: GsapEditBlockReason,
|
|
58
|
+
readonly detail?: GsapEditBlockDetail,
|
|
59
|
+
) {
|
|
36
60
|
super(COPY[reason]);
|
|
37
61
|
this.name = "GsapEditBlockedError";
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
42
|
-
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
|
|
66
|
+
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
46
70
|
const editability = editabilityForProvenance(animation.provenance);
|
|
47
71
|
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
// Same message for all three, but they are different problems: only the first
|
|
73
|
+
// is genuinely a runtime-computed value.
|
|
74
|
+
if (editability === "source") {
|
|
75
|
+
throw new GsapEditBlockedError("source-uneditable", "provenance-runtime-dynamic");
|
|
76
|
+
}
|
|
77
|
+
if (animation.hasUnresolvedKeyframes) {
|
|
78
|
+
throw new GsapEditBlockedError("source-uneditable", "unresolved-keyframes");
|
|
79
|
+
}
|
|
80
|
+
if (animation.hasUnresolvedSelector) {
|
|
81
|
+
throw new GsapEditBlockedError("source-uneditable", "unresolved-selector");
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
84
|
|
|
@@ -84,7 +112,8 @@ export function directEditOutcomeForProperties(
|
|
|
84
112
|
}
|
|
85
113
|
return { status: "persisted" };
|
|
86
114
|
} catch (error) {
|
|
87
|
-
if (isGsapEditBlockedError(error))
|
|
115
|
+
if (isGsapEditBlockedError(error))
|
|
116
|
+
return { status: "blocked", reason: error.reason, detail: error.detail };
|
|
88
117
|
throw error;
|
|
89
118
|
}
|
|
90
119
|
}
|
|
@@ -180,11 +180,15 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
|
|
|
180
180
|
);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
function makeBoxSelection(): { el: HTMLElement; selection: DomEditSelection } {
|
|
184
184
|
const el = document.createElement("div");
|
|
185
185
|
el.id = "box";
|
|
186
186
|
document.body.append(el);
|
|
187
|
-
|
|
187
|
+
return { el, selection: { id: "box", selector: "#box", element: el } as DomEditSelection };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
|
|
191
|
+
const { el, selection } = makeBoxSelection();
|
|
188
192
|
const liveSizeTween = {
|
|
189
193
|
targets: () => [el],
|
|
190
194
|
vars: { width: 300, duration: 1 },
|
|
@@ -199,7 +203,42 @@ it("blocks when runtime size motion exists but the authored tween cannot be reso
|
|
|
199
203
|
|
|
200
204
|
await expect(
|
|
201
205
|
tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
|
|
202
|
-
).resolves.toEqual({
|
|
206
|
+
).resolves.toEqual({
|
|
207
|
+
status: "blocked",
|
|
208
|
+
reason: "source-uneditable",
|
|
209
|
+
detail: "live-resize-no-source-tween",
|
|
210
|
+
});
|
|
211
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("blocks a tween with no positive duration instead of committing into it", async () => {
|
|
215
|
+
const { selection } = makeBoxSelection();
|
|
216
|
+
// A zero-duration `from` is not an instant hold (those are `set`/`to`), so it
|
|
217
|
+
// reaches the duration check: a resize has no timeline position to land on.
|
|
218
|
+
const zeroDurationTween = {
|
|
219
|
+
id: "#box-size",
|
|
220
|
+
targetSelector: "#box",
|
|
221
|
+
propertyGroup: "size",
|
|
222
|
+
method: "from",
|
|
223
|
+
position: 0,
|
|
224
|
+
duration: 0,
|
|
225
|
+
properties: { width: 150, height: 150 },
|
|
226
|
+
} as unknown as GsapAnimation;
|
|
227
|
+
const commitMutation = vi.fn();
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
tryGsapResizeIntercept(
|
|
231
|
+
selection,
|
|
232
|
+
{ width: 344, height: 344 },
|
|
233
|
+
[zeroDurationTween],
|
|
234
|
+
null,
|
|
235
|
+
commitMutation,
|
|
236
|
+
),
|
|
237
|
+
).resolves.toEqual({
|
|
238
|
+
status: "blocked",
|
|
239
|
+
reason: "source-uneditable",
|
|
240
|
+
detail: "zero-duration-tween",
|
|
241
|
+
});
|
|
203
242
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
204
243
|
});
|
|
205
244
|
|
|
@@ -137,7 +137,12 @@ export async function tryGsapResizeIntercept(
|
|
|
137
137
|
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
138
138
|
: false;
|
|
139
139
|
if (!anim && hasLiveResizeTween) {
|
|
140
|
-
|
|
140
|
+
// Third twin of the position/rotation cases: a live tween with no source match.
|
|
141
|
+
return {
|
|
142
|
+
status: "blocked",
|
|
143
|
+
reason: "source-uneditable",
|
|
144
|
+
detail: "live-resize-no-source-tween",
|
|
145
|
+
};
|
|
141
146
|
}
|
|
142
147
|
logResize("intercept-enter", {
|
|
143
148
|
hasScaleGroup,
|
|
@@ -194,7 +199,12 @@ export async function tryGsapResizeIntercept(
|
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
const tweenDuration = resolveTweenDuration(anim);
|
|
197
|
-
if (tweenDuration <= 0)
|
|
202
|
+
if (tweenDuration <= 0) {
|
|
203
|
+
// The tween exists in source but has no positive duration, so there is no
|
|
204
|
+
// timeline position at which a resize could land — a different cause from
|
|
205
|
+
// the live-tween-without-a-source-match cases above.
|
|
206
|
+
return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
|
|
207
|
+
}
|
|
198
208
|
|
|
199
209
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
200
210
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
@@ -94,7 +94,11 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
94
94
|
vi.fn().mockResolvedValue([]),
|
|
95
95
|
);
|
|
96
96
|
|
|
97
|
-
expect(result).toEqual({
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
status: "blocked",
|
|
99
|
+
reason: "source-uneditable",
|
|
100
|
+
detail: "live-position-no-source-tween",
|
|
101
|
+
});
|
|
98
102
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
99
103
|
});
|
|
100
104
|
|
|
@@ -354,7 +358,11 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
|
|
354
358
|
fakeIframe("puck-b", [liveRotation]),
|
|
355
359
|
commitMutation,
|
|
356
360
|
),
|
|
357
|
-
).resolves.toEqual({
|
|
361
|
+
).resolves.toEqual({
|
|
362
|
+
status: "blocked",
|
|
363
|
+
reason: "source-uneditable",
|
|
364
|
+
detail: "live-rotation-no-source-tween",
|
|
365
|
+
});
|
|
358
366
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
359
367
|
});
|
|
360
368
|
|
|
@@ -156,7 +156,13 @@ async function preflightGsapDragIntercept(
|
|
|
156
156
|
const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
157
157
|
|
|
158
158
|
if (hasLivePosition && !posAnim) {
|
|
159
|
-
|
|
159
|
+
// GSAP is visibly moving this element but the parser found no position
|
|
160
|
+
// tween for it — a source-match gap, not necessarily computed source.
|
|
161
|
+
return {
|
|
162
|
+
status: "blocked",
|
|
163
|
+
reason: "source-uneditable",
|
|
164
|
+
detail: "live-position-no-source-tween",
|
|
165
|
+
};
|
|
160
166
|
}
|
|
161
167
|
if (!posAnim && !writeTargetSelector(selection)) {
|
|
162
168
|
return { status: "blocked", reason: "no-selector" };
|
|
@@ -268,7 +274,7 @@ export async function tryGsapDragIntercept(
|
|
|
268
274
|
}
|
|
269
275
|
|
|
270
276
|
if (!posAnim) {
|
|
271
|
-
return { status: "blocked", reason: "source-uneditable" };
|
|
277
|
+
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
272
278
|
}
|
|
273
279
|
|
|
274
280
|
// Verify the anim ID is still valid in the current file. The React-state
|
|
@@ -353,7 +359,12 @@ export async function tryGsapRotationIntercept(
|
|
|
353
359
|
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
|
|
354
360
|
: false;
|
|
355
361
|
if (!anim && hasLiveRotationTween) {
|
|
356
|
-
|
|
362
|
+
// Rotation twin of the position case above: live tween, no source match.
|
|
363
|
+
return {
|
|
364
|
+
status: "blocked",
|
|
365
|
+
reason: "source-uneditable",
|
|
366
|
+
detail: "live-rotation-no-source-tween",
|
|
367
|
+
};
|
|
357
368
|
}
|
|
358
369
|
|
|
359
370
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
@@ -28,6 +28,19 @@ import {
|
|
|
28
28
|
type UseTimelineElementVisibilityEditingInput,
|
|
29
29
|
} from "./timelineTrackVisibility";
|
|
30
30
|
|
|
31
|
+
interface AudioGroupCarveInput extends UseTimelineElementVisibilityEditingInput {
|
|
32
|
+
checkEditable?: (elements: readonly TimelineElement[]) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertAudioGroupEditable(
|
|
36
|
+
checkEditable: AudioGroupCarveInput["checkEditable"],
|
|
37
|
+
elements: readonly TimelineElement[],
|
|
38
|
+
): void {
|
|
39
|
+
if (checkEditable && !checkEditable(elements)) {
|
|
40
|
+
throw new Error("Timeline edit blocked");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Assign (or restore) `data-audio-group` across a set of members.
|
|
33
46
|
*
|
|
@@ -267,7 +280,8 @@ export function useAudioGroupCarveAssignment({
|
|
|
267
280
|
previewIframeRef,
|
|
268
281
|
pendingTimelineEditPathRef,
|
|
269
282
|
isRecordingRef,
|
|
270
|
-
|
|
283
|
+
checkEditable,
|
|
284
|
+
}: AudioGroupCarveInput): (
|
|
271
285
|
clipIds: readonly string[],
|
|
272
286
|
groupId: string,
|
|
273
287
|
groupLabel?: string,
|
|
@@ -289,6 +303,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
289
303
|
const domId = runtimeAudioId(item);
|
|
290
304
|
return domId !== null && wanted.has(domId);
|
|
291
305
|
});
|
|
306
|
+
if (elements.length === wanted.size) assertAudioGroupEditable(checkEditable, elements);
|
|
292
307
|
try {
|
|
293
308
|
// Loud, not silent: an unresolved id used to leave `elements` short,
|
|
294
309
|
// `createAudioGroupAndAssignMembers` returning early with no write, and
|
|
@@ -333,6 +348,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
333
348
|
isRecordingRef,
|
|
334
349
|
showToast,
|
|
335
350
|
projectIdRef,
|
|
351
|
+
checkEditable,
|
|
336
352
|
],
|
|
337
353
|
);
|
|
338
354
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
3
|
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
@@ -29,6 +29,10 @@ function patchLiveGroupAttribute(
|
|
|
29
29
|
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function audioGroupAttributeLiveKey(groupId: string, attr: string): string {
|
|
33
|
+
return `${groupId}\0${attr}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
|
|
34
38
|
* empty attribute is UNITY, not zero.
|
|
@@ -210,9 +214,16 @@ export function useSetAudioGroupAttribute({
|
|
|
210
214
|
}: UseTimelineElementVisibilityEditingInput): {
|
|
211
215
|
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
212
216
|
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
|
217
|
+
revertLive: (groupId: string, attr: string) => void;
|
|
213
218
|
} {
|
|
219
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
214
220
|
const setLive = useCallback(
|
|
215
221
|
(groupId: string, attr: string, value: string | null) => {
|
|
222
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
223
|
+
const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
|
224
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
225
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
226
|
+
}
|
|
216
227
|
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
|
217
228
|
// Live too, not just on commit: a fader drag is `setLive` per frame and
|
|
218
229
|
// `setQuiet` once on release, so without this the strip's own readout
|
|
@@ -221,6 +232,26 @@ export function useSetAudioGroupAttribute({
|
|
|
221
232
|
},
|
|
222
233
|
[previewIframeRef],
|
|
223
234
|
);
|
|
235
|
+
const revertLive = useCallback(
|
|
236
|
+
(groupId: string, attr: string) => {
|
|
237
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
238
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
239
|
+
patchLiveGroupAttribute(
|
|
240
|
+
previewIframeRef.current,
|
|
241
|
+
groupId,
|
|
242
|
+
attr,
|
|
243
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
244
|
+
);
|
|
245
|
+
syncStoredGroupAttribute(
|
|
246
|
+
groupId,
|
|
247
|
+
attr,
|
|
248
|
+
previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
|
|
249
|
+
null,
|
|
250
|
+
);
|
|
251
|
+
liveBeforeRef.current.delete(key);
|
|
252
|
+
},
|
|
253
|
+
[previewIframeRef],
|
|
254
|
+
);
|
|
224
255
|
const setQuiet = useCallback(
|
|
225
256
|
async (groupId: string, attr: string, value: string | null, label: string) => {
|
|
226
257
|
if (isRecordingRef?.current) {
|
|
@@ -242,6 +273,7 @@ export function useSetAudioGroupAttribute({
|
|
|
242
273
|
recordEdit,
|
|
243
274
|
pendingTimelineEditPathRef,
|
|
244
275
|
});
|
|
276
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
245
277
|
syncStoredGroupAttribute(groupId, attr, value);
|
|
246
278
|
} catch (error) {
|
|
247
279
|
// `persistElementAttribute` leaves the live DOM at the previous value
|
|
@@ -257,6 +289,7 @@ export function useSetAudioGroupAttribute({
|
|
|
257
289
|
console.error("[Timeline] Failed to set group attribute", error);
|
|
258
290
|
const message = error instanceof Error ? error.message : "Failed to update group";
|
|
259
291
|
showToast(message);
|
|
292
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
260
293
|
}
|
|
261
294
|
},
|
|
262
295
|
[
|
|
@@ -270,5 +303,5 @@ export function useSetAudioGroupAttribute({
|
|
|
270
303
|
projectIdRef,
|
|
271
304
|
],
|
|
272
305
|
);
|
|
273
|
-
return { setLive, setQuiet };
|
|
306
|
+
return { setLive, setQuiet, revertLive };
|
|
274
307
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
|
|
4
|
+
/** A host's verdict on one element: editable, or blocked with a reason to show. */
|
|
5
|
+
export type TimelineEditPermission = true | { blocked: true; reason: string };
|
|
6
|
+
|
|
7
|
+
export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refuses a write when any target element is blocked, toasting the host's
|
|
11
|
+
* reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function useTimelineEditGate(
|
|
14
|
+
canEdit: CanEditTimelineElement | undefined,
|
|
15
|
+
showToast: (message: string, tone?: "error" | "info") => void,
|
|
16
|
+
) {
|
|
17
|
+
return useCallback(
|
|
18
|
+
(targets: readonly TimelineElement[]): boolean => {
|
|
19
|
+
if (!canEdit) return true;
|
|
20
|
+
for (const element of targets) {
|
|
21
|
+
const verdict = canEdit(element);
|
|
22
|
+
if (verdict !== true) {
|
|
23
|
+
showToast(verdict.reason, "error");
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
[canEdit, showToast],
|
|
30
|
+
);
|
|
31
|
+
}
|