@hyperframes/studio 0.8.114 → 0.8.116
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-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
- package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
- package/dist/index.d.ts +146 -40
- package/dist/index.html +2 -2
- package/dist/index.js +4021 -3591
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +41 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +5 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +4 -125
- package/src/hooks/gsapDragPositionCommit.ts +36 -384
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +96 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -168
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +13 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +8 -6
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
- package/src/hooks/useDomEditTextCommits.ts +13 -3
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +8 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +34 -3
- package/src/hooks/useEnableKeyframes.ts +7 -4
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +101 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
- package/src/hooks/useGsapScriptCommits.ts +15 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +22 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/sceneSwap.ts +2 -1
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +51 -5
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +4 -3
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommitMutationCall,
|
|
3
|
+
CommitMutation,
|
|
4
|
+
CommitMutationOptions,
|
|
5
|
+
MutationResult,
|
|
6
|
+
} from "./gsapScriptCommitTypes";
|
|
7
|
+
import {
|
|
8
|
+
keyframeUsageActions,
|
|
9
|
+
changedMutationIndices,
|
|
10
|
+
primaryKeyframeAction,
|
|
11
|
+
trackKeyframeUsage,
|
|
12
|
+
type KeyframeUsageAction,
|
|
13
|
+
} from "../utils/keyframeUsage";
|
|
14
|
+
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
|
|
15
|
+
|
|
16
|
+
export function observeGsapGesture(writer: CommitMutation | null) {
|
|
17
|
+
let changed = false;
|
|
18
|
+
let pendingResults = 0;
|
|
19
|
+
const actions = new Set<KeyframeUsageAction>();
|
|
20
|
+
const observe = (calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
|
21
|
+
pendingResults += 1;
|
|
22
|
+
return {
|
|
23
|
+
...options,
|
|
24
|
+
keyframeTelemetry: false,
|
|
25
|
+
onResult: (result: MutationResult) => {
|
|
26
|
+
pendingResults -= 1;
|
|
27
|
+
options.onResult?.(result);
|
|
28
|
+
if (!result.ok || result.changed !== true) return;
|
|
29
|
+
changed = true;
|
|
30
|
+
const members = changedMutationIndices(
|
|
31
|
+
result,
|
|
32
|
+
calls.length,
|
|
33
|
+
calls.map((call) => call.options),
|
|
34
|
+
).map((index) => calls[index]!);
|
|
35
|
+
for (const action of keyframeUsageActions(
|
|
36
|
+
members.map((call) => call.mutation),
|
|
37
|
+
members.map((call) => call.options.keyframeAction),
|
|
38
|
+
))
|
|
39
|
+
actions.add(action);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
let commit: CommitMutation | null = null;
|
|
44
|
+
if (writer) {
|
|
45
|
+
commit = (selection, mutation, options) =>
|
|
46
|
+
writer(selection, mutation, observe([{ selection, mutation, options }], options));
|
|
47
|
+
if (writer.batch) {
|
|
48
|
+
const batch = writer.batch;
|
|
49
|
+
commit.batch = (calls, options) => batch(calls, observe(calls, options));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
commit,
|
|
54
|
+
recordDomResult: (result: { changed: boolean } | undefined) => {
|
|
55
|
+
changed ||= result?.changed === true;
|
|
56
|
+
},
|
|
57
|
+
finish: (domChanged = false): GeometryCommitResult => {
|
|
58
|
+
if (pendingResults !== 0) return { ok: true, changed: false };
|
|
59
|
+
const action = primaryKeyframeAction(actions);
|
|
60
|
+
if (action) trackKeyframeUsage(action);
|
|
61
|
+
return { ok: true, changed: changed || domChanged };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
4
|
+
import { gsapWriteFile } from "./gsapScriptCommitTypes";
|
|
5
|
+
|
|
6
|
+
/** The first member a group's preflight refused, else one from a second file. */
|
|
7
|
+
export function firstPreflightFailure(
|
|
8
|
+
results: PromiseSettledResult<void>[],
|
|
9
|
+
updates: DomEditGroupPathOffsetCommit[],
|
|
10
|
+
savedOnElement: Map<DomEditSelection, boolean>,
|
|
11
|
+
activeCompPath: string | null | undefined,
|
|
12
|
+
): { error: unknown; selection: DomEditSelection } | null {
|
|
13
|
+
for (const [index, result] of results.entries()) {
|
|
14
|
+
if (result.status !== "rejected") continue;
|
|
15
|
+
const selection = updates[index]?.selection;
|
|
16
|
+
if (selection) return { error: result.reason, selection };
|
|
17
|
+
}
|
|
18
|
+
return secondFile(updates, savedOnElement, activeCompPath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The group's script writes go out as one batch to one file, so members from two files refuse. */
|
|
22
|
+
function secondFile(
|
|
23
|
+
updates: DomEditGroupPathOffsetCommit[],
|
|
24
|
+
savedOnElement: Map<DomEditSelection, boolean>,
|
|
25
|
+
activeCompPath: string | null | undefined,
|
|
26
|
+
): { error: unknown; selection: DomEditSelection } | null {
|
|
27
|
+
const scripted = updates.filter(({ selection }) => !savedOnElement.has(selection));
|
|
28
|
+
const fileOf = (s: DomEditSelection) => gsapWriteFile(s, activeCompPath);
|
|
29
|
+
const file = scripted[0] && fileOf(scripted[0].selection);
|
|
30
|
+
const other = scripted.find(({ selection }) => fileOf(selection) !== file);
|
|
31
|
+
return other
|
|
32
|
+
? { error: new GsapEditBlockedError("mixed-files"), selection: other.selection }
|
|
33
|
+
: null;
|
|
34
|
+
}
|
|
@@ -4,23 +4,14 @@ import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/glob
|
|
|
4
4
|
import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
5
5
|
import { roundToCenti } from "../utils/rounding";
|
|
6
6
|
|
|
7
|
-
type
|
|
8
|
-
selection: DomEditSelection,
|
|
9
|
-
mutation: Record<string, unknown>,
|
|
10
|
-
options: {
|
|
11
|
-
label: string;
|
|
12
|
-
coalesceKey?: string;
|
|
13
|
-
softReload?: boolean;
|
|
14
|
-
skipReload?: boolean;
|
|
15
|
-
},
|
|
16
|
-
) => Promise<void>;
|
|
7
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
17
8
|
|
|
18
9
|
export async function commitKeyframeAtTimeImpl(
|
|
19
10
|
selection: DomEditSelection,
|
|
20
11
|
absoluteTime: number,
|
|
21
12
|
animations: GsapAnimation[],
|
|
22
13
|
properties: Record<string, number | string>,
|
|
23
|
-
commitMutation:
|
|
14
|
+
commitMutation: CommitMutation,
|
|
24
15
|
): Promise<void> {
|
|
25
16
|
// Matching an authored tween is a string compare against what the author
|
|
26
17
|
// wrote, so it keeps using the selection's own selector; the NEW tween below
|
|
@@ -38,7 +29,7 @@ export async function commitKeyframeAtTimeImpl(
|
|
|
38
29
|
await commitMutation(
|
|
39
30
|
selection,
|
|
40
31
|
{ type: "convert-to-keyframes", animationId: tween.id },
|
|
41
|
-
{ label: "Convert to keyframes", skipReload: true },
|
|
32
|
+
{ label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
|
|
42
33
|
);
|
|
43
34
|
}
|
|
44
35
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
2
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
2
3
|
|
|
3
4
|
export class GsapPreviewConvergenceError extends Error {}
|
|
4
5
|
export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
|
5
6
|
|
|
6
7
|
/** Verify rollback ownership support before any GSAP mutation can land. */
|
|
7
8
|
export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
|
8
|
-
const response = await
|
|
9
|
+
const response = await studioApiFetch(
|
|
9
10
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
|
10
11
|
);
|
|
11
12
|
if (!response.ok) {
|
|
@@ -29,7 +30,7 @@ export async function rollbackOwnedMutation(
|
|
|
29
30
|
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
30
31
|
throw new Error(`Unsafe path: ${targetPath}`);
|
|
31
32
|
}
|
|
32
|
-
const response = await
|
|
33
|
+
const response = await studioApiFetch(
|
|
33
34
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
|
34
35
|
{
|
|
35
36
|
method: "POST",
|
|
@@ -95,7 +96,7 @@ export async function postGsapMutation(
|
|
|
95
96
|
): Promise<GsapMutationStatus> {
|
|
96
97
|
let response: Response;
|
|
97
98
|
try {
|
|
98
|
-
response = await
|
|
99
|
+
response = await studioApiFetch(
|
|
99
100
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
100
101
|
{
|
|
101
102
|
method: "POST",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
|
+
|
|
6
|
+
/** A property's `[start, end]` as GSAP parsed it from the file when the tween first rendered. */
|
|
7
|
+
type Ends = Record<string, [number, number]>;
|
|
8
|
+
|
|
9
|
+
/** A GSAP 3 tween as the preview runtime holds it, with the property tweens the parse readers walk. */
|
|
10
|
+
export function liveTween(
|
|
11
|
+
target: Element,
|
|
12
|
+
tween: { start: number; duration: number; vars: Record<string, unknown>; ends?: Ends },
|
|
13
|
+
{ from = false, parts }: { from?: boolean; parts?: unknown[] } = {},
|
|
14
|
+
) {
|
|
15
|
+
let head: Record<string, unknown> | undefined;
|
|
16
|
+
for (const [p, [s, e]] of Object.entries(tween.ends ?? {}).reverse())
|
|
17
|
+
head = from ? { p, s: e, c: s - e, _next: head } : { p, s, c: e - s, _next: head };
|
|
18
|
+
return {
|
|
19
|
+
targets: () => [target],
|
|
20
|
+
startTime: () => tween.start,
|
|
21
|
+
duration: () => tween.duration,
|
|
22
|
+
vars: tween.vars,
|
|
23
|
+
_from: from,
|
|
24
|
+
...(head && { _pt: { d: { _pt: head } } }),
|
|
25
|
+
...(parts && { timeline: { getChildren: () => parts } }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A preview iframe whose one timeline holds `tweens`, answering `getProperty` from `live`. */
|
|
30
|
+
export function previewWith(
|
|
31
|
+
element: Element,
|
|
32
|
+
tweens: unknown[],
|
|
33
|
+
live: Record<string, number> = {},
|
|
34
|
+
): HTMLIFrameElement {
|
|
35
|
+
return {
|
|
36
|
+
contentWindow: {
|
|
37
|
+
__timelines: { main: { getChildren: () => tweens, duration: () => 10 } },
|
|
38
|
+
gsap: {
|
|
39
|
+
getProperty: (_el: Element, prop: string) => live[prop] ?? 0,
|
|
40
|
+
defaults: () => ({ ease: "power1.out" }),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
contentDocument: element.ownerDocument,
|
|
44
|
+
} as unknown as HTMLIFrameElement;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
|
|
48
|
+
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
|
|
49
|
+
|
|
50
|
+
/** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
|
|
51
|
+
export function elTween(
|
|
52
|
+
id: string,
|
|
53
|
+
properties: Record<string, number>,
|
|
54
|
+
duration: number,
|
|
55
|
+
ease?: string,
|
|
56
|
+
): GsapAnimation {
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
targetSelector: "#el",
|
|
60
|
+
propertyGroup: classifyTweenPropertyGroup(properties),
|
|
61
|
+
method: "to",
|
|
62
|
+
properties,
|
|
63
|
+
position: 0,
|
|
64
|
+
resolvedStart: 0,
|
|
65
|
+
duration,
|
|
66
|
+
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : ease && { ease }),
|
|
67
|
+
} as unknown as GsapAnimation;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-test cleanup for the intercept sweeps: mocks, the playhead store and the document. */
|
|
71
|
+
export function resetGsapEditState(): void {
|
|
72
|
+
vi.restoreAllMocks();
|
|
73
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
74
|
+
document.body.innerHTML = "";
|
|
75
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { findParsedTween, parsedImplicitEndValue } from "./gsapParsedTween";
|
|
5
|
+
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
|
|
6
|
+
|
|
7
|
+
type Parsed = Parameters<typeof parsedImplicitEndValue>[0];
|
|
8
|
+
const el = document.createElement("div");
|
|
9
|
+
const part = (start: number, ends?: Record<string, [number, number]>) =>
|
|
10
|
+
liveTween(el, { start, duration: 1, vars: { x: 0 }, ends });
|
|
11
|
+
const keyframed = (parts: unknown[]) =>
|
|
12
|
+
liveTween(el, { start: 0, duration: 2, vars: { keyframes: [] } }, { parts }) as Parsed;
|
|
13
|
+
|
|
14
|
+
describe("parsedImplicitEndValue", () => {
|
|
15
|
+
it("reads a flat tween's own start and end", () => {
|
|
16
|
+
const value = parsedImplicitEndValue(part(0, { x: [-60, 0] }) as Parsed);
|
|
17
|
+
expect([value("x", "start"), value("x", "end")]).toEqual([-60, 0]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reads a keyframed tween's start from its first part and its end from its last", () => {
|
|
21
|
+
const value = parsedImplicitEndValue(
|
|
22
|
+
keyframed([part(0, { x: [0, 50] }), part(1, { x: [50, 120] })]),
|
|
23
|
+
);
|
|
24
|
+
expect([value("x", "start"), value("x", "end")]).toEqual([0, 120]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("refuses when the part nearest that end is not initialised, rather than read an earlier one", () => {
|
|
28
|
+
const value = parsedImplicitEndValue(keyframed([part(0, { x: [0, 50] }), part(1)]));
|
|
29
|
+
expect(value("x", "end")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("has no value without a parsed tween", () => {
|
|
33
|
+
expect(parsedImplicitEndValue(null)("x", "start")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("findParsedTween", () => {
|
|
38
|
+
afterEach(() => document.body.replaceChildren());
|
|
39
|
+
|
|
40
|
+
it("takes the flat tween an edit names, not a keyframed tween at the same start", () => {
|
|
41
|
+
const fade = liveTween(el, { start: 0, duration: 2, vars: { keyframes: {}, ease: "none" } });
|
|
42
|
+
const slide = liveTween(el, { start: 0, duration: 2, vars: { x: 300 } });
|
|
43
|
+
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 0, duration: 2 });
|
|
44
|
+
expect(findParsedTween(previewWith(el, [fade, slide]), el, edit)).toBe(slide);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parses a to() tween the playhead has not reached, from its authored start", () => {
|
|
48
|
+
const box = document.body.appendChild(document.createElement("div"));
|
|
49
|
+
box.id = "box";
|
|
50
|
+
const timeline = gsap.timeline({ paused: true }).to(box, { x: 300, duration: 1 }, 2);
|
|
51
|
+
timeline.seek(0.5);
|
|
52
|
+
gsap.set(box, { x: 40 });
|
|
53
|
+
const iframe = { contentWindow: { __timelines: { main: timeline }, gsap } };
|
|
54
|
+
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 2, duration: 1 });
|
|
55
|
+
|
|
56
|
+
const parsed = findParsedTween(iframe as unknown as HTMLIFrameElement, box, edit);
|
|
57
|
+
|
|
58
|
+
expect(parsedImplicitEndValue(parsed)("x", "start")).toBe(0);
|
|
59
|
+
expect([gsap.getProperty(box, "x"), timeline.time()]).toEqual([40, 0.5]);
|
|
60
|
+
timeline.kill();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { elementTargets } from "../utils/elementGsap";
|
|
3
|
+
import { resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
4
|
+
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
|
|
5
|
+
|
|
6
|
+
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
|
|
7
|
+
interface PropTween {
|
|
8
|
+
p?: string;
|
|
9
|
+
s?: number;
|
|
10
|
+
c?: number;
|
|
11
|
+
d?: { _pt?: PropTween };
|
|
12
|
+
_next?: PropTween;
|
|
13
|
+
}
|
|
14
|
+
interface ParsedTween {
|
|
15
|
+
_pt?: PropTween;
|
|
16
|
+
_from?: boolean;
|
|
17
|
+
_initted?: boolean;
|
|
18
|
+
vars?: Record<string, unknown>;
|
|
19
|
+
parent?: {
|
|
20
|
+
vars?: { defaults?: { ease?: unknown } };
|
|
21
|
+
time?: () => number;
|
|
22
|
+
seek?: (time: number, suppressEvents?: boolean) => unknown;
|
|
23
|
+
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
|
|
24
|
+
};
|
|
25
|
+
timeline?: { getChildren?: () => ParsedTween[] };
|
|
26
|
+
targets?: () => unknown[];
|
|
27
|
+
startTime?: () => number;
|
|
28
|
+
duration?: () => number;
|
|
29
|
+
}
|
|
30
|
+
interface GsapWindow {
|
|
31
|
+
gsap?: {
|
|
32
|
+
defaults?: () => { ease?: unknown };
|
|
33
|
+
getProperty?: (target: Element, prop: string) => unknown;
|
|
34
|
+
set?: (target: Element, vars: Record<string, unknown>) => void;
|
|
35
|
+
parseEase?: (name: string) => unknown;
|
|
36
|
+
};
|
|
37
|
+
__timelines?: Record<string, { getChildren?: (nested: boolean) => ParsedTween[] }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `scale` parses into the two longhands, which always share a start and end for a `scale` tween.
|
|
41
|
+
const PARSED_NAME: Record<string, string> = { scale: "scaleX", rotate: "rotation" };
|
|
42
|
+
|
|
43
|
+
function findPropTween(pt: PropTween | undefined, prop: string): PropTween | null {
|
|
44
|
+
for (let node = pt; node; node = node._next) {
|
|
45
|
+
if (node.p === prop && typeof node.s === "number" && typeof node.c === "number") return node;
|
|
46
|
+
const nested = findPropTween(node.d?._pt, prop);
|
|
47
|
+
if (nested) return nested;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `[start, end]` of `prop` in one initialised tween; a from() tween runs its pair backwards. */
|
|
53
|
+
function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
|
|
54
|
+
const pt = findPropTween(tween._pt, PARSED_NAME[prop] ?? prop);
|
|
55
|
+
if (!pt) return null;
|
|
56
|
+
const pair: [number, number] = [pt.s!, pt.s! + pt.c!];
|
|
57
|
+
return tween._from ? [pair[1], pair[0]] : pair;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
|
|
61
|
+
// fallow-ignore-next-line complexity
|
|
62
|
+
export function findParsedTween(
|
|
63
|
+
iframe: HTMLIFrameElement | null,
|
|
64
|
+
element: Element,
|
|
65
|
+
anim: GsapAnimation,
|
|
66
|
+
): ParsedTween | null {
|
|
67
|
+
const win = iframe?.contentWindow as GsapWindow | null;
|
|
68
|
+
const start = resolveTweenStart(anim);
|
|
69
|
+
if (!win?.__timelines || start == null) return null;
|
|
70
|
+
const steps = anim.keyframes?.keyframes.map((kf) => kf.properties) ?? [anim.properties];
|
|
71
|
+
const props = [...new Set(steps.flatMap((step) => Object.keys(step)))];
|
|
72
|
+
const keyframed = Boolean(anim.keyframes);
|
|
73
|
+
for (const timeline of Object.values(win.__timelines)) {
|
|
74
|
+
for (const tween of timeline?.getChildren?.(true) ?? []) {
|
|
75
|
+
const targets = (tween.targets?.() ?? []) as Array<{ id?: unknown }>;
|
|
76
|
+
if (!targets.includes(element) && !targets.some((t) => element.id && t.id === element.id))
|
|
77
|
+
continue;
|
|
78
|
+
if (!startsAt(tween, start)) continue;
|
|
79
|
+
const vars = tween.vars ?? {};
|
|
80
|
+
const carries = keyframed ? "keyframes" in vars : props.some((p) => p in vars);
|
|
81
|
+
if (!carries || !((tween.duration?.() ?? 0) > 0)) continue;
|
|
82
|
+
parseUnplayed(win, element, tween, props);
|
|
83
|
+
return tween;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** GSAP's startTime() counts the tween's `delay`; the file's parse does not. */
|
|
90
|
+
function startsAt(tween: ParsedTween, start: number): boolean {
|
|
91
|
+
const delay = Number(tween.vars?.delay) || 0;
|
|
92
|
+
return Math.abs((tween.startTime?.() ?? Number.NaN) - delay - start) <= 1e-3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `anim` timed as GSAP runs it: its start with any delay, a duration it leaves to GSAP filled in. */
|
|
96
|
+
export function withLiveTiming(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
97
|
+
const start = tween?.startTime?.();
|
|
98
|
+
return {
|
|
99
|
+
...anim,
|
|
100
|
+
...(Number.isFinite(start) && { resolvedStart: start }),
|
|
101
|
+
...(anim.duration == null && tween && { duration: tween.duration?.() }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** GSAP parses a to() tween only when the playhead first passes it, and a soft reload resets that.
|
|
106
|
+
* Play its timeline from the tween's start (so earlier tweens set its start value) to its end and
|
|
107
|
+
* back, its channels cleared first; then restore every styled element's attributes and GSAP cache. */
|
|
108
|
+
function parseUnplayed(win: GsapWindow, element: Element, tween: ParsedTween, props: string[]) {
|
|
109
|
+
const seek = !isParsed(tween) && seekable(win, tween);
|
|
110
|
+
if (!seek) return;
|
|
111
|
+
const { parent, gsap } = seek;
|
|
112
|
+
const before = layerStates(tween, element);
|
|
113
|
+
const now = parent.time();
|
|
114
|
+
const start = tween.startTime?.() ?? 0;
|
|
115
|
+
gsap.set(element, { clearProps: props.join(",") });
|
|
116
|
+
// Re-read now, so GSAP rebuilds its transform cache from the cleared element before the seek.
|
|
117
|
+
for (const p of props) gsap.getProperty(element, p);
|
|
118
|
+
try {
|
|
119
|
+
parent.seek(start, true);
|
|
120
|
+
parent.seek(start + (tween.duration?.() ?? 0), true);
|
|
121
|
+
} finally {
|
|
122
|
+
parent.seek(now, true);
|
|
123
|
+
restore(before);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isParsed(tween: ParsedTween): boolean {
|
|
128
|
+
const parts = tween.timeline?.getChildren?.() ?? [];
|
|
129
|
+
return Boolean(tween._initted) && parts.every((part) => part._initted);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type GsapTools = {
|
|
133
|
+
getProperty: (el: Element, p: string) => unknown;
|
|
134
|
+
set: (el: Element, v: Record<string, unknown>) => void;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function seekable(win: GsapWindow, tween: ParsedTween) {
|
|
138
|
+
const { parent } = tween;
|
|
139
|
+
const gsap = win.gsap;
|
|
140
|
+
if (!parent?.seek || !parent.time || !gsap?.getProperty || !gsap.set) return null;
|
|
141
|
+
return {
|
|
142
|
+
parent: { seek: parent.seek.bind(parent), time: parent.time.bind(parent) },
|
|
143
|
+
gsap: { getProperty: gsap.getProperty.bind(gsap), set: gsap.set.bind(gsap) } as GsapTools,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type Layer = Element & { _gsap?: Record<string, unknown> };
|
|
148
|
+
|
|
149
|
+
function layerStates(tween: ParsedTween, element: Element) {
|
|
150
|
+
const layers = new Set<Layer>([element]);
|
|
151
|
+
for (const child of tween.parent?.getChildren?.(true, true, false) ?? [])
|
|
152
|
+
for (const target of elementTargets(child)) layers.add(target);
|
|
153
|
+
return [...layers].map((layer) => ({
|
|
154
|
+
layer,
|
|
155
|
+
attributes: new Map([...layer.attributes].map((a) => [a.name, a.value])),
|
|
156
|
+
cache: layer._gsap && { ...layer._gsap },
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restore(states: ReturnType<typeof layerStates>) {
|
|
161
|
+
for (const { layer, attributes, cache } of states) {
|
|
162
|
+
restoreAttributes(layer, attributes);
|
|
163
|
+
if (cache && layer._gsap) restoreCache(layer._gsap, cache);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function restoreAttributes(layer: Element, attributes: Map<string, string>) {
|
|
168
|
+
for (const { name } of [...layer.attributes])
|
|
169
|
+
if (!attributes.has(name)) layer.removeAttribute(name);
|
|
170
|
+
for (const [name, value] of attributes)
|
|
171
|
+
if (layer.getAttribute(name) !== value) layer.setAttribute(name, value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function restoreCache(live: Record<string, unknown>, cache: Record<string, unknown>) {
|
|
175
|
+
for (const key of Object.keys(live)) if (!(key in cache)) delete live[key];
|
|
176
|
+
Object.assign(live, cache);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Start and end values from GSAP's own parse of the tween, as loaded from the file. Null when
|
|
180
|
+
* GSAP has not initialised that part of the tween yet: the caller refuses rather than guess. */
|
|
181
|
+
export function parsedImplicitEndValue(tween: ParsedTween | null): ImplicitEndValue {
|
|
182
|
+
return (prop, end) => {
|
|
183
|
+
if (!tween) return null;
|
|
184
|
+
for (const part of partsFrom(tween, end)) {
|
|
185
|
+
const pair = endsIn(part, prop);
|
|
186
|
+
if (pair) return end === "start" ? pair[0] : pair[1];
|
|
187
|
+
// The nearest part animating `prop` is not initialised: an earlier one would be a wrong value.
|
|
188
|
+
if (prop in (part.vars ?? {})) return null;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A keyframed tween's parts, nearest `end` first; a flat tween is its own one part. */
|
|
195
|
+
function partsFrom(tween: ParsedTween, end: "start" | "end"): ParsedTween[] {
|
|
196
|
+
const parts = tween.timeline?.getChildren?.() ?? [];
|
|
197
|
+
if (parts.length === 0) return [tween];
|
|
198
|
+
return end === "start" ? parts : [...parts].reverse();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The ease GSAP resolved for a flat tween that authors none: its timeline's default, else GSAP's. */
|
|
202
|
+
export function parsedTweenEase(iframe: HTMLIFrameElement | null, tween: ParsedTween | null) {
|
|
203
|
+
if (!tween) return null;
|
|
204
|
+
const win = iframe?.contentWindow as GsapWindow | null;
|
|
205
|
+
const ease =
|
|
206
|
+
tween.vars?.ease ?? tween.parent?.vars?.defaults?.ease ?? win?.gsap?.defaults?.().ease;
|
|
207
|
+
if (typeof ease === "string") return ease;
|
|
208
|
+
const named = (name: string) => win?.gsap?.parseEase?.(name) === ease;
|
|
209
|
+
return typeof ease === "function" ? (BUILT_IN_EASES.find(named) ?? null) : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// GSAP holds a resolved ease, its own default included, as the function parseEase returns.
|
|
213
|
+
const BUILT_IN_EASES = [
|
|
214
|
+
"none",
|
|
215
|
+
...[
|
|
216
|
+
"power1",
|
|
217
|
+
"power2",
|
|
218
|
+
"power3",
|
|
219
|
+
"power4",
|
|
220
|
+
"sine",
|
|
221
|
+
"expo",
|
|
222
|
+
"circ",
|
|
223
|
+
"back",
|
|
224
|
+
"elastic",
|
|
225
|
+
"bounce",
|
|
226
|
+
].flatMap((e) => [`${e}.in`, `${e}.out`, `${e}.inOut`]),
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/** An array of keyframe steps at the exact percentages GSAP times them; the parse rounds them. */
|
|
230
|
+
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
231
|
+
const data = anim.keyframes;
|
|
232
|
+
const parts = tween?.timeline?.getChildren?.() ?? [];
|
|
233
|
+
const total = tween?.duration?.() ?? 0;
|
|
234
|
+
if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
|
|
235
|
+
return anim;
|
|
236
|
+
const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
|
|
237
|
+
if (ends.some((end) => !Number.isFinite(end))) return anim;
|
|
238
|
+
const keyframes = data.keyframes.map((kf, i) => ({
|
|
239
|
+
...kf,
|
|
240
|
+
percentage: Math.round(ends[i]! * 100000) / 1000,
|
|
241
|
+
}));
|
|
242
|
+
return { ...anim, keyframes: { ...data, keyframes } };
|
|
243
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { tween } from "./gsapParsedTween.test-helpers";
|
|
5
|
+
import { findGsapPositionAnimation, pickClosestToPlayhead } from "./gsapPositionDetection";
|
|
6
|
+
|
|
7
|
+
afterEach(() => usePlayerStore.setState({ currentTime: 0 }));
|
|
8
|
+
|
|
9
|
+
const slide = (
|
|
10
|
+
id: string,
|
|
11
|
+
resolvedStart: number,
|
|
12
|
+
duration: number,
|
|
13
|
+
fields: Partial<GsapAnimation> = {},
|
|
14
|
+
) => tween({ id, method: "to", properties: { x: 10 }, resolvedStart, duration, ...fields });
|
|
15
|
+
const keyed = (...percentages: number[]) => ({
|
|
16
|
+
keyframes: {
|
|
17
|
+
format: "object-array" as const,
|
|
18
|
+
keyframes: percentages.map((percentage) => ({ percentage, properties: { x: percentage } })),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("pickClosestToPlayhead", () => {
|
|
23
|
+
it.each([
|
|
24
|
+
["the one spanning the playhead", 1.5, [slide("a", 0, 1), slide("b", 1, 1)], "b"],
|
|
25
|
+
["the nearer end when none spans it", 5, [slide("a", 0, 1), slide("b", 2, 2)], "b"],
|
|
26
|
+
[
|
|
27
|
+
"the one whose file states the playhead's value",
|
|
28
|
+
1,
|
|
29
|
+
[slide("a", 0, 1), slide("b", 1, 1)],
|
|
30
|
+
"a",
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
"the later one when both state it",
|
|
34
|
+
1,
|
|
35
|
+
[slide("a", 0, 2, keyed(0, 50, 100)), slide("b", 0.5, 1, keyed(0, 50, 100))],
|
|
36
|
+
"b",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"the one with more keyframes on a tie",
|
|
40
|
+
5,
|
|
41
|
+
[slide("a", 0, 1, keyed(0, 100)), slide("b", 0, 1, keyed(0, 50, 100))],
|
|
42
|
+
"b",
|
|
43
|
+
],
|
|
44
|
+
])("picks %s", (_, time, anims, id) => {
|
|
45
|
+
usePlayerStore.setState({ currentTime: time });
|
|
46
|
+
expect(pickClosestToPlayhead(anims)?.id).toBe(id);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("findGsapPositionAnimation", () => {
|
|
51
|
+
it.each([
|
|
52
|
+
[
|
|
53
|
+
"the tween spanning the playhead",
|
|
54
|
+
1.5,
|
|
55
|
+
undefined,
|
|
56
|
+
[slide("a", 0, 1, keyed(0, 100)), slide("b", 1, 1)],
|
|
57
|
+
"b",
|
|
58
|
+
],
|
|
59
|
+
["the nearer tween", 9, undefined, [slide("a", 0, 1), slide("b", 6, 1)], "b"],
|
|
60
|
+
[
|
|
61
|
+
"the selected target's tween",
|
|
62
|
+
0.5,
|
|
63
|
+
"#box",
|
|
64
|
+
[slide("a", 0, 1, { targetSelector: "#card", ...keyed(0, 100) }), slide("b", 0, 1)],
|
|
65
|
+
"b",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"a tween on one target over a shared one",
|
|
69
|
+
0.5,
|
|
70
|
+
undefined,
|
|
71
|
+
[slide("a", 0, 1, { targetSelector: "#box, #card" }), slide("b", 0, 1)],
|
|
72
|
+
"b",
|
|
73
|
+
],
|
|
74
|
+
])("prefers %s", (_, time, selector, anims, id) => {
|
|
75
|
+
usePlayerStore.setState({ currentTime: time });
|
|
76
|
+
expect(findGsapPositionAnimation(anims, selector)?.id).toBe(id);
|
|
77
|
+
});
|
|
78
|
+
});
|