@hyperframes/studio 0.7.57 → 0.7.59
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--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +18029 -11563
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -6
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +55 -1
- package/src/hooks/timelineTrackVisibility.ts +31 -13
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +81 -162
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +182 -84
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +306 -155
- package/src/hooks/useTimelineEditing.ts +111 -101
- package/src/hooks/useTimelineEditingTypes.ts +4 -1
- package/src/hooks/useTimelineGroupEditing.ts +51 -5
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-DfmYkU44.js +0 -423
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// edit's undo history, and swapping the rewritten script into the live preview
|
|
4
4
|
// without a full iframe reload when possible.
|
|
5
5
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
6
|
-
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
6
|
+
import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
|
7
7
|
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
|
8
8
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
10
10
|
|
|
11
|
+
class GsapPreviewConvergenceError extends Error {}
|
|
12
|
+
class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
|
13
|
+
|
|
11
14
|
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
12
15
|
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
13
16
|
throw new Error(`Unsafe path: ${targetPath}`);
|
|
@@ -25,6 +28,49 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
|
|
25
28
|
return data.content;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** Verify rollback ownership support before any GSAP mutation can land. */
|
|
32
|
+
async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
|
33
|
+
const response = await fetch(
|
|
34
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
|
35
|
+
);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
|
|
38
|
+
}
|
|
39
|
+
const body = await response.json().catch(() => null);
|
|
40
|
+
if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
|
|
41
|
+
throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Atomically restore one GSAP mutation only while its exact output still owns
|
|
46
|
+
* the file. The server performs compare + write synchronously, eliminating the
|
|
47
|
+
* client GET→PUT window that could overwrite a successor edit. */
|
|
48
|
+
async function rollbackOwnedMutation(
|
|
49
|
+
projectId: string,
|
|
50
|
+
targetPath: string,
|
|
51
|
+
expected: string,
|
|
52
|
+
restore: string,
|
|
53
|
+
): Promise<"restored" | "conflict"> {
|
|
54
|
+
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
55
|
+
throw new Error(`Unsafe path: ${targetPath}`);
|
|
56
|
+
}
|
|
57
|
+
const response = await fetch(
|
|
58
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ expected, restore }),
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`Failed to restore ${targetPath}`);
|
|
67
|
+
}
|
|
68
|
+
const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
|
|
69
|
+
if (result.restored === true && result.conflict === false) return "restored";
|
|
70
|
+
if (result.restored === false && result.conflict === true) return "conflict";
|
|
71
|
+
throw new Error(`Invalid restore response for ${targetPath}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
28
74
|
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
|
|
29
75
|
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
|
|
30
76
|
try {
|
|
@@ -34,19 +80,7 @@ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: n
|
|
|
34
80
|
}
|
|
35
81
|
}
|
|
36
82
|
|
|
37
|
-
/**
|
|
38
|
-
* Optimistically push the composition's content-driven length into the player
|
|
39
|
-
* store right after the live DOM patch, so the duration readout + seek bar
|
|
40
|
-
* update immediately. The readout binds to store.duration (PlayerControls);
|
|
41
|
-
* edits only patched store.elements, so the number stayed frozen (esp. on
|
|
42
|
-
* shrink) until a manual refresh. Read from the just-patched preview DOM (raw
|
|
43
|
-
* data-duration) so it's immune to the runtime's truncated live durations.
|
|
44
|
-
*
|
|
45
|
-
* Also writes the content end into the live root's `data-duration`. Timing
|
|
46
|
-
* edits take the soft-reload path (no full iframe reload), which lets the
|
|
47
|
-
* runtime recompute the length from the root's declared duration and post it
|
|
48
|
-
* back — reading the STALE root would revert this optimistic set.
|
|
49
|
-
*/
|
|
83
|
+
/** Keep the duration readout and live root aligned with optimistically patched clips. */
|
|
50
84
|
export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
|
|
51
85
|
const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
|
|
52
86
|
if (end > 0) {
|
|
@@ -55,14 +89,7 @@ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): vo
|
|
|
55
89
|
}
|
|
56
90
|
}
|
|
57
91
|
|
|
58
|
-
/**
|
|
59
|
-
* Snapshot the store duration BEFORE an optimistic duration update
|
|
60
|
-
* (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
|
|
61
|
-
* rollback closure for the persist-failure path. The rollback restores BOTH
|
|
62
|
-
* the store duration and the live root's `data-duration` — otherwise a failed
|
|
63
|
-
* write leaves the readout/seek bar and the live root advertising a duration
|
|
64
|
-
* the saved source never got. No-op when the duration never changed.
|
|
65
|
-
*/
|
|
92
|
+
/** Restore both store and live-root duration when a timing persist fails. */
|
|
66
93
|
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
|
|
67
94
|
const previousDuration = usePlayerStore.getState().duration;
|
|
68
95
|
return () => {
|
|
@@ -77,19 +104,38 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
|
|
|
77
104
|
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
|
78
105
|
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
|
79
106
|
* when the endpoint didn't return one (older server, or a multi-script comp the
|
|
80
|
-
* soft path can't scope)
|
|
107
|
+
* soft path can't scope) — the caller then full-reloads when `mutated`, or
|
|
108
|
+
* rebinds the runtime timing in place when nothing was rewritten (see
|
|
109
|
+
* syncTimingEditPreview).
|
|
81
110
|
*/
|
|
82
|
-
export type GsapMutationStatus = {
|
|
111
|
+
export type GsapMutationStatus = {
|
|
112
|
+
mutated: boolean;
|
|
113
|
+
scriptText: string | null;
|
|
114
|
+
/** Atomic whole-file ownership pair returned by the mutation endpoint. */
|
|
115
|
+
before?: string;
|
|
116
|
+
after?: string;
|
|
117
|
+
};
|
|
83
118
|
|
|
84
119
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
120
|
return typeof value === "object" && value !== null;
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
89
|
-
if (
|
|
124
|
+
if (
|
|
125
|
+
!isRecord(value) ||
|
|
126
|
+
typeof value.mutated !== "boolean" ||
|
|
127
|
+
typeof value.before !== "string" ||
|
|
128
|
+
typeof value.after !== "string" ||
|
|
129
|
+
value.mutated !== (value.before !== value.after) ||
|
|
130
|
+
("changed" in value && value.changed !== value.mutated)
|
|
131
|
+
) {
|
|
132
|
+
throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
|
|
133
|
+
}
|
|
90
134
|
return {
|
|
91
|
-
mutated: value.mutated
|
|
135
|
+
mutated: value.mutated,
|
|
92
136
|
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
|
137
|
+
before: value.before,
|
|
138
|
+
after: value.after,
|
|
93
139
|
};
|
|
94
140
|
}
|
|
95
141
|
|
|
@@ -98,6 +144,40 @@ function readMutationError(value: unknown, fallback: string): string {
|
|
|
98
144
|
return fallback;
|
|
99
145
|
}
|
|
100
146
|
|
|
147
|
+
async function postGsapMutation(
|
|
148
|
+
projectId: string,
|
|
149
|
+
filePath: string,
|
|
150
|
+
mutation: Record<string, unknown>,
|
|
151
|
+
fallback: string,
|
|
152
|
+
): Promise<GsapMutationStatus> {
|
|
153
|
+
let response: Response;
|
|
154
|
+
try {
|
|
155
|
+
response = await fetch(
|
|
156
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
157
|
+
{
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify(mutation),
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
165
|
+
cause: error,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const body: unknown = await response.json().catch(() => null);
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
|
|
171
|
+
}
|
|
172
|
+
return readMutationStatus(body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Re-derive live timing windows without re-executing composition scripts.
|
|
176
|
+
* Works for zero-GSAP compositions; false asks the caller to full-reload. */
|
|
177
|
+
function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
|
|
178
|
+
return applySoftReloadFinalization(iframe, currentTime);
|
|
179
|
+
}
|
|
180
|
+
|
|
101
181
|
/**
|
|
102
182
|
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
|
|
103
183
|
* soft reload over the full iframe reload that flashes every clip.
|
|
@@ -115,15 +195,34 @@ function readMutationError(value: unknown, fallback: string): string {
|
|
|
115
195
|
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
|
|
116
196
|
* result (no gsap runtime / rebind hook / scopable key / script element, or the
|
|
117
197
|
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
|
|
118
|
-
* already applied the shift; a remount would re-flash for nothing.
|
|
119
|
-
*
|
|
198
|
+
* already applied the shift; a remount would re-flash for nothing.
|
|
199
|
+
*
|
|
200
|
+
* `mutated` is the canonical decision, regardless of whether the server echoes
|
|
201
|
+
* the unchanged script text:
|
|
202
|
+
* - `mutated: false` — nothing was rewritten because there was NOTHING TO
|
|
203
|
+
* REWRITE (the clip has no domId so no id-addressed tweens, the delta was
|
|
204
|
+
* zero, or the server confirmed a no-op). Every script is unchanged and the
|
|
205
|
+
* timing attributes are already live-patched, so (when `rebindWhenUnmutated`
|
|
206
|
+
* allows it) `rebindPreviewTiming` re-seeks + rebinds and the runtime
|
|
207
|
+
* re-derives the clip windows from the live DOM — no script re-execution,
|
|
208
|
+
* no full-reload blink. This covers comps with zero GSAP scripts too; only
|
|
209
|
+
* a missing iframe/runtime hook falls back to the full reload.
|
|
210
|
+
* - `mutated: true` with no script — the file on disk WAS rewritten but the
|
|
211
|
+
* server returned no script (older server, multi-script comp): the live
|
|
212
|
+
* script is now stale, so a rebind against it would show wrong positions →
|
|
213
|
+
* full-reload.
|
|
120
214
|
*/
|
|
121
215
|
function syncTimingEditPreview(
|
|
122
216
|
iframe: HTMLIFrameElement | null,
|
|
123
|
-
outcome:
|
|
217
|
+
outcome: GsapMutationStatus,
|
|
124
218
|
currentTime: number,
|
|
125
219
|
reloadPreview: () => void,
|
|
220
|
+
rebindWhenUnmutated: boolean,
|
|
126
221
|
): void {
|
|
222
|
+
if (!outcome.mutated && rebindWhenUnmutated) {
|
|
223
|
+
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
127
226
|
if (!iframe || !outcome.scriptText) {
|
|
128
227
|
reloadPreview();
|
|
129
228
|
return;
|
|
@@ -140,6 +239,14 @@ async function finishTimelineTimingFallback(input: {
|
|
|
140
239
|
reloadPreview: () => void;
|
|
141
240
|
gsapMutation?: () => Promise<GsapMutationStatus>;
|
|
142
241
|
onGsapError: (error: unknown) => void;
|
|
242
|
+
/**
|
|
243
|
+
* When the mutation produced no rewrite (mutated:false, no scriptText),
|
|
244
|
+
* rebind the runtime timing in place (no script re-execution) instead of
|
|
245
|
+
* full-reloading (see syncTimingEditPreview). Callers pass false when a full
|
|
246
|
+
* reload is the only sync that reflects everything (e.g. a multi-file group
|
|
247
|
+
* edit).
|
|
248
|
+
*/
|
|
249
|
+
rebindWhenUnmutated: boolean;
|
|
143
250
|
}): Promise<void> {
|
|
144
251
|
let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
|
|
145
252
|
if (input.gsapMutation) {
|
|
@@ -147,6 +254,9 @@ async function finishTimelineTimingFallback(input: {
|
|
|
147
254
|
outcome = await input.gsapMutation();
|
|
148
255
|
} catch (error) {
|
|
149
256
|
input.onGsapError(error);
|
|
257
|
+
// Protocol and ownership conflicts mean the live iframe may no longer
|
|
258
|
+
// represent the bytes that won on disk. Converge explicitly by reloading.
|
|
259
|
+
if (error instanceof GsapPreviewConvergenceError) input.reloadPreview();
|
|
150
260
|
return;
|
|
151
261
|
}
|
|
152
262
|
}
|
|
@@ -155,6 +265,7 @@ async function finishTimelineTimingFallback(input: {
|
|
|
155
265
|
outcome,
|
|
156
266
|
usePlayerStore.getState().currentTime,
|
|
157
267
|
input.reloadPreview,
|
|
268
|
+
input.rebindWhenUnmutated,
|
|
158
269
|
);
|
|
159
270
|
}
|
|
160
271
|
|
|
@@ -162,44 +273,124 @@ async function finishTimelineTimingFallback(input: {
|
|
|
162
273
|
// outlast one GSAP server round-trip, never a real second edit.
|
|
163
274
|
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
|
164
275
|
|
|
276
|
+
type OwnedMutationStep = {
|
|
277
|
+
path: string;
|
|
278
|
+
before: string;
|
|
279
|
+
after: string;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
type OwnedMutationRunner = (
|
|
283
|
+
path: string,
|
|
284
|
+
mutation: () => Promise<GsapMutationStatus> | null,
|
|
285
|
+
) => Promise<GsapMutationStatus>;
|
|
286
|
+
|
|
165
287
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
288
|
+
* Restore successful mutation steps in reverse order through the server's
|
|
289
|
+
* atomic compare-and-restore endpoint. A conflict means a successor owns the
|
|
290
|
+
* file and is deliberately preserved. Conflicts and restore errors require a
|
|
291
|
+
* preview reload; errors are also reported through onError.
|
|
292
|
+
*/
|
|
293
|
+
async function rollbackMutatedFiles(
|
|
294
|
+
projectId: string,
|
|
295
|
+
ownedSteps: readonly OwnedMutationStep[],
|
|
296
|
+
onError: (error: unknown) => void,
|
|
297
|
+
): Promise<"restored" | "convergence-required"> {
|
|
298
|
+
let convergenceRequired = false;
|
|
299
|
+
for (let index = ownedSteps.length - 1; index >= 0; index -= 1) {
|
|
300
|
+
const step = ownedSteps[index];
|
|
301
|
+
if (!step || step.before === step.after) continue;
|
|
302
|
+
try {
|
|
303
|
+
if (
|
|
304
|
+
(await rollbackOwnedMutation(projectId, step.path, step.after, step.before)) === "conflict"
|
|
305
|
+
) {
|
|
306
|
+
convergenceRequired = true;
|
|
307
|
+
}
|
|
308
|
+
} catch (rollbackError) {
|
|
309
|
+
convergenceRequired = true;
|
|
310
|
+
onError(rollbackError);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return convergenceRequired ? "convergence-required" : "restored";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function rollbackAfterFailure(
|
|
317
|
+
projectId: string,
|
|
318
|
+
ownedSteps: readonly OwnedMutationStep[],
|
|
319
|
+
onError: (error: unknown) => void,
|
|
320
|
+
originalError: unknown,
|
|
321
|
+
): Promise<never> {
|
|
322
|
+
const outcome = await rollbackMutatedFiles(projectId, ownedSteps, onError);
|
|
323
|
+
if (outcome === "convergence-required") {
|
|
324
|
+
throw new GsapPreviewConvergenceError(
|
|
325
|
+
"GSAP rollback could not safely restore every owned write; preview reload required",
|
|
326
|
+
{ cause: originalError },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
throw originalError;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Fold server-owned GSAP rewrites into the preceding timing history entry.
|
|
334
|
+
* Every mutation contributes the atomic before/after pair returned by its
|
|
335
|
+
* endpoint; the first before and last after for each contiguous file chain are
|
|
336
|
+
* the only bytes this transaction may record or roll back.
|
|
177
337
|
*/
|
|
338
|
+
// The ledger, reverse rollback, final ownership check, and history fold are one
|
|
339
|
+
// transaction; extracting phases would obscure which function owns convergence.
|
|
340
|
+
// fallow-ignore-next-line complexity
|
|
178
341
|
async function foldGsapMutationIntoHistory(input: {
|
|
179
342
|
projectId: string;
|
|
180
|
-
paths: string[];
|
|
181
343
|
label: string;
|
|
182
344
|
coalesceKey?: string;
|
|
183
345
|
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
184
|
-
gsapMutation: () => Promise<GsapMutationStatus>;
|
|
185
|
-
|
|
346
|
+
gsapMutation: (runOwnedMutation: OwnedMutationRunner) => Promise<GsapMutationStatus>;
|
|
347
|
+
onRollbackError: (error: unknown) => void;
|
|
186
348
|
}): Promise<GsapMutationStatus> {
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
349
|
+
const ownedSteps: OwnedMutationStep[] = [];
|
|
350
|
+
const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
|
|
351
|
+
const pending = mutation();
|
|
352
|
+
if (!pending) return { mutated: false, scriptText: null };
|
|
353
|
+
const status = await pending;
|
|
354
|
+
if (!status.mutated) return status;
|
|
355
|
+
if (status.before === undefined || status.after === undefined) {
|
|
356
|
+
throw new GsapOwnershipProtocolError(
|
|
357
|
+
`GSAP mutation returned no owned before/after pair for ${path}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const previous = [...ownedSteps].reverse().find((step) => step.path === path);
|
|
361
|
+
const step = { path, before: status.before, after: status.after };
|
|
362
|
+
ownedSteps.push(step);
|
|
363
|
+
// A foreign writer landed between two same-file mutation steps. The second
|
|
364
|
+
// step already wrote, so keep it in ownedSteps for reverse rollback, then
|
|
365
|
+
// fail rather than folding foreign bytes into this gesture's history.
|
|
366
|
+
if (previous && previous.after !== step.before) {
|
|
367
|
+
throw new Error(`GSAP mutation ownership chain broke for ${path}`);
|
|
368
|
+
}
|
|
369
|
+
return status;
|
|
370
|
+
};
|
|
371
|
+
let status: GsapMutationStatus;
|
|
372
|
+
try {
|
|
373
|
+
await requireGsapOwnershipProtocol(input.projectId);
|
|
374
|
+
status = await input.gsapMutation(runOwnedMutation);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
|
|
193
377
|
}
|
|
194
|
-
const status = await input.gsapMutation();
|
|
195
378
|
if (status.mutated) {
|
|
196
379
|
try {
|
|
380
|
+
const ownershipByPath = new Map<string, { before: string; after: string }>();
|
|
381
|
+
for (const step of ownedSteps) {
|
|
382
|
+
const owned = ownershipByPath.get(step.path);
|
|
383
|
+
if (owned) owned.after = step.after;
|
|
384
|
+
else ownershipByPath.set(step.path, { before: step.before, after: step.after });
|
|
385
|
+
}
|
|
197
386
|
const files: Record<string, { before: string; after: string }> = {};
|
|
198
|
-
for (const path of
|
|
199
|
-
const priorContent = before.get(path);
|
|
387
|
+
for (const [path, owned] of ownershipByPath) {
|
|
200
388
|
const finalContent = await readFileContent(input.projectId, path);
|
|
201
|
-
if (
|
|
202
|
-
|
|
389
|
+
if (finalContent !== owned.after) {
|
|
390
|
+
throw new Error(`GSAP mutation ownership lost for ${path}`);
|
|
391
|
+
}
|
|
392
|
+
if (owned.before !== owned.after) {
|
|
393
|
+
files[path] = owned;
|
|
203
394
|
}
|
|
204
395
|
}
|
|
205
396
|
if (Object.keys(files).length > 0) {
|
|
@@ -212,7 +403,7 @@ async function foldGsapMutationIntoHistory(input: {
|
|
|
212
403
|
});
|
|
213
404
|
}
|
|
214
405
|
} catch (error) {
|
|
215
|
-
input.
|
|
406
|
+
return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
|
|
216
407
|
}
|
|
217
408
|
}
|
|
218
409
|
return status;
|
|
@@ -230,23 +421,16 @@ export async function shiftGsapPositions(
|
|
|
230
421
|
delta: number,
|
|
231
422
|
): Promise<GsapMutationStatus> {
|
|
232
423
|
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
|
|
233
|
-
|
|
234
|
-
|
|
424
|
+
return postGsapMutation(
|
|
425
|
+
projectId,
|
|
426
|
+
filePath,
|
|
235
427
|
{
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
type: "shift-positions",
|
|
240
|
-
targetSelector: `#${elementId}`,
|
|
241
|
-
delta,
|
|
242
|
-
}),
|
|
428
|
+
type: "shift-positions",
|
|
429
|
+
targetSelector: `#${elementId}`,
|
|
430
|
+
delta,
|
|
243
431
|
},
|
|
432
|
+
"shift-positions failed",
|
|
244
433
|
);
|
|
245
|
-
if (!res.ok) {
|
|
246
|
-
const err = await res.json().catch(() => null);
|
|
247
|
-
throw new Error(readMutationError(err, "shift-positions failed"));
|
|
248
|
-
}
|
|
249
|
-
return readMutationStatus(await res.json().catch(() => null));
|
|
250
434
|
}
|
|
251
435
|
|
|
252
436
|
export async function scaleGsapPositions(
|
|
@@ -262,26 +446,19 @@ export async function scaleGsapPositions(
|
|
|
262
446
|
return { mutated: false, scriptText: null };
|
|
263
447
|
if (oldStart === newStart && oldDuration === newDuration)
|
|
264
448
|
return { mutated: false, scriptText: null };
|
|
265
|
-
|
|
266
|
-
|
|
449
|
+
return postGsapMutation(
|
|
450
|
+
projectId,
|
|
451
|
+
filePath,
|
|
267
452
|
{
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
oldDuration,
|
|
275
|
-
newStart,
|
|
276
|
-
newDuration,
|
|
277
|
-
}),
|
|
453
|
+
type: "scale-positions",
|
|
454
|
+
targetSelector: `#${elementId}`,
|
|
455
|
+
oldStart,
|
|
456
|
+
oldDuration,
|
|
457
|
+
newStart,
|
|
458
|
+
newDuration,
|
|
278
459
|
},
|
|
460
|
+
"scale-positions failed",
|
|
279
461
|
);
|
|
280
|
-
if (!res.ok) {
|
|
281
|
-
const err = await res.json().catch(() => null);
|
|
282
|
-
throw new Error(readMutationError(err, "scale-positions failed"));
|
|
283
|
-
}
|
|
284
|
-
return readMutationStatus(await res.json().catch(() => null));
|
|
285
462
|
}
|
|
286
463
|
|
|
287
464
|
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
|
@@ -297,8 +474,11 @@ export type SingleClipGsapEdit =
|
|
|
297
474
|
* Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
|
|
298
475
|
* server shift/scale mutation, folds the rewrite into the timing edit's history
|
|
299
476
|
* entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
|
|
300
|
-
* the rewritten script
|
|
301
|
-
*
|
|
477
|
+
* the rewritten script. When there was nothing to rewrite (no domId — e.g. a
|
|
478
|
+
* selector-addressed caption clip — zero delta, or a server no-op) it rebinds
|
|
479
|
+
* the runtime timing in place instead of full-reloading; full reload remains
|
|
480
|
+
* for genuine rewrites without a returned script and for comps the soft path
|
|
481
|
+
* can't handle (see syncTimingEditPreview).
|
|
302
482
|
*/
|
|
303
483
|
export function finishClipTimingFallback(input: {
|
|
304
484
|
iframe: HTMLIFrameElement | null;
|
|
@@ -338,15 +518,16 @@ export function finishClipTimingFallback(input: {
|
|
|
338
518
|
? () =>
|
|
339
519
|
foldGsapMutationIntoHistory({
|
|
340
520
|
projectId,
|
|
341
|
-
paths: [targetPath],
|
|
342
521
|
label: input.label,
|
|
343
522
|
coalesceKey: input.coalesceKey,
|
|
344
523
|
recordEdit: input.recordEdit,
|
|
345
|
-
gsapMutation: () =>
|
|
346
|
-
|
|
524
|
+
gsapMutation: (runOwnedMutation) =>
|
|
525
|
+
runOwnedMutation(targetPath, () => runMutation(projectId, domId)),
|
|
526
|
+
onRollbackError: onGsapError,
|
|
347
527
|
})
|
|
348
528
|
: undefined,
|
|
349
529
|
onGsapError,
|
|
530
|
+
rebindWhenUnmutated: true,
|
|
350
531
|
});
|
|
351
532
|
}
|
|
352
533
|
|
|
@@ -386,18 +567,17 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
386
567
|
gsapMutation: () =>
|
|
387
568
|
foldGsapMutationIntoHistory({
|
|
388
569
|
projectId: input.projectId,
|
|
389
|
-
paths: input.changes.map((change) => input.resolveChangePath(change.element)),
|
|
390
570
|
label: input.label,
|
|
391
571
|
coalesceKey: input.coalesceKey,
|
|
392
572
|
recordEdit: input.recordEdit,
|
|
393
|
-
gsapMutation: async () => {
|
|
573
|
+
gsapMutation: async (runOwnedMutation) => {
|
|
394
574
|
let mutated = false;
|
|
395
575
|
let scriptText: GsapMutationStatus["scriptText"] = null;
|
|
396
576
|
for (const change of input.changes) {
|
|
397
577
|
const changePath = input.resolveChangePath(change.element);
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
578
|
+
const status = await runOwnedMutation(changePath, () =>
|
|
579
|
+
input.mutateChange(change, changePath),
|
|
580
|
+
);
|
|
401
581
|
mutated = mutated || status.mutated;
|
|
402
582
|
// The LAST mutation against the active comp carries the cumulative
|
|
403
583
|
// rewritten script for that file.
|
|
@@ -405,8 +585,13 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
405
585
|
}
|
|
406
586
|
return { mutated, scriptText: otherFileChanged ? null : scriptText };
|
|
407
587
|
},
|
|
408
|
-
|
|
588
|
+
onRollbackError: onGsapError,
|
|
409
589
|
}),
|
|
410
590
|
onGsapError,
|
|
591
|
+
// A batch where nothing needed rewriting (every change was zero-delta or
|
|
592
|
+
// no-domId, e.g. closing a gap over caption clips) still needs the runtime
|
|
593
|
+
// to re-derive clip windows — the in-place timing rebind covers that. But
|
|
594
|
+
// when another file changed, only a full reload reflects every file.
|
|
595
|
+
rebindWhenUnmutated: !otherFileChanged,
|
|
411
596
|
});
|
|
412
597
|
}
|
|
@@ -44,7 +44,9 @@ describe("toggleTimelineTrackHidden", () => {
|
|
|
44
44
|
if (iframe.contentDocument) {
|
|
45
45
|
iframe.contentDocument.body.innerHTML = `
|
|
46
46
|
<div id="hero"></div>
|
|
47
|
-
<div id="
|
|
47
|
+
<div data-composition-id="scene" data-composition-file="scene.html">
|
|
48
|
+
<div id="subtitle"></div>
|
|
49
|
+
</div>
|
|
48
50
|
`;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -199,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
|
|
|
199
201
|
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
|
200
202
|
).toBeUndefined();
|
|
201
203
|
});
|
|
204
|
+
|
|
205
|
+
it("hides several elements in ONE atomic write when given an array of keys", async () => {
|
|
206
|
+
const files = new Map([
|
|
207
|
+
[
|
|
208
|
+
"index.html",
|
|
209
|
+
`<div id="hero" data-start="0" data-duration="2"></div>
|
|
210
|
+
<div id="caption" data-start="1" data-duration="2"></div>
|
|
211
|
+
<div id="badge" data-start="2" data-duration="2"></div>`,
|
|
212
|
+
],
|
|
213
|
+
]);
|
|
214
|
+
stubProjectFiles(files);
|
|
215
|
+
|
|
216
|
+
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
|
|
217
|
+
const caption = element({
|
|
218
|
+
id: "caption",
|
|
219
|
+
key: "index.html:#caption",
|
|
220
|
+
domId: "caption",
|
|
221
|
+
track: 1,
|
|
222
|
+
});
|
|
223
|
+
const badge = element({ id: "badge", key: "index.html:#badge", domId: "badge", track: 2 });
|
|
224
|
+
|
|
225
|
+
const writes: Array<{ path: string; content: string }> = [];
|
|
226
|
+
const recordEdit = vi.fn();
|
|
227
|
+
|
|
228
|
+
await toggleTimelineElementHidden({
|
|
229
|
+
projectId: "project-1",
|
|
230
|
+
activeCompPath: "index.html",
|
|
231
|
+
timelineElements: [hero, caption, badge],
|
|
232
|
+
elementKey: ["index.html:#hero", "index.html:#caption"],
|
|
233
|
+
hidden: true,
|
|
234
|
+
previewIframe: null,
|
|
235
|
+
writeProjectFile: async (path, content) => {
|
|
236
|
+
writes.push({ path, content });
|
|
237
|
+
},
|
|
238
|
+
recordEdit,
|
|
239
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
240
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// One write carrying BOTH hides — per-element writes would clobber each
|
|
244
|
+
// other (each starts from the original file content).
|
|
245
|
+
expect(writes).toHaveLength(1);
|
|
246
|
+
expect(writes[0]?.content).toContain(
|
|
247
|
+
'id="hero" data-start="0" data-duration="2" data-hidden=""',
|
|
248
|
+
);
|
|
249
|
+
expect(writes[0]?.content).toContain(
|
|
250
|
+
'id="caption" data-start="1" data-duration="2" data-hidden=""',
|
|
251
|
+
);
|
|
252
|
+
expect(writes[0]?.content).toContain('id="badge" data-start="2" data-duration="2"></div>');
|
|
253
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
|
|
255
|
+
});
|
|
202
256
|
});
|