@hyperframes/studio 0.7.90 → 0.7.93
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-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -7,9 +7,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
7
7
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
8
|
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
11
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
12
|
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
|
11
13
|
|
|
12
14
|
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
|
15
|
+
type CommitResult = { ok: boolean; changed: boolean };
|
|
16
|
+
type CommitOptions = { onResult?: (result: CommitResult) => void };
|
|
17
|
+
type CommitCall = [selection: unknown, mutation: unknown, options: CommitOptions];
|
|
18
|
+
|
|
19
|
+
function readCommitOptions(args: unknown[]): CommitOptions {
|
|
20
|
+
// The hook accepts the production writer type; these test doubles expose only
|
|
21
|
+
// the third tuple member they exercise.
|
|
22
|
+
return (args as unknown as CommitCall)[2];
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
let cleanup: (() => void) | null = null;
|
|
15
26
|
afterEach(() => {
|
|
@@ -21,15 +32,14 @@ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSe
|
|
|
21
32
|
|
|
22
33
|
function successfulCommitMutation() {
|
|
23
34
|
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
|
24
|
-
const options = args
|
|
25
|
-
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
|
26
|
-
};
|
|
35
|
+
const options = readCommitOptions(args);
|
|
27
36
|
options.onResult?.({ ok: true, changed: true });
|
|
28
37
|
});
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
function renderKeyframeOps(over: {
|
|
32
41
|
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
|
42
|
+
commitMutationSafely?: (...args: unknown[]) => Promise<void>;
|
|
33
43
|
trackGsapSaveFailure: (...args: unknown[]) => void;
|
|
34
44
|
}) {
|
|
35
45
|
const captured: { api: HookApi | null } = { api: null };
|
|
@@ -41,7 +51,7 @@ function renderKeyframeOps(over: {
|
|
|
41
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
42
52
|
commitMutation: over.commitMutation as any,
|
|
43
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
44
|
-
commitMutationSafely: (() => {}) as any,
|
|
54
|
+
commitMutationSafely: (over.commitMutationSafely ?? (async () => {})) as any,
|
|
45
55
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
46
56
|
trackGsapSaveFailure: over.trackGsapSaveFailure as any,
|
|
47
57
|
sdkSession: null,
|
|
@@ -135,7 +145,7 @@ describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
|
|
135
145
|
|
|
136
146
|
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
|
137
147
|
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
|
138
|
-
const options = args
|
|
148
|
+
const options = readCommitOptions(args);
|
|
139
149
|
options.onResult?.({ ok: true, changed: false });
|
|
140
150
|
});
|
|
141
151
|
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
@@ -203,6 +213,74 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
|
203
213
|
);
|
|
204
214
|
});
|
|
205
215
|
|
|
216
|
+
it("clears delete-all cache only when persistence confirms the change", async () => {
|
|
217
|
+
let finishCommit: (() => void) | undefined;
|
|
218
|
+
const commitMutationSafely = vi.fn(
|
|
219
|
+
(...args: unknown[]) =>
|
|
220
|
+
new Promise<void>((resolve) => {
|
|
221
|
+
const options = readCommitOptions(args);
|
|
222
|
+
finishCommit = () => {
|
|
223
|
+
options.onResult?.({ ok: true, changed: true });
|
|
224
|
+
resolve();
|
|
225
|
+
};
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
const api = renderKeyframeOps({
|
|
229
|
+
commitMutation: successfulCommitMutation(),
|
|
230
|
+
commitMutationSafely,
|
|
231
|
+
trackGsapSaveFailure: vi.fn(),
|
|
232
|
+
});
|
|
233
|
+
const cached: KeyframeCacheEntry = {
|
|
234
|
+
format: "percentage",
|
|
235
|
+
keyframes: [
|
|
236
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
237
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
|
|
241
|
+
|
|
242
|
+
const pending = api.removeAllKeyframes(selection, "box-to-0-position");
|
|
243
|
+
expect(commitMutationSafely).toHaveBeenCalledWith(
|
|
244
|
+
selection,
|
|
245
|
+
{ type: "remove-all-keyframes", animationId: "box-to-0-position" },
|
|
246
|
+
expect.objectContaining({ label: "Remove all keyframes", softReload: true }),
|
|
247
|
+
);
|
|
248
|
+
expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
|
|
249
|
+
|
|
250
|
+
finishCommit?.();
|
|
251
|
+
await pending;
|
|
252
|
+
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("clears the live DOM identity for a selector-only selection", async () => {
|
|
256
|
+
const element = document.createElement("div");
|
|
257
|
+
element.id = "box";
|
|
258
|
+
const selectorOnlySelection: DomEditSelection = {
|
|
259
|
+
...selection,
|
|
260
|
+
id: undefined,
|
|
261
|
+
selector: ".box",
|
|
262
|
+
element,
|
|
263
|
+
};
|
|
264
|
+
const commitMutationSafely = vi.fn(async (...args: unknown[]) => {
|
|
265
|
+
const options = readCommitOptions(args);
|
|
266
|
+
options.onResult?.({ ok: true, changed: true });
|
|
267
|
+
});
|
|
268
|
+
const api = renderKeyframeOps({
|
|
269
|
+
commitMutation: successfulCommitMutation(),
|
|
270
|
+
commitMutationSafely,
|
|
271
|
+
trackGsapSaveFailure: vi.fn(),
|
|
272
|
+
});
|
|
273
|
+
const cached: KeyframeCacheEntry = {
|
|
274
|
+
format: "percentage",
|
|
275
|
+
keyframes: [{ percentage: 0, properties: { x: 0 } }],
|
|
276
|
+
};
|
|
277
|
+
usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
|
|
278
|
+
|
|
279
|
+
await api.removeAllKeyframes(selectorOnlySelection, "box-to-0-position");
|
|
280
|
+
|
|
281
|
+
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
206
284
|
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
|
207
285
|
const commitMutation = successfulCommitMutation();
|
|
208
286
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "../utils/sdkCutover";
|
|
16
16
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
17
17
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
18
|
-
import { idFromSelector } from "./gsapShared";
|
|
19
18
|
import {
|
|
20
19
|
clearKeyframeCacheForElement,
|
|
21
20
|
readKeyframeSnapshot,
|
|
@@ -336,11 +335,9 @@ export function useGsapKeyframeOps({
|
|
|
336
335
|
const removeAllKeyframes = useCallback(
|
|
337
336
|
async (selection: DomEditSelection, animationId: string) => {
|
|
338
337
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
const elementId = selection.id ?? idFromSelector(selection.selector);
|
|
343
|
-
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
|
338
|
+
// A class/descendant selector can resolve a live element whose selection
|
|
339
|
+
// deliberately has no id. The cache is still keyed by that DOM id.
|
|
340
|
+
const cacheElementId = selection.id || selection.element?.id;
|
|
344
341
|
if (sdkSession && sdkDeps) {
|
|
345
342
|
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
|
346
343
|
targetPath,
|
|
@@ -349,12 +346,26 @@ export function useGsapKeyframeOps({
|
|
|
349
346
|
sdkDeps,
|
|
350
347
|
{ label: "Remove all keyframes" },
|
|
351
348
|
);
|
|
352
|
-
if (cutoverCommittedOrThrow(handled))
|
|
349
|
+
if (cutoverCommittedOrThrow(handled)) {
|
|
350
|
+
if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
353
|
}
|
|
354
|
-
commitMutationSafely(
|
|
354
|
+
await commitMutationSafely(
|
|
355
355
|
selection,
|
|
356
356
|
{ type: "remove-all-keyframes", animationId },
|
|
357
|
-
{
|
|
357
|
+
{
|
|
358
|
+
label: "Remove all keyframes",
|
|
359
|
+
softReload: true,
|
|
360
|
+
// The committed result is the single success boundary: clearing
|
|
361
|
+
// before it makes failed saves lie, while waiting for the reload leaves
|
|
362
|
+
// stale diamonds visible during the source round-trip.
|
|
363
|
+
onResult: (result) => {
|
|
364
|
+
if (result.changed !== false && cacheElementId) {
|
|
365
|
+
clearKeyframeCacheForElement(targetPath, cacheElementId);
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
},
|
|
358
369
|
);
|
|
359
370
|
},
|
|
360
371
|
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
3
|
+
import { drainStudioSaveQueues } from "./usePreviewPersistence";
|
|
4
|
+
|
|
5
|
+
describe("drainStudioSaveQueues", () => {
|
|
6
|
+
it("does not erase a pending-field conflict with a clean DOM queue", async () => {
|
|
7
|
+
const conflict = new StudioFileConflictError({
|
|
8
|
+
filePath: "index.html",
|
|
9
|
+
currentVersion: "v2",
|
|
10
|
+
currentContent: "external",
|
|
11
|
+
attemptedContent: "studio",
|
|
12
|
+
});
|
|
13
|
+
const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
|
|
14
|
+
|
|
15
|
+
await expect(
|
|
16
|
+
drainStudioSaveQueues(
|
|
17
|
+
async () => ({ status: "conflict" as const, error: conflict }),
|
|
18
|
+
waitForDomQueue,
|
|
19
|
+
),
|
|
20
|
+
).resolves.toEqual({ status: "conflict", error: conflict });
|
|
21
|
+
expect(waitForDomQueue).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
|
|
|
3
3
|
import {
|
|
4
4
|
installStudioManualEditSeekReapply,
|
|
5
5
|
reapplyPositionEditsAfterSeek,
|
|
6
|
-
readStudioFileChangePath,
|
|
7
6
|
} from "../components/editor/manualEdits";
|
|
8
7
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
9
8
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
|
-
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
11
|
-
import {
|
|
9
|
+
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
|
|
10
|
+
import {
|
|
11
|
+
flushStudioPendingEdits,
|
|
12
|
+
type StudioPendingEditsDrainResult,
|
|
13
|
+
} from "../utils/studioPendingEdits";
|
|
12
14
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
13
15
|
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
|
|
14
16
|
import { usePlayerStore } from "../player";
|
|
@@ -29,19 +31,12 @@ interface RecordEditInput {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface UsePreviewPersistenceParams {
|
|
32
|
-
projectId: string | null;
|
|
33
34
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
35
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
36
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
36
37
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
37
38
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
38
39
|
activeCompPathRef: React.MutableRefObject<string | null>;
|
|
39
|
-
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
|
40
|
-
* Used to suppress file-change echoes so we don't reload after our own saves. */
|
|
41
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
42
|
-
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
|
|
43
|
-
* events for these paths are always suppressed since the preview is already up-to-date. */
|
|
44
|
-
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
|
|
45
40
|
/** Called to reload the preview after undo/redo or external file changes. */
|
|
46
41
|
reloadPreview: () => void;
|
|
47
42
|
}
|
|
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
|
|
|
71
66
|
}
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
|
|
82
|
-
if (pendingTimelinePaths?.has(changedPath)) {
|
|
83
|
-
pendingTimelinePaths.delete(changedPath);
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return Date.now() - domEditSaveTimestampRef.current >= 4000;
|
|
69
|
+
export async function drainStudioSaveQueues(
|
|
70
|
+
flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
|
|
71
|
+
waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
|
|
72
|
+
): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
|
|
73
|
+
const pending = await flushPendingFields();
|
|
74
|
+
if (pending.status !== "clean") return pending;
|
|
75
|
+
return waitForDomQueue();
|
|
87
76
|
}
|
|
88
77
|
|
|
89
78
|
// fallow-ignore-next-line complexity
|
|
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
|
|
|
107
96
|
// ── Hook ──
|
|
108
97
|
|
|
109
98
|
export function usePreviewPersistence({
|
|
110
|
-
projectId,
|
|
111
99
|
showToast,
|
|
112
100
|
readOptionalProjectFile: _readOptionalProjectFile,
|
|
113
101
|
writeProjectFile: _writeProjectFile,
|
|
114
102
|
recordEdit: _recordEdit,
|
|
115
103
|
previewIframeRef,
|
|
116
104
|
activeCompPathRef,
|
|
117
|
-
domEditSaveTimestampRef,
|
|
118
105
|
reloadPreview,
|
|
119
|
-
pendingTimelineEditPathRef,
|
|
120
106
|
}: UsePreviewPersistenceParams) {
|
|
121
107
|
void _recordEdit;
|
|
122
108
|
|
|
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
|
|
|
152
138
|
});
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
156
|
-
// current value, even when the callback was captured in a stale closure.
|
|
157
|
-
const projectIdRef = useRef(projectId);
|
|
158
|
-
projectIdRef.current = projectId;
|
|
159
|
-
|
|
160
141
|
// ── Queue / drain helpers ──
|
|
161
142
|
|
|
162
143
|
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
|
|
163
144
|
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
|
|
164
145
|
}, []);
|
|
165
146
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
147
|
+
const drainPendingDomEditSaves = useCallback(async () => {
|
|
148
|
+
return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
|
|
149
|
+
return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
|
|
150
|
+
});
|
|
169
151
|
}, []);
|
|
170
152
|
|
|
153
|
+
const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
|
|
154
|
+
const result = await drainPendingDomEditSaves();
|
|
155
|
+
if (result.status !== "clean") throw result.error;
|
|
156
|
+
}, [drainPendingDomEditSaves]);
|
|
157
|
+
|
|
171
158
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
172
159
|
domEditSaveQueueRef.current?.reset();
|
|
173
160
|
setDomEditSaveQueuePaused(null);
|
|
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
|
|
|
235
222
|
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
|
|
236
223
|
});
|
|
237
224
|
|
|
238
|
-
// ── Listen for external file changes (HMR / SSE) ──
|
|
239
|
-
useMountEffect(() => {
|
|
240
|
-
const handler = (payload?: unknown) => {
|
|
241
|
-
if (
|
|
242
|
-
shouldReloadForStudioFileChange(
|
|
243
|
-
payload,
|
|
244
|
-
pendingTimelineEditPathRef,
|
|
245
|
-
domEditSaveTimestampRef,
|
|
246
|
-
)
|
|
247
|
-
) {
|
|
248
|
-
// fallow-ignore-next-line code-duplication
|
|
249
|
-
reloadPreview();
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
if (import.meta.hot) {
|
|
253
|
-
import.meta.hot.on("hf:file-change", handler);
|
|
254
|
-
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
255
|
-
}
|
|
256
|
-
// SSE fallback for embedded studio server
|
|
257
|
-
const es = new EventSource("/api/events");
|
|
258
|
-
es.addEventListener("file-change", handler);
|
|
259
|
-
return () => es.close();
|
|
260
|
-
});
|
|
261
|
-
|
|
262
225
|
return {
|
|
263
226
|
domTextCommitVersionRef,
|
|
264
227
|
domEditSaveQueueRef,
|
|
265
228
|
applyStudioManualEditsToPreviewRef,
|
|
266
229
|
queueDomEditSave,
|
|
230
|
+
drainPendingDomEditSaves,
|
|
267
231
|
waitForPendingDomEditSaves,
|
|
268
232
|
domEditSaveQueuePaused,
|
|
269
233
|
resetDomEditSaveQueueBreaker,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import type { MutableRefObject } from "react";
|
|
3
2
|
import { openComposition } from "@hyperframes/sdk";
|
|
4
3
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
4
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
6
5
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
7
6
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
7
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
8
|
+
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Read a project file's content, or undefined on a non-2xx (optional read).
|
|
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
48
48
|
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
49
49
|
* the open effect below.
|
|
50
50
|
*/
|
|
51
|
-
// Reload-suppression baseline: a file-change within this window of our own SDK
|
|
52
|
-
// cutover write is a CANDIDATE echo, but the decision is content-identity based
|
|
53
|
-
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
|
|
54
|
-
// still reloads (its reverted bytes were never registered as a self-write). The
|
|
55
|
-
// window only bounds how long a registered self-write stays suppressible.
|
|
56
|
-
const SELF_WRITE_SUPPRESS_MS = 2000;
|
|
57
|
-
|
|
58
|
-
/** Best-effort read of the changed file's content from a file-change payload. */
|
|
59
|
-
function readFileChangeContent(payload: unknown): string | null {
|
|
60
|
-
if (!payload || typeof payload !== "object") return null;
|
|
61
|
-
const record = payload as Record<string, unknown>;
|
|
62
|
-
if (typeof record.content === "string") return record.content;
|
|
63
|
-
if ("data" in record) return readFileChangeContent(record.data);
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
51
|
/**
|
|
68
52
|
* Decide whether a file-change for the active composition should reload the SDK
|
|
69
53
|
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
|
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
|
|
|
157
141
|
export function useSdkSession(
|
|
158
142
|
projectId: string | null,
|
|
159
143
|
activeCompPath: string | null,
|
|
160
|
-
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
161
144
|
): SdkSessionHandle {
|
|
162
145
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
163
146
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
@@ -171,40 +154,13 @@ export function useSdkSession(
|
|
|
171
154
|
const reloadTokenRef = useRef(reloadToken);
|
|
172
155
|
reloadTokenRef.current = reloadToken;
|
|
173
156
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const withinWindow =
|
|
182
|
-
!!domEditSaveTimestampRef &&
|
|
183
|
-
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
|
|
184
|
-
const decide = (content: string | null) => {
|
|
185
|
-
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
|
|
186
|
-
};
|
|
187
|
-
const payloadContent = readFileChangeContent(payload);
|
|
188
|
-
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
189
|
-
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
190
|
-
if (payloadContent != null || readProjectId == null) {
|
|
191
|
-
decide(payloadContent);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
readProjectFileOptional(readProjectId, compPath)
|
|
195
|
-
.then((c) => decide(c ?? null))
|
|
196
|
-
.catch(() => decide(null));
|
|
197
|
-
};
|
|
198
|
-
if (import.meta.hot) {
|
|
199
|
-
import.meta.hot.on("hf:file-change", handler);
|
|
200
|
-
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
201
|
-
}
|
|
202
|
-
// SSE fallback for the embedded studio server.
|
|
203
|
-
const es = new EventSource("/api/events");
|
|
204
|
-
es.addEventListener("file-change", handler);
|
|
205
|
-
return () => es.close();
|
|
206
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
-
}, [activeCompPath, projectId]);
|
|
157
|
+
useEffect(
|
|
158
|
+
() =>
|
|
159
|
+
addExternalFileReloadListener((changedPath) => {
|
|
160
|
+
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
|
|
161
|
+
}),
|
|
162
|
+
[],
|
|
163
|
+
);
|
|
208
164
|
|
|
209
165
|
// ── Open / re-open the session ──
|
|
210
166
|
useEffect(() => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
deleteExternalConflictSnapshot,
|
|
4
|
+
loadExternalConflictSnapshot,
|
|
5
|
+
persistExternalConflictSnapshot,
|
|
6
|
+
persistExternalFailureSnapshot,
|
|
7
|
+
} from "../utils/externalConflictStorage";
|
|
8
|
+
import { notifyExternalFileReload } from "./externalFileReloadBus";
|
|
9
|
+
import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
|
|
10
|
+
import type { useFileManager } from "./useFileManager";
|
|
11
|
+
import type { usePreviewPersistence } from "./usePreviewPersistence";
|
|
12
|
+
|
|
13
|
+
type FileManager = Pick<
|
|
14
|
+
ReturnType<typeof useFileManager>,
|
|
15
|
+
| "editingFile"
|
|
16
|
+
| "flushPendingSourceSave"
|
|
17
|
+
| "discardPendingSourceSave"
|
|
18
|
+
| "getPendingSourceCandidate"
|
|
19
|
+
| "overwriteExternalConflict"
|
|
20
|
+
| "readProjectFile"
|
|
21
|
+
| "updateEditingFileContent"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
type PreviewPersistence = Pick<
|
|
25
|
+
ReturnType<typeof usePreviewPersistence>,
|
|
26
|
+
"drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
interface UseStudioExternalFileChangesOptions {
|
|
30
|
+
projectId: string | null;
|
|
31
|
+
activeCompPath: string | null;
|
|
32
|
+
masterCompPath: string | null;
|
|
33
|
+
fileManager: FileManager;
|
|
34
|
+
previewPersistence: PreviewPersistence;
|
|
35
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
36
|
+
reloadPreview: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
|
|
40
|
+
export function useStudioExternalFileChanges({
|
|
41
|
+
projectId,
|
|
42
|
+
activeCompPath,
|
|
43
|
+
masterCompPath,
|
|
44
|
+
fileManager,
|
|
45
|
+
previewPersistence,
|
|
46
|
+
pendingTimelineEditPathRef,
|
|
47
|
+
reloadPreview,
|
|
48
|
+
}: UseStudioExternalFileChangesOptions) {
|
|
49
|
+
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
|
50
|
+
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
|
51
|
+
const drainPendingChanges = useCallback(async () => {
|
|
52
|
+
const source = await flushPendingSourceSave();
|
|
53
|
+
if (source.status !== "clean") return source;
|
|
54
|
+
return drainPendingDomEditSaves();
|
|
55
|
+
}, [drainPendingDomEditSaves, flushPendingSourceSave]);
|
|
56
|
+
|
|
57
|
+
const discardPendingChanges = useCallback(() => {
|
|
58
|
+
discardPendingSourceSave();
|
|
59
|
+
resetDomEditSaveQueueBreaker();
|
|
60
|
+
}, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
|
|
61
|
+
|
|
62
|
+
return useExternalFileChangeCoordinator({
|
|
63
|
+
projectId,
|
|
64
|
+
activeCompPath,
|
|
65
|
+
recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
|
|
66
|
+
pendingTimelineEditPathRef,
|
|
67
|
+
drainPendingChanges,
|
|
68
|
+
getPendingCandidate: fileManager.getPendingSourceCandidate,
|
|
69
|
+
discardPendingChanges,
|
|
70
|
+
reloadPreview,
|
|
71
|
+
reloadSdkSession: notifyExternalFileReload,
|
|
72
|
+
persistConflictSnapshot: persistExternalConflictSnapshot,
|
|
73
|
+
persistFailureSnapshot: persistExternalFailureSnapshot,
|
|
74
|
+
loadConflictSnapshot: loadExternalConflictSnapshot,
|
|
75
|
+
deleteConflictSnapshot: deleteExternalConflictSnapshot,
|
|
76
|
+
overwriteConflict: fileManager.overwriteExternalConflict,
|
|
77
|
+
readProjectFile: fileManager.readProjectFile,
|
|
78
|
+
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
79
|
+
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect
|
|
1
|
+
import { useEffect } from "react";
|
|
2
2
|
import { useSdkSession } from "./useSdkSession";
|
|
3
3
|
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
4
4
|
|
|
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
|
17
17
|
export function useStudioSdkSessions(
|
|
18
18
|
projectId: string | null,
|
|
19
19
|
activeCompPath: string | null,
|
|
20
|
-
domEditSaveTimestampRef: MutableRefObject<number>,
|
|
21
20
|
masterCompPath: string | null,
|
|
22
21
|
) {
|
|
23
22
|
// On the master view (no explicit comp) the schema panels target the project's
|
|
24
23
|
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
|
25
24
|
// "index.html" that may not exist. `null` when the project has no composition
|
|
26
25
|
// yet, which correctly leaves the session (and the panels) empty.
|
|
27
|
-
const sdkHandle = useSdkSession(
|
|
28
|
-
projectId,
|
|
29
|
-
activeCompPath ?? masterCompPath,
|
|
30
|
-
domEditSaveTimestampRef,
|
|
31
|
-
);
|
|
26
|
+
const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
|
|
32
27
|
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
|
33
28
|
useEffect(() => {
|
|
34
29
|
usePreviewVariablesStore.getState().setValues(null);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
|
|
3
|
+
import { trackStudioSessionStart } from "../telemetry/events";
|
|
4
|
+
|
|
5
|
+
export function useStudioSessionStart(
|
|
6
|
+
projectId: string | null,
|
|
7
|
+
resolving: boolean,
|
|
8
|
+
waitingForServer: boolean,
|
|
9
|
+
): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (resolving || waitingForServer || hasFiredSessionStart()) return;
|
|
12
|
+
markSessionStartFired();
|
|
13
|
+
trackStudioSessionStart({ has_project: projectId != null });
|
|
14
|
+
}, [projectId, resolving, waitingForServer]);
|
|
15
|
+
}
|
|
@@ -59,11 +59,15 @@ describe("timeline performance fixture", () => {
|
|
|
59
59
|
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it.each(PROFILES)(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
it.each(PROFILES)(
|
|
63
|
+
"generates an identical 50k %s fixture",
|
|
64
|
+
(profile) => {
|
|
65
|
+
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
66
|
+
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
67
|
+
expect(second).toEqual(first);
|
|
68
|
+
},
|
|
69
|
+
30_000,
|
|
70
|
+
);
|
|
67
71
|
|
|
68
72
|
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
|
|
69
73
|
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
|
|
@@ -90,7 +94,7 @@ describe("timeline performance fixture", () => {
|
|
|
90
94
|
usePlayerStore.setState({
|
|
91
95
|
isPlaying: true,
|
|
92
96
|
requestedSeekTime: 42,
|
|
93
|
-
|
|
97
|
+
timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
|
|
94
98
|
clipManifest: [],
|
|
95
99
|
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
|
|
96
100
|
});
|
|
@@ -108,7 +112,7 @@ describe("timeline performance fixture", () => {
|
|
|
108
112
|
expect(usePlayerStore.getState()).toMatchObject({
|
|
109
113
|
isPlaying: false,
|
|
110
114
|
requestedSeekTime: null,
|
|
111
|
-
|
|
115
|
+
timelineFocus: null,
|
|
112
116
|
clipManifest: null,
|
|
113
117
|
duration: 600,
|
|
114
118
|
timelineReady: true,
|