@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- 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 +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- 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/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- 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 +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- 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 +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -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/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// Soft-reload-first preview sync for timeline timing edits: server GSAP
|
|
2
|
+
// position mutations (shift / scale), folding those rewrites into the timing
|
|
3
|
+
// edit's undo history, and swapping the rewritten script into the live preview
|
|
4
|
+
// without a full iframe reload when possible.
|
|
5
|
+
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
|
7
|
+
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
|
8
|
+
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
|
+
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
10
|
+
|
|
11
|
+
class GsapPreviewConvergenceError extends Error {}
|
|
12
|
+
class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
|
13
|
+
|
|
14
|
+
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
15
|
+
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
16
|
+
throw new Error(`Unsafe path: ${targetPath}`);
|
|
17
|
+
}
|
|
18
|
+
const response = await fetch(
|
|
19
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
|
|
20
|
+
);
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
23
|
+
}
|
|
24
|
+
const data = (await response.json()) as { content?: string };
|
|
25
|
+
if (typeof data.content !== "string") {
|
|
26
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
27
|
+
}
|
|
28
|
+
return data.content;
|
|
29
|
+
}
|
|
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
|
+
|
|
74
|
+
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
|
|
75
|
+
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
|
|
76
|
+
try {
|
|
77
|
+
patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
|
|
78
|
+
} catch {
|
|
79
|
+
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Keep the duration readout and live root aligned with optimistically patched clips. */
|
|
84
|
+
export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
|
|
85
|
+
const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
|
|
86
|
+
if (end > 0) {
|
|
87
|
+
usePlayerStore.getState().setDuration(end);
|
|
88
|
+
patchIframeRootDuration(iframe, end);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Restore both store and live-root duration when a timing persist fails. */
|
|
93
|
+
export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
|
|
94
|
+
const previousDuration = usePlayerStore.getState().duration;
|
|
95
|
+
return () => {
|
|
96
|
+
if (usePlayerStore.getState().duration === previousDuration) return;
|
|
97
|
+
usePlayerStore.getState().setDuration(previousDuration);
|
|
98
|
+
patchIframeRootDuration(iframe, previousDuration);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The bits of the server GSAP-mutation response the timeline edit path needs.
|
|
104
|
+
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
|
105
|
+
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
|
106
|
+
* when the endpoint didn't return one (older server, or a multi-script comp the
|
|
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).
|
|
110
|
+
*/
|
|
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
|
+
};
|
|
118
|
+
|
|
119
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
120
|
+
return typeof value === "object" && value !== null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
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
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
mutated: value.mutated,
|
|
136
|
+
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
|
137
|
+
before: value.before,
|
|
138
|
+
after: value.after,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readMutationError(value: unknown, fallback: string): string {
|
|
143
|
+
if (isRecord(value) && typeof value.error === "string") return value.error;
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
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
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
|
|
183
|
+
* soft reload over the full iframe reload that flashes every clip.
|
|
184
|
+
*
|
|
185
|
+
* Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
|
|
186
|
+
* already (a) patched the live DOM timing attributes, (b) updated the store's
|
|
187
|
+
* elements optimistically (the drag commit calls `updateElement` before the
|
|
188
|
+
* persist), and (c) had the server rewrite the GSAP tween positions — which is the
|
|
189
|
+
* `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
|
|
190
|
+
* document (no navigation), re-seeks to the current playhead, and rebinds the
|
|
191
|
+
* timeline, so the runtime matches the already-correct store. Nothing structural
|
|
192
|
+
* changed (no clip added/removed), so `processTimelineMessage` would re-derive the
|
|
193
|
+
* identical element set — skipping it just avoids the flash.
|
|
194
|
+
*
|
|
195
|
+
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
|
|
196
|
+
* result (no gsap runtime / rebind hook / scopable key / script element, or the
|
|
197
|
+
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
|
|
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.
|
|
214
|
+
*/
|
|
215
|
+
function syncTimingEditPreview(
|
|
216
|
+
iframe: HTMLIFrameElement | null,
|
|
217
|
+
outcome: GsapMutationStatus,
|
|
218
|
+
currentTime: number,
|
|
219
|
+
reloadPreview: () => void,
|
|
220
|
+
rebindWhenUnmutated: boolean,
|
|
221
|
+
): void {
|
|
222
|
+
if (!outcome.mutated && rebindWhenUnmutated) {
|
|
223
|
+
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!iframe || !outcome.scriptText) {
|
|
227
|
+
reloadPreview();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const result = applySoftReload(iframe, outcome.scriptText, {
|
|
231
|
+
onAsyncFailure: reloadPreview,
|
|
232
|
+
currentTimeOverride: currentTime,
|
|
233
|
+
});
|
|
234
|
+
if (result === "cannot-soft-reload") reloadPreview();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function finishTimelineTimingFallback(input: {
|
|
238
|
+
iframe: HTMLIFrameElement | null;
|
|
239
|
+
reloadPreview: () => void;
|
|
240
|
+
gsapMutation?: () => Promise<GsapMutationStatus>;
|
|
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;
|
|
250
|
+
}): Promise<void> {
|
|
251
|
+
let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
|
|
252
|
+
if (input.gsapMutation) {
|
|
253
|
+
try {
|
|
254
|
+
outcome = await input.gsapMutation();
|
|
255
|
+
} catch (error) {
|
|
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();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
syncTimingEditPreview(
|
|
264
|
+
input.iframe,
|
|
265
|
+
outcome,
|
|
266
|
+
usePlayerStore.getState().currentTime,
|
|
267
|
+
input.reloadPreview,
|
|
268
|
+
input.rebindWhenUnmutated,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
|
|
273
|
+
// outlast one GSAP server round-trip, never a real second edit.
|
|
274
|
+
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
|
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
|
+
|
|
287
|
+
/**
|
|
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.
|
|
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
|
|
341
|
+
async function foldGsapMutationIntoHistory(input: {
|
|
342
|
+
projectId: string;
|
|
343
|
+
label: string;
|
|
344
|
+
coalesceKey?: string;
|
|
345
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
346
|
+
gsapMutation: (runOwnedMutation: OwnedMutationRunner) => Promise<GsapMutationStatus>;
|
|
347
|
+
onRollbackError: (error: unknown) => void;
|
|
348
|
+
}): Promise<GsapMutationStatus> {
|
|
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);
|
|
377
|
+
}
|
|
378
|
+
if (status.mutated) {
|
|
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
|
+
}
|
|
386
|
+
const files: Record<string, { before: string; after: string }> = {};
|
|
387
|
+
for (const [path, owned] of ownershipByPath) {
|
|
388
|
+
const finalContent = await readFileContent(input.projectId, path);
|
|
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;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (Object.keys(files).length > 0) {
|
|
397
|
+
await input.recordEdit({
|
|
398
|
+
label: input.label,
|
|
399
|
+
kind: "timeline",
|
|
400
|
+
coalesceKey: input.coalesceKey,
|
|
401
|
+
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
|
402
|
+
files,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return status;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Shift all GSAP animation positions targeting a given element by a time delta.
|
|
414
|
+
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
|
415
|
+
* Returns the rewritten script so the caller can soft-reload instead of full-reload.
|
|
416
|
+
*/
|
|
417
|
+
export async function shiftGsapPositions(
|
|
418
|
+
projectId: string,
|
|
419
|
+
filePath: string,
|
|
420
|
+
elementId: string,
|
|
421
|
+
delta: number,
|
|
422
|
+
): Promise<GsapMutationStatus> {
|
|
423
|
+
if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
|
|
424
|
+
return postGsapMutation(
|
|
425
|
+
projectId,
|
|
426
|
+
filePath,
|
|
427
|
+
{
|
|
428
|
+
type: "shift-positions",
|
|
429
|
+
targetSelector: `#${elementId}`,
|
|
430
|
+
delta,
|
|
431
|
+
},
|
|
432
|
+
"shift-positions failed",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function scaleGsapPositions(
|
|
437
|
+
projectId: string,
|
|
438
|
+
filePath: string,
|
|
439
|
+
elementId: string,
|
|
440
|
+
oldStart: number,
|
|
441
|
+
oldDuration: number,
|
|
442
|
+
newStart: number,
|
|
443
|
+
newDuration: number,
|
|
444
|
+
): Promise<GsapMutationStatus> {
|
|
445
|
+
if (!elementId || oldDuration <= 0 || newDuration <= 0)
|
|
446
|
+
return { mutated: false, scriptText: null };
|
|
447
|
+
if (oldStart === newStart && oldDuration === newDuration)
|
|
448
|
+
return { mutated: false, scriptText: null };
|
|
449
|
+
return postGsapMutation(
|
|
450
|
+
projectId,
|
|
451
|
+
filePath,
|
|
452
|
+
{
|
|
453
|
+
type: "scale-positions",
|
|
454
|
+
targetSelector: `#${elementId}`,
|
|
455
|
+
oldStart,
|
|
456
|
+
oldDuration,
|
|
457
|
+
newStart,
|
|
458
|
+
newDuration,
|
|
459
|
+
},
|
|
460
|
+
"scale-positions failed",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
|
465
|
+
export type SingleClipGsapEdit =
|
|
466
|
+
| { kind: "shift"; delta: number }
|
|
467
|
+
| {
|
|
468
|
+
kind: "scale";
|
|
469
|
+
from: { start: number; duration: number };
|
|
470
|
+
to: { start: number; duration: number };
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
|
|
475
|
+
* server shift/scale mutation, folds the rewrite into the timing edit's history
|
|
476
|
+
* entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
|
|
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).
|
|
482
|
+
*/
|
|
483
|
+
export function finishClipTimingFallback(input: {
|
|
484
|
+
iframe: HTMLIFrameElement | null;
|
|
485
|
+
reloadPreview: () => void;
|
|
486
|
+
projectId: string | null;
|
|
487
|
+
targetPath: string;
|
|
488
|
+
domId: string | undefined;
|
|
489
|
+
label: string;
|
|
490
|
+
coalesceKey?: string;
|
|
491
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
492
|
+
edit: SingleClipGsapEdit;
|
|
493
|
+
}): Promise<void> {
|
|
494
|
+
const { projectId, targetPath, domId, edit } = input;
|
|
495
|
+
const timingChanged =
|
|
496
|
+
edit.kind === "shift"
|
|
497
|
+
? edit.delta !== 0
|
|
498
|
+
: edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
|
|
499
|
+
const runMutation = (pid: string, id: string): Promise<GsapMutationStatus> =>
|
|
500
|
+
edit.kind === "shift"
|
|
501
|
+
? shiftGsapPositions(pid, targetPath, id, edit.delta)
|
|
502
|
+
: scaleGsapPositions(
|
|
503
|
+
pid,
|
|
504
|
+
targetPath,
|
|
505
|
+
id,
|
|
506
|
+
edit.from.start,
|
|
507
|
+
edit.from.duration,
|
|
508
|
+
edit.to.start,
|
|
509
|
+
edit.to.duration,
|
|
510
|
+
);
|
|
511
|
+
const onGsapError = (err: unknown) =>
|
|
512
|
+
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
|
|
513
|
+
return finishTimelineTimingFallback({
|
|
514
|
+
iframe: input.iframe,
|
|
515
|
+
reloadPreview: input.reloadPreview,
|
|
516
|
+
gsapMutation:
|
|
517
|
+
timingChanged && domId && projectId
|
|
518
|
+
? () =>
|
|
519
|
+
foldGsapMutationIntoHistory({
|
|
520
|
+
projectId,
|
|
521
|
+
label: input.label,
|
|
522
|
+
coalesceKey: input.coalesceKey,
|
|
523
|
+
recordEdit: input.recordEdit,
|
|
524
|
+
gsapMutation: (runOwnedMutation) =>
|
|
525
|
+
runOwnedMutation(targetPath, () => runMutation(projectId, domId)),
|
|
526
|
+
onRollbackError: onGsapError,
|
|
527
|
+
})
|
|
528
|
+
: undefined,
|
|
529
|
+
onGsapError,
|
|
530
|
+
rebindWhenUnmutated: true,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
|
|
536
|
+
* per-change server mutation for every changed clip, folds the rewrites into the
|
|
537
|
+
* timing edit's history entry, and soft-reloads the preview when possible.
|
|
538
|
+
*
|
|
539
|
+
* The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
|
|
540
|
+
* the active comp's rewritten script can be soft-reloaded (swapped in place, no
|
|
541
|
+
* all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
|
|
542
|
+
* multi-file move — no scriptText is passed, so the fallback does ONE full
|
|
543
|
+
* reload that reflects every changed file.
|
|
544
|
+
*/
|
|
545
|
+
export async function finishGroupTimingGsapFallback<C extends { element: TimelineElement }>(input: {
|
|
546
|
+
projectId: string;
|
|
547
|
+
iframe: HTMLIFrameElement | null;
|
|
548
|
+
reloadPreview: () => void;
|
|
549
|
+
label: string;
|
|
550
|
+
errorLabel: string;
|
|
551
|
+
coalesceKey?: string;
|
|
552
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
553
|
+
activeCompPath: string | null;
|
|
554
|
+
changes: readonly C[];
|
|
555
|
+
resolveChangePath: (element: TimelineElement) => string;
|
|
556
|
+
/** Per-change GSAP mutation; return null to skip a change with no timing delta. */
|
|
557
|
+
mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
|
|
558
|
+
}): Promise<void> {
|
|
559
|
+
const activePath = input.activeCompPath || "index.html";
|
|
560
|
+
const otherFileChanged = input.changes.some(
|
|
561
|
+
(change) => input.resolveChangePath(change.element) !== activePath,
|
|
562
|
+
);
|
|
563
|
+
const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
|
|
564
|
+
await finishTimelineTimingFallback({
|
|
565
|
+
iframe: input.iframe,
|
|
566
|
+
reloadPreview: input.reloadPreview,
|
|
567
|
+
gsapMutation: () =>
|
|
568
|
+
foldGsapMutationIntoHistory({
|
|
569
|
+
projectId: input.projectId,
|
|
570
|
+
label: input.label,
|
|
571
|
+
coalesceKey: input.coalesceKey,
|
|
572
|
+
recordEdit: input.recordEdit,
|
|
573
|
+
gsapMutation: async (runOwnedMutation) => {
|
|
574
|
+
let mutated = false;
|
|
575
|
+
let scriptText: GsapMutationStatus["scriptText"] = null;
|
|
576
|
+
for (const change of input.changes) {
|
|
577
|
+
const changePath = input.resolveChangePath(change.element);
|
|
578
|
+
const status = await runOwnedMutation(changePath, () =>
|
|
579
|
+
input.mutateChange(change, changePath),
|
|
580
|
+
);
|
|
581
|
+
mutated = mutated || status.mutated;
|
|
582
|
+
// The LAST mutation against the active comp carries the cumulative
|
|
583
|
+
// rewritten script for that file.
|
|
584
|
+
if (changePath === activePath) scriptText = status.scriptText;
|
|
585
|
+
}
|
|
586
|
+
return { mutated, scriptText: otherFileChanged ? null : scriptText };
|
|
587
|
+
},
|
|
588
|
+
onRollbackError: onGsapError,
|
|
589
|
+
}),
|
|
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,
|
|
596
|
+
});
|
|
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
|
|
|
@@ -82,9 +82,10 @@ function patchLiveHiddenState(
|
|
|
82
82
|
iframe: HTMLIFrameElement | null,
|
|
83
83
|
elements: readonly TimelineElement[],
|
|
84
84
|
hidden: boolean,
|
|
85
|
+
activeCompPath: string | null,
|
|
85
86
|
): void {
|
|
86
87
|
for (const element of elements) {
|
|
87
|
-
const target = findTimelineElementInIframe(iframe, element);
|
|
88
|
+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
88
89
|
if (!target) continue;
|
|
89
90
|
if (hidden) {
|
|
90
91
|
target.setAttribute("data-hidden", "");
|
|
@@ -134,7 +135,7 @@ async function setElementsHidden({
|
|
|
134
135
|
}: SetElementsHiddenInput): Promise<string[]> {
|
|
135
136
|
if (elements.length === 0) return [];
|
|
136
137
|
|
|
137
|
-
patchLiveHiddenState(previewIframe, elements, hidden);
|
|
138
|
+
patchLiveHiddenState(previewIframe, elements, hidden, activeCompPath);
|
|
138
139
|
reseekPreviewRuntime(previewIframe);
|
|
139
140
|
|
|
140
141
|
const hiddenOperation: PatchOperation = {
|
|
@@ -188,7 +189,7 @@ async function setElementsHidden({
|
|
|
188
189
|
// The optimistic live patch already ran; a patch-target/save failure here would
|
|
189
190
|
// otherwise leave the preview showing the wrong visibility until a reload. Revert
|
|
190
191
|
// the live DOM to the prior state so what's on screen matches what persisted.
|
|
191
|
-
patchLiveHiddenState(previewIframe, elements, !hidden);
|
|
192
|
+
patchLiveHiddenState(previewIframe, elements, !hidden, activeCompPath);
|
|
192
193
|
reseekPreviewRuntime(previewIframe);
|
|
193
194
|
throw error;
|
|
194
195
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useCaptionStore } from "../captions/store";
|
|
3
|
+
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
3
4
|
import { useCaptionSync } from "../captions/hooks/useCaptionSync";
|
|
4
5
|
import { parseCaptionComposition } from "../captions/parser";
|
|
5
6
|
|
|
@@ -111,6 +112,7 @@ export function useCaptionDetection({
|
|
|
111
112
|
const handleMessage = (e: MessageEvent) => {
|
|
112
113
|
const data = e.data;
|
|
113
114
|
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
115
|
+
if (!acceptStudioRuntimeMessage(data)) return;
|
|
114
116
|
tryActivateCaptions();
|
|
115
117
|
}
|
|
116
118
|
};
|