11import type { Composition , GsapTweenSpec } from "@hyperframes/sdk" ;
22import type { DomEditSelection } from "../components/editor/domEditing" ;
33import type { PatchOperation } from "./sourcePatcher" ;
4- import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability" ;
4+ import * as studioAvailability from "../components/editor/manualEditingAvailability" ;
55import { trackStudioEvent } from "./studioTelemetry" ;
66import { patchOpsToSdkEditOps } from "./sdkOpMapping" ;
77import { recordResolverParity , recordAnimationResolverParity } from "./sdkResolverShadow" ;
@@ -15,6 +15,7 @@ import {
1515 type CutoverOptions ,
1616 type CutoverResult ,
1717} from "./sdkEditTransaction" ;
18+ import { isSdkFamilyEnabled , type StudioSdkOperationFamily } from "./sdkCutoverPolicy" ;
1819
1920export { shouldUseSdkCutover } from "./sdkCutoverEligibility" ;
2021export {
@@ -24,6 +25,27 @@ export {
2425} from "./sdkEditTransaction" ;
2526export type { CutoverDeps , CutoverOptions , CutoverResult } from "./sdkEditTransaction" ;
2627
28+ function sdkFamilyEnabled ( family : StudioSdkOperationFamily ) : boolean {
29+ const configured = Object . prototype . hasOwnProperty . call (
30+ studioAvailability ,
31+ "STUDIO_SDK_CUTOVER_FAMILIES" ,
32+ )
33+ ? studioAvailability . STUDIO_SDK_CUTOVER_FAMILIES
34+ : undefined ;
35+ return isSdkFamilyEnabled ( studioAvailability . STUDIO_SDK_CUTOVER_ENABLED , configured , family ) ;
36+ }
37+
38+ function trackCutoverResult (
39+ result : CutoverResult ,
40+ context : { hfId ?: string | null ; opCount : number } ,
41+ ) : void {
42+ if ( result . status === "committed" ) {
43+ trackStudioEvent ( "sdk_cutover_success" , context ) ;
44+ } else if ( result . status === "failed" ) {
45+ trackStudioEvent ( "sdk_cutover_failed" , { ...context , error : result . error . message } ) ;
46+ }
47+ }
48+
2749/** True when targetPath isn't the composition the SDK session models. */
2850function wrongCompositionFile ( deps : CutoverDeps , targetPath : string ) : boolean {
2951 return deps . compositionPath != null && targetPath !== deps . compositionPath ;
@@ -38,7 +60,7 @@ export async function sdkCutoverPersist(
3860 deps : CutoverDeps ,
3961 options ?: CutoverOptions ,
4062) : Promise < CutoverResult > {
41- if ( ! shouldUseSdkCutover ( STUDIO_SDK_CUTOVER_ENABLED , ! ! sdkSession , selection . hfId , ops ) )
63+ if ( ! shouldUseSdkCutover ( sdkFamilyEnabled ( "dom" ) , ! ! sdkSession , selection . hfId , ops ) )
4264 return declinedCutover ( "ineligible_operation" ) ;
4365 if ( ! sdkSession ) return declinedCutover ( "session_unavailable" ) ;
4466 const hfId = selection . hfId ;
@@ -58,11 +80,7 @@ export async function sdkCutoverPersist(
5880 } ,
5981 options ,
6082 ) ;
61- if ( result . status === "committed" ) {
62- trackStudioEvent ( "sdk_cutover_success" , { hfId, opCount : ops . length } ) ;
63- } else if ( result . status === "failed" ) {
64- trackStudioEvent ( "sdk_cutover_failed" , { hfId, error : result . error . message } ) ;
65- }
83+ trackCutoverResult ( result , { hfId, opCount : ops . length } ) ;
6684 return result ;
6785}
6886
@@ -86,7 +104,7 @@ export async function sdkTimingPersist(
86104 // Dark-launch gate: without this, timing cutover runs whenever an SDK session
87105 // exists (it always does, for shadow/selection) — flipping the flag OFF would
88106 // NOT disable it. Gate here so flag-off routes back to the legacy server path.
89- if ( ! STUDIO_SDK_CUTOVER_ENABLED ) return declinedCutover ( "feature_disabled" ) ;
107+ if ( ! sdkFamilyEnabled ( "timing" ) ) return declinedCutover ( "feature_disabled" ) ;
90108 if ( ! sdkSession ) return declinedCutover ( "session_unavailable" ) ;
91109 if ( ! sdkSession . getElement ( hfId ) ) return declinedCutover ( "target_not_found" ) ;
92110 if ( wrongCompositionFile ( deps , targetPath ) ) return declinedCutover ( "wrong_composition_file" ) ;
@@ -102,11 +120,7 @@ export async function sdkTimingPersist(
102120 options ,
103121 serializedBefore ,
104122 ) ;
105- if ( result . status === "committed" ) {
106- trackStudioEvent ( "sdk_cutover_success" , { hfId, opCount : 1 } ) ;
107- } else if ( result . status === "failed" ) {
108- trackStudioEvent ( "sdk_cutover_failed" , { hfId, error : result . error . message } ) ;
109- }
123+ trackCutoverResult ( result , { hfId, opCount : 1 } ) ;
110124 return result ;
111125 } catch ( error ) {
112126 const failed = { status : "failed" , error : asCutoverError ( error ) } as const ;
@@ -134,7 +148,7 @@ export async function sdkTimingBatchPersist(
134148 timingSrc ? ( ) => timingSrc ( targetPath ) : undefined ,
135149 ) ;
136150 }
137- if ( ! STUDIO_SDK_CUTOVER_ENABLED ) return declinedCutover ( "feature_disabled" ) ;
151+ if ( ! sdkFamilyEnabled ( "timing" ) ) return declinedCutover ( "feature_disabled" ) ;
138152 if ( ! sdkSession ) return declinedCutover ( "session_unavailable" ) ;
139153 if ( wrongCompositionFile ( deps , targetPath ) ) return declinedCutover ( "wrong_composition_file" ) ;
140154 if ( changes . some ( ( change ) => ! sdkSession . getElement ( change . hfId ) ) )
@@ -209,13 +223,14 @@ export function sdkGsapTweenPersist(
209223 }
210224 // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
211225 // matches the other three chokepoints' discipline.
212- if ( ! STUDIO_SDK_CUTOVER_ENABLED ) return Promise . resolve ( declinedCutover ( "feature_disabled" ) ) ;
226+ if ( ! sdkFamilyEnabled ( "gsap-animation" ) )
227+ return Promise . resolve ( declinedCutover ( "feature_disabled" ) ) ;
213228 if ( op . kind === "add" && sdkSession && ! sdkSession . getElement ( op . target ) )
214229 return Promise . resolve ( declinedCutover ( "target_not_found" ) ) ;
215230 // dispatchGsapOpAndPersist declines on before===after — that catches stale
216231 // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
217232 // back to the server path. This subsumes explicit existence guards for set/remove.
218- return dispatchGsapOpAndPersist ( targetPath , sdkSession , deps , options , ( s ) => {
233+ return dispatchGsapOpAndPersist ( "gsap-animation" , targetPath , sdkSession , deps , options , ( s ) => {
219234 s . batch ( ( ) => {
220235 if ( op . kind === "add" ) {
221236 s . addGsapTween ( op . target , op . spec ) ;
@@ -229,6 +244,7 @@ export function sdkGsapTweenPersist(
229244}
230245
231246async function dispatchGsapOpAndPersist (
247+ family : "gsap-animation" | "gsap-keyframe" ,
232248 targetPath : string ,
233249 sdkSession : Composition | null | undefined ,
234250 deps : CutoverDeps ,
@@ -243,7 +259,7 @@ async function dispatchGsapOpAndPersist(
243259 }
244260 // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
245261 // flag OFF → explicit decline → caller falls back to the legacy server path.
246- if ( ! STUDIO_SDK_CUTOVER_ENABLED ) return declinedCutover ( "feature_disabled" ) ;
262+ if ( ! sdkFamilyEnabled ( family ) ) return declinedCutover ( "feature_disabled" ) ;
247263 if ( ! sdkSession ) return declinedCutover ( "session_unavailable" ) ;
248264 if ( wrongCompositionFile ( deps , targetPath ) ) return declinedCutover ( "wrong_composition_file" ) ;
249265 const session = sdkSession ;
@@ -291,6 +307,7 @@ export function sdkGsapKeyframePersist(
291307 options ?: CutoverOptions ,
292308) : Promise < CutoverResult > {
293309 return dispatchGsapOpAndPersist (
310+ "gsap-keyframe" ,
294311 targetPath ,
295312 sdkSession ,
296313 deps ,
@@ -309,6 +326,7 @@ export function sdkGsapRemoveKeyframePersist(
309326 options ?: CutoverOptions ,
310327) : Promise < CutoverResult > {
311328 return dispatchGsapOpAndPersist (
329+ "gsap-keyframe" ,
312330 targetPath ,
313331 sdkSession ,
314332 deps ,
@@ -328,6 +346,7 @@ export function sdkGsapRemovePropertyPersist(
328346 options ?: CutoverOptions ,
329347) : Promise < CutoverResult > {
330348 return dispatchGsapOpAndPersist (
349+ "gsap-animation" ,
331350 targetPath ,
332351 sdkSession ,
333352 deps ,
@@ -344,7 +363,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
344363 deps : CutoverDeps ,
345364 options ?: CutoverOptions ,
346365) : Promise < CutoverResult > {
347- return dispatchGsapOpAndPersist ( targetPath , sdkSession , deps , options , ( s ) =>
366+ return dispatchGsapOpAndPersist ( "gsap-animation" , targetPath , sdkSession , deps , options , ( s ) =>
348367 s . dispatch ( { type : "deleteAllForSelector" , selector } ) ,
349368 ) ;
350369}
@@ -357,6 +376,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
357376 options ?: CutoverOptions ,
358377) : Promise < CutoverResult > {
359378 return dispatchGsapOpAndPersist (
379+ "gsap-keyframe" ,
360380 targetPath ,
361381 sdkSession ,
362382 deps ,
@@ -375,6 +395,7 @@ export function sdkGsapConvertToKeyframesPersist(
375395 options ?: CutoverOptions ,
376396) : Promise < CutoverResult > {
377397 return dispatchGsapOpAndPersist (
398+ "gsap-keyframe" ,
378399 targetPath ,
379400 sdkSession ,
380401 deps ,
@@ -399,6 +420,16 @@ type KeyframesPayload = {
399420 ease ?: string ;
400421} ;
401422
423+ function keyframesPayload (
424+ targetSelector : string ,
425+ position : number ,
426+ duration : number ,
427+ keyframes : KeyframeSpec [ ] ,
428+ ease : string | undefined ,
429+ ) : KeyframesPayload {
430+ return { targetSelector, position, duration, keyframes, ...( ease ? { ease } : { } ) } ;
431+ }
432+
402433/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
403434function dispatchWithKeyframes (
404435 s : Composition ,
@@ -412,6 +443,38 @@ function dispatchWithKeyframes(
412443 }
413444}
414445
446+ function persistKeyframesOperation ( input : {
447+ targetPath : string ;
448+ targetSelector : string ;
449+ position : number ;
450+ duration : number ;
451+ keyframes : KeyframeSpec [ ] ;
452+ ease : string | undefined ;
453+ sdkSession : Composition | null | undefined ;
454+ deps : CutoverDeps ;
455+ options ?: CutoverOptions ;
456+ animationId ?: string ;
457+ } ) : Promise < CutoverResult > {
458+ const payload = keyframesPayload (
459+ input . targetSelector ,
460+ input . position ,
461+ input . duration ,
462+ input . keyframes ,
463+ input . ease ,
464+ ) ;
465+ return dispatchGsapOpAndPersist (
466+ "gsap-keyframe" ,
467+ input . targetPath ,
468+ input . sdkSession ,
469+ input . deps ,
470+ input . options ,
471+ ( session ) => dispatchWithKeyframes ( session , payload , input . animationId ) ,
472+ input . animationId
473+ ? { animationId : input . animationId , opLabel : "replaceWithKeyframes" }
474+ : undefined ,
475+ ) ;
476+ }
477+
415478export function sdkAddWithKeyframesPersist (
416479 targetPath : string ,
417480 targetSelector : string ,
@@ -423,16 +486,17 @@ export function sdkAddWithKeyframesPersist(
423486 deps : CutoverDeps ,
424487 options ?: CutoverOptions ,
425488) : Promise < CutoverResult > {
426- const payload : KeyframesPayload = {
489+ return persistKeyframesOperation ( {
490+ targetPath,
427491 targetSelector,
428492 position,
429493 duration,
430494 keyframes,
431- ... ( ease ? { ease } : { } ) ,
432- } ;
433- return dispatchGsapOpAndPersist ( targetPath , sdkSession , deps , options , ( s ) =>
434- dispatchWithKeyframes ( s , payload ) ,
435- ) ;
495+ ease,
496+ sdkSession ,
497+ deps,
498+ options ,
499+ } ) ;
436500}
437501
438502export function sdkReplaceWithKeyframesPersist (
@@ -447,21 +511,18 @@ export function sdkReplaceWithKeyframesPersist(
447511 deps : CutoverDeps ,
448512 options ?: CutoverOptions ,
449513) : Promise < CutoverResult > {
450- const payload : KeyframesPayload = {
514+ return persistKeyframesOperation ( {
515+ targetPath,
516+ animationId,
451517 targetSelector,
452518 position,
453519 duration,
454520 keyframes,
455- ...( ease ? { ease } : { } ) ,
456- } ;
457- return dispatchGsapOpAndPersist (
458- targetPath ,
521+ ease,
459522 sdkSession,
460523 deps,
461524 options,
462- ( s ) => dispatchWithKeyframes ( s , payload , animationId ) ,
463- { animationId, opLabel : "replaceWithKeyframes" } ,
464- ) ;
525+ } ) ;
465526}
466527
467528export async function sdkDeletePersist (
@@ -476,7 +537,7 @@ export async function sdkDeletePersist(
476537 Promise . resolve ( originalContent ) ,
477538 ) ;
478539 // Dark-launch gate: flag OFF → legacy server delete path.
479- if ( ! STUDIO_SDK_CUTOVER_ENABLED ) return declinedCutover ( "feature_disabled" ) ;
540+ if ( ! sdkFamilyEnabled ( "lifecycle" ) ) return declinedCutover ( "feature_disabled" ) ;
480541 if ( ! sdkSession ) return declinedCutover ( "session_unavailable" ) ;
481542 if ( ! sdkSession . getElement ( hfId ) ) return declinedCutover ( "target_not_found" ) ;
482543 if ( wrongCompositionFile ( deps , targetPath ) ) return declinedCutover ( "wrong_composition_file" ) ;
@@ -488,10 +549,6 @@ export async function sdkDeletePersist(
488549 ( session ) => session . removeElement ( hfId ) ,
489550 { label : "Delete element" } ,
490551 ) ;
491- if ( result . status === "committed" ) {
492- trackStudioEvent ( "sdk_cutover_success" , { hfId, opCount : 1 } ) ;
493- } else if ( result . status === "failed" ) {
494- trackStudioEvent ( "sdk_cutover_failed" , { hfId, error : result . error . message } ) ;
495- }
552+ trackCutoverResult ( result , { hfId, opCount : 1 } ) ;
496553 return result ;
497554}
0 commit comments