@hyperframes/studio 0.8.46 → 0.8.48
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-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- package/dist/assets/index-DnRfAiK2.css +0 -1
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -5,7 +5,11 @@ import * as studioAvailability from "../components/editor/manualEditingAvailabil
|
|
|
5
5
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
6
6
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
7
7
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isResolverDisagreement,
|
|
10
|
+
shouldDeclineTextCutoverForTarget,
|
|
11
|
+
shouldUseSdkCutover,
|
|
12
|
+
} from "./sdkCutoverEligibility";
|
|
9
13
|
import {
|
|
10
14
|
asCutoverError,
|
|
11
15
|
declinedCutover,
|
|
@@ -78,15 +82,17 @@ export async function sdkCutoverPersist(
|
|
|
78
82
|
options?: CutoverOptions,
|
|
79
83
|
): Promise<CutoverResult> {
|
|
80
84
|
if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
|
|
81
|
-
return declinedCutover("ineligible_operation");
|
|
82
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
85
|
+
return declinedCutover("ineligible_operation", "dom");
|
|
86
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "dom");
|
|
83
87
|
const hfId = selection.hfId;
|
|
84
|
-
if (!hfId) return declinedCutover("target_unaddressable");
|
|
88
|
+
if (!hfId) return declinedCutover("target_unaddressable", "dom");
|
|
85
89
|
const target = sdkSession.getElement(hfId);
|
|
86
|
-
if (!target)
|
|
90
|
+
if (!target)
|
|
91
|
+
return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
|
|
87
92
|
if (shouldDeclineTextCutoverForTarget(target, ops))
|
|
88
|
-
return declinedCutover("unsupported_text_target");
|
|
89
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
93
|
+
return declinedCutover("unsupported_text_target", "dom");
|
|
94
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
95
|
+
return declinedCutover("wrong_composition_file", "dom");
|
|
90
96
|
const result = await persistSdkCandidateMutation(
|
|
91
97
|
sdkSession,
|
|
92
98
|
targetPath,
|
|
@@ -122,10 +128,12 @@ export async function sdkTimingPersist(
|
|
|
122
128
|
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
|
|
123
129
|
// exists (it always does, for shadow/selection) — flipping the flag OFF would
|
|
124
130
|
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
|
|
125
|
-
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
|
|
126
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
127
|
-
if (!sdkSession.getElement(hfId))
|
|
128
|
-
|
|
131
|
+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
|
|
132
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "timing");
|
|
133
|
+
if (!sdkSession.getElement(hfId))
|
|
134
|
+
return declinedCutover("target_not_found", "timing", isResolverDisagreement(sdkSession, hfId));
|
|
135
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
136
|
+
return declinedCutover("wrong_composition_file", "timing");
|
|
129
137
|
try {
|
|
130
138
|
const serializedBefore = sdkSession.serialize();
|
|
131
139
|
const result = await persistSdkCandidateMutation(
|
|
@@ -166,11 +174,17 @@ export async function sdkTimingBatchPersist(
|
|
|
166
174
|
{ targetPath, compositionPath: deps.compositionPath },
|
|
167
175
|
);
|
|
168
176
|
}
|
|
169
|
-
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
|
|
170
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
171
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
|
|
178
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "timing");
|
|
179
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
180
|
+
return declinedCutover("wrong_composition_file", "timing");
|
|
181
|
+
const unresolved = changes.find((change) => !sdkSession.getElement(change.hfId));
|
|
182
|
+
if (unresolved)
|
|
183
|
+
return declinedCutover(
|
|
184
|
+
"target_not_found",
|
|
185
|
+
"timing",
|
|
186
|
+
isResolverDisagreement(sdkSession, unresolved.hfId),
|
|
187
|
+
);
|
|
174
188
|
try {
|
|
175
189
|
const serializedBefore = sdkSession.serialize();
|
|
176
190
|
const result = await persistSdkCandidateMutation(
|
|
@@ -244,9 +258,9 @@ export function sdkGsapTweenPersist(
|
|
|
244
258
|
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
|
|
245
259
|
// matches the other three chokepoints' discipline.
|
|
246
260
|
if (!sdkFamilyEnabled("gsap-animation"))
|
|
247
|
-
return Promise.resolve(declinedCutover("feature_disabled"));
|
|
261
|
+
return Promise.resolve(declinedCutover("feature_disabled", "gsap-animation"));
|
|
248
262
|
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
|
|
249
|
-
return Promise.resolve(declinedCutover("target_not_found"));
|
|
263
|
+
return Promise.resolve(declinedCutover("target_not_found", "gsap-animation"));
|
|
250
264
|
// dispatchGsapOpAndPersist declines on before===after — that catches stale
|
|
251
265
|
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
|
|
252
266
|
// back to the server path. This subsumes explicit existence guards for set/remove.
|
|
@@ -285,9 +299,10 @@ async function dispatchGsapOpAndPersist(
|
|
|
285
299
|
}
|
|
286
300
|
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
|
|
287
301
|
// flag OFF → explicit decline → caller falls back to the legacy server path.
|
|
288
|
-
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
|
|
289
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
290
|
-
if (wrongCompositionFile(deps, targetPath))
|
|
302
|
+
if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled", family);
|
|
303
|
+
if (!sdkSession) return declinedCutover("session_unavailable", family);
|
|
304
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
305
|
+
return declinedCutover("wrong_composition_file", family);
|
|
291
306
|
const session = sdkSession;
|
|
292
307
|
// persistSdkCandidateMutation owns the shared per-project/file transaction
|
|
293
308
|
// coordinator used by both SDK and legacy GSAP writes.
|
|
@@ -559,10 +574,16 @@ export async function sdkDeletePersist(
|
|
|
559
574
|
{ targetPath, compositionPath: deps.compositionPath },
|
|
560
575
|
);
|
|
561
576
|
// Dark-launch gate: flag OFF → legacy server delete path.
|
|
562
|
-
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
|
|
563
|
-
if (!sdkSession) return declinedCutover("session_unavailable");
|
|
564
|
-
if (!sdkSession.getElement(hfId))
|
|
565
|
-
|
|
577
|
+
if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled", "lifecycle");
|
|
578
|
+
if (!sdkSession) return declinedCutover("session_unavailable", "lifecycle");
|
|
579
|
+
if (!sdkSession.getElement(hfId))
|
|
580
|
+
return declinedCutover(
|
|
581
|
+
"target_not_found",
|
|
582
|
+
"lifecycle",
|
|
583
|
+
isResolverDisagreement(sdkSession, hfId),
|
|
584
|
+
);
|
|
585
|
+
if (wrongCompositionFile(deps, targetPath))
|
|
586
|
+
return declinedCutover("wrong_composition_file", "lifecycle");
|
|
566
587
|
const result = await persistSdkCandidateMutation(
|
|
567
588
|
sdkSession,
|
|
568
589
|
targetPath,
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
|
|
5
5
|
* block has no dependency on the persist/dispatch functions there.
|
|
6
6
|
*/
|
|
7
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
7
8
|
import type { PatchOperation } from "./sourcePatcher";
|
|
8
9
|
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
|
10
|
+
import { resolveSnapshot } from "./sdkResolverShadow";
|
|
9
11
|
|
|
10
12
|
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
|
11
13
|
"inline-style",
|
|
@@ -102,6 +104,25 @@ export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOpe
|
|
|
102
104
|
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* On a `target_not_found`, decide whether the miss is the known benign class
|
|
109
|
+
* (node genuinely absent from the session — nothing dispatch could have done)
|
|
110
|
+
* or a resolver disagreement: the shadow's broader resolver finds it, so
|
|
111
|
+
* dispatch could have made the edit and `getElement` refused it. The shadow
|
|
112
|
+
* event stays silent in that case, so this flag is the only signal for it.
|
|
113
|
+
*
|
|
114
|
+
* Diagnostic context only — it must never turn a clean decline into a throw, so
|
|
115
|
+
* a session shape without `getElements` reports "no disagreement" and the edit
|
|
116
|
+
* falls back exactly as it otherwise would have.
|
|
117
|
+
*/
|
|
118
|
+
export function isResolverDisagreement(session: Composition, hfId: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
return resolveSnapshot(session, hfId) !== null;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
export function shouldUseSdkCutover(
|
|
106
127
|
flagEnabled: boolean,
|
|
107
128
|
hasSession: boolean,
|
|
@@ -7,8 +7,17 @@ import {
|
|
|
7
7
|
} from "./sdkCutoverPolicy";
|
|
8
8
|
|
|
9
9
|
describe("Studio SDK operation-family policy", () => {
|
|
10
|
-
it("enables
|
|
11
|
-
|
|
10
|
+
it("enables every family when the master switch is on and no family list is set", () => {
|
|
11
|
+
const all = new Set(STUDIO_SDK_OPERATION_FAMILIES);
|
|
12
|
+
expect(resolveEnabledSdkFamilies({}, true)).toEqual(all);
|
|
13
|
+
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "" }, true)).toEqual(all);
|
|
14
|
+
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: " " }, true)).toEqual(
|
|
15
|
+
all,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("enables no family when the master switch is off, regardless of the family list", () => {
|
|
20
|
+
expect(resolveEnabledSdkFamilies({}, false).size).toBe(0);
|
|
12
21
|
expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe(
|
|
13
22
|
0,
|
|
14
23
|
);
|
|
@@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies(
|
|
|
54
54
|
): ReadonlySet<StudioSdkOperationFamily> {
|
|
55
55
|
if (!masterEnabled) return new Set();
|
|
56
56
|
const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"];
|
|
57
|
-
|
|
57
|
+
// Unset/blank = every family. The list exists to RESTRICT (kill switch per family).
|
|
58
|
+
if (typeof raw !== "string" || raw.trim() === "") return new Set(STUDIO_SDK_OPERATION_FAMILIES);
|
|
58
59
|
const requested = raw
|
|
59
60
|
.split(",")
|
|
60
61
|
.map((family) => family.trim())
|
|
@@ -85,8 +86,9 @@ export function renderStudioSdkCutoverReport(): string {
|
|
|
85
86
|
return [
|
|
86
87
|
"# Studio SDK cutover report",
|
|
87
88
|
"",
|
|
88
|
-
"
|
|
89
|
-
"
|
|
89
|
+
"Default: SDK persist ON for every family. Kill switches:",
|
|
90
|
+
" `VITE_STUDIO_SDK_CUTOVER_ENABLED=false` (all families → server path)",
|
|
91
|
+
" `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` (only listed families → SDK; rest → server)",
|
|
90
92
|
"",
|
|
91
93
|
"A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.",
|
|
92
94
|
"",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
6
|
+
import { declinedCutover } from "./sdkEditTransaction";
|
|
7
|
+
|
|
8
|
+
describe("declinedCutover telemetry", () => {
|
|
9
|
+
it("emits sdk_cutover_declined with reason and family, and returns the declined result", () => {
|
|
10
|
+
const result = declinedCutover("target_not_found", "timing");
|
|
11
|
+
expect(result).toEqual({ status: "declined", reason: "target_not_found" });
|
|
12
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
|
|
13
|
+
reason: "target_not_found",
|
|
14
|
+
family: "timing",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("emits family:null when the decline happens before a family is known", () => {
|
|
19
|
+
declinedCutover("no_change");
|
|
20
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
|
|
21
|
+
reason: "no_change",
|
|
22
|
+
family: null,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("omits resolverDisagreement when it is not set, and carries it when it is", () => {
|
|
27
|
+
declinedCutover("target_not_found", "timing");
|
|
28
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
29
|
+
reason: "target_not_found",
|
|
30
|
+
family: "timing",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
declinedCutover("target_not_found", "timing", true);
|
|
34
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
35
|
+
reason: "target_not_found",
|
|
36
|
+
family: "timing",
|
|
37
|
+
resolverDisagreement: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
declinedCutover("target_not_found", "timing", false);
|
|
41
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
|
|
42
|
+
reason: "target_not_found",
|
|
43
|
+
family: "timing",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { EditHistoryKind } from "./editHistory";
|
|
|
3
3
|
import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
|
4
4
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
5
5
|
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
6
|
+
import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy";
|
|
6
7
|
|
|
7
8
|
export type CutoverResult =
|
|
8
9
|
| { status: "declined"; reason: string }
|
|
@@ -66,7 +67,27 @@ interface CandidateEdit {
|
|
|
66
67
|
after: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Explicit fall-back to the legacy server path. Emits `sdk_cutover_declined` so
|
|
72
|
+
* post-flip we can distinguish "SDK took the edit" from "SDK bowed out"; payload
|
|
73
|
+
* is reason + family only (no hfId / path / content).
|
|
74
|
+
*
|
|
75
|
+
* `resolverDisagreement` is the one extra field with diagnostic value beyond the
|
|
76
|
+
* reason itself: on a `target_not_found`, the shadow's `resolveSnapshot` (what
|
|
77
|
+
* dispatch resolves) found the element that `getElement` could not. The shadow
|
|
78
|
+
* event stays silent in that case, so without this flag the case is invisible —
|
|
79
|
+
* and it is an adoption loss (a dispatchable edit refused), not a missing node.
|
|
80
|
+
*/
|
|
81
|
+
export function declinedCutover(
|
|
82
|
+
reason: string,
|
|
83
|
+
family?: StudioSdkOperationFamily,
|
|
84
|
+
resolverDisagreement?: boolean,
|
|
85
|
+
): CutoverResult {
|
|
86
|
+
trackStudioEvent("sdk_cutover_declined", {
|
|
87
|
+
reason,
|
|
88
|
+
family: family ?? null,
|
|
89
|
+
...(resolverDisagreement ? { resolverDisagreement: true } : {}),
|
|
90
|
+
});
|
|
70
91
|
return { status: "declined", reason };
|
|
71
92
|
}
|
|
72
93
|
|
|
@@ -96,7 +96,16 @@ function countHfIdInSource(source: string, id: string): number {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Resolvability the SDK DISPATCH path actually has (mirrors `resolveScoped`):
|
|
101
|
+
* exact scoped-path match, then canonical bare match, then first bare match.
|
|
102
|
+
*
|
|
103
|
+
* Exported so the cutover path can distinguish its two `target_not_found` cases:
|
|
104
|
+
* `Composition.getElement` is narrower (canonical-only for a bare id) than what
|
|
105
|
+
* `dispatch` resolves, so an element can be genuinely dispatchable yet still
|
|
106
|
+
* fail `getElement` — a resolver disagreement worth fixing, not a missing node.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
|
100
109
|
const els = session.getElements();
|
|
101
110
|
const exact = els.find((el) => el.scopedId === id);
|
|
102
111
|
if (exact) return exact;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
findMatchingTimelineElementId,
|
|
6
6
|
findTimelineIdByAncestor,
|
|
7
7
|
resolveDroppedAssetDimensions,
|
|
8
|
+
resolveElementTrack,
|
|
8
9
|
resolveTimelineIdForSelection,
|
|
9
10
|
resolveTimelineSelectionSeekTime,
|
|
10
11
|
} from "./studioHelpers";
|
|
@@ -42,6 +43,13 @@ describe("findMatchingTimelineElementId", () => {
|
|
|
42
43
|
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("matches by hfId when the selection has no domId, so an element with no authored id can still be found", () => {
|
|
47
|
+
const els = [el({ id: "hf-1", domId: undefined, hfId: "hf-1", sourceFile: "index.html" })];
|
|
48
|
+
expect(
|
|
49
|
+
findMatchingTimelineElementId({ id: undefined, hfId: "hf-1", sourceFile: "index.html" }, els),
|
|
50
|
+
).toBe("hf-1");
|
|
51
|
+
});
|
|
52
|
+
|
|
45
53
|
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
|
46
54
|
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
|
47
55
|
expect(
|
|
@@ -199,3 +207,13 @@ describe("resolveDroppedAssetDimensions", () => {
|
|
|
199
207
|
expect(load).toHaveBeenCalledOnce();
|
|
200
208
|
});
|
|
201
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("resolveElementTrack", () => {
|
|
212
|
+
it("rounds an authored track", () => {
|
|
213
|
+
expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("falls back to the resolved track, rounded, when nothing was authored", () => {
|
|
217
|
+
expect(resolveElementTrack({ authoredTrack: undefined, track: 3.6 })).toBe(4);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -126,9 +126,15 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
|
126
126
|
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
type ElementMatchSelection = Pick<
|
|
129
|
+
export type ElementMatchSelection = Pick<
|
|
130
130
|
DomEditSelection,
|
|
131
|
-
|
|
131
|
+
| "id"
|
|
132
|
+
| "hfId"
|
|
133
|
+
| "selector"
|
|
134
|
+
| "selectorIndex"
|
|
135
|
+
| "sourceFile"
|
|
136
|
+
| "compositionSrc"
|
|
137
|
+
| "isCompositionHost"
|
|
132
138
|
>;
|
|
133
139
|
|
|
134
140
|
function matchesByDomId(
|
|
@@ -142,6 +148,17 @@ function matchesByDomId(
|
|
|
142
148
|
);
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
function matchesByHfId(
|
|
152
|
+
selection: ElementMatchSelection,
|
|
153
|
+
element: TimelineElement,
|
|
154
|
+
selectionSourceFile: string,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (!selection.hfId) return false;
|
|
157
|
+
return (
|
|
158
|
+
element.hfId === selection.hfId && (element.sourceFile || "index.html") === selectionSourceFile
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
function matchesByCompositionHost(
|
|
146
163
|
selection: ElementMatchSelection,
|
|
147
164
|
element: TimelineElement,
|
|
@@ -172,14 +189,22 @@ export function findMatchingTimelineElementId(
|
|
|
172
189
|
// scan let `.find()` stop at an EARLIER, unrelated host that merely shares
|
|
173
190
|
// the compositionSrc, before the scan ever reached the correct id/selector
|
|
174
191
|
// match further down the list — collapsing every repeated host to the
|
|
175
|
-
// first one. Try id, then selector, across the WHOLE list
|
|
176
|
-
// back to the coarser compositionSrc-only match when
|
|
177
|
-
// specific element.
|
|
192
|
+
// first one. Try id, then hfId, then selector, across the WHOLE list
|
|
193
|
+
// first; only fall back to the coarser compositionSrc-only match when
|
|
194
|
+
// none of them identifies a specific element.
|
|
178
195
|
const byId = selection.id
|
|
179
196
|
? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile))
|
|
180
197
|
: undefined;
|
|
181
198
|
if (byId) return byId.key ?? byId.id;
|
|
182
199
|
|
|
200
|
+
// hfId is the stable content-hash id every element gets regardless of
|
|
201
|
+
// whether it has a real DOM id — the only correlator for an element like
|
|
202
|
+
// an ungroup child that has neither an authored id nor a selector.
|
|
203
|
+
const byHfId = selection.hfId
|
|
204
|
+
? elements.find((el) => matchesByHfId(selection, el, selectionSourceFile))
|
|
205
|
+
: undefined;
|
|
206
|
+
if (byHfId) return byHfId.key ?? byHfId.id;
|
|
207
|
+
|
|
183
208
|
const bySelector = selection.selector
|
|
184
209
|
? elements.find((el) => matchesBySelector(selection, el))
|
|
185
210
|
: undefined;
|
|
@@ -198,6 +223,17 @@ export function findMatchingTimelineElementId(
|
|
|
198
223
|
return null;
|
|
199
224
|
}
|
|
200
225
|
|
|
226
|
+
// The element's track: authored if given, else the runtime's already-resolved
|
|
227
|
+
// fallback — always rounded to an integer index either way. Shared by the
|
|
228
|
+
// group/ungroup and razor-split flows so they can't drift out of sync again.
|
|
229
|
+
export function resolveElementTrack(
|
|
230
|
+
element: Pick<TimelineElement, "authoredTrack" | "track">,
|
|
231
|
+
): number {
|
|
232
|
+
return Math.round(
|
|
233
|
+
Number.isFinite(element.authoredTrack) ? (element.authoredTrack as number) : element.track,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/**
|
|
202
238
|
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
|
|
203
239
|
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
|
|
@@ -73,6 +73,20 @@ describe("timelineSnapEnabled preference", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
describe("rippleEditEnabled preference", () => {
|
|
77
|
+
it("round-trips through storage", () => {
|
|
78
|
+
const storage = createStorage();
|
|
79
|
+
writeStudioUiPreferences({ rippleEditEnabled: false }, storage);
|
|
80
|
+
expect(readStudioUiPreferences(storage).rippleEditEnabled).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ignores non-boolean values", () => {
|
|
84
|
+
const storage = createStorage();
|
|
85
|
+
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ rippleEditEnabled: "yes" }));
|
|
86
|
+
expect(readStudioUiPreferences(storage).rippleEditEnabled).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
76
90
|
describe("thumbnailMode preference", () => {
|
|
77
91
|
it("round-trips the adaptive mode", () => {
|
|
78
92
|
const storage = createStorage();
|
|
@@ -24,6 +24,9 @@ export interface StudioUiPreferences {
|
|
|
24
24
|
snapToGrid?: boolean;
|
|
25
25
|
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
|
|
26
26
|
timelineSnapEnabled?: boolean;
|
|
27
|
+
/** Keeps the main track gapless: deleting a clip closes the gap. Distinct
|
|
28
|
+
* from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
|
|
29
|
+
rippleEditEnabled?: boolean;
|
|
27
30
|
/** Transport + ruler readout mode: timecode or frame number. */
|
|
28
31
|
timeDisplayMode?: TimelineTimeDisplayMode;
|
|
29
32
|
/**
|
|
@@ -136,6 +139,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
136
139
|
if (typeof parsed.timelineSnapEnabled === "boolean") {
|
|
137
140
|
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
|
|
138
141
|
}
|
|
142
|
+
if (typeof parsed.rippleEditEnabled === "boolean") {
|
|
143
|
+
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
|
|
144
|
+
}
|
|
139
145
|
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
|
|
140
146
|
preferences.timeDisplayMode = parsed.timeDisplayMode;
|
|
141
147
|
}
|
|
@@ -31,16 +31,24 @@ export interface StudioUrlState {
|
|
|
31
31
|
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* The composition a schema-level panel (Variables / Slideshow)
|
|
35
|
-
* master view, where there is no explicit `activeCompPath`.
|
|
36
|
-
* `index.html` convention, but fall back to the first
|
|
34
|
+
* The composition auto-open and a schema-level panel (Variables / Slideshow)
|
|
35
|
+
* target on the master view, where there is no explicit `activeCompPath`.
|
|
36
|
+
* Prefer the `index.html` convention, but fall back to the first entry
|
|
37
37
|
* (composition-browser order) so projects whose entry file is `card.html`,
|
|
38
38
|
* `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
|
|
39
|
-
*
|
|
39
|
+
* Takes the server's filtered composition list, never the raw file tree —
|
|
40
|
+
* a non-composition `.html` (a vendored preset, one with no
|
|
41
|
+
* `data-composition-id`) must never become the target. Returns null when the
|
|
42
|
+
* project carries no composition file at all.
|
|
40
43
|
*/
|
|
41
|
-
export function resolveMasterCompositionPath(
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
44
|
+
export function resolveMasterCompositionPath(compositions: string[]): string | null {
|
|
45
|
+
if (compositions.includes("index.html")) return "index.html";
|
|
46
|
+
return compositions.find((p) => p.endsWith(".html")) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A URL with no `activeCompPath` needs no hydration step — there's nothing to apply. */
|
|
50
|
+
export function isHydratedFromUrlState(urlState: StudioUrlState): boolean {
|
|
51
|
+
return urlState.activeCompPath == null;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export function normalizeStudioUrlPanelTab(tab: RightPanelTab | null): RightPanelTab | null {
|
|
@@ -34,6 +34,16 @@ export function isAudioTimelineElement(
|
|
|
34
34
|
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The two tags the property panel lets you put a volume automation lane on.
|
|
38
|
+
* Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
|
|
39
|
+
* `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
|
|
40
|
+
export function isAudioOrVideoTimelineElement(
|
|
41
|
+
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!element) return false;
|
|
44
|
+
return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/** True for the music track: an audio element with data-timeline-role="music",
|
|
38
48
|
* or — when no role is set — an id matching the music regex. Voiceover/other
|
|
39
49
|
* audio (explicit non-music role) is excluded. */
|