@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -3,6 +3,7 @@ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerS
|
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
|
5
5
|
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
|
6
|
+
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
|
|
6
7
|
|
|
7
8
|
export type { ClipManifestClip } from "../lib/playbackTypes";
|
|
8
9
|
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
|
@@ -15,7 +16,6 @@ export {
|
|
|
15
16
|
parseTimelineFromDOM,
|
|
16
17
|
readTimelineDurationFromDocument,
|
|
17
18
|
resolveStandaloneRootCompositionSrc,
|
|
18
|
-
resolveIframe,
|
|
19
19
|
} from "../lib/timelineDOM";
|
|
20
20
|
export {
|
|
21
21
|
shouldIgnorePlaybackShortcutEvent,
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
mergeTimelineElementsPreservingDowngrades,
|
|
37
37
|
parseTimelineFromDOM,
|
|
38
38
|
} from "../lib/timelineDOM";
|
|
39
|
+
import { normalizeToZones } from "../components/timelineZones";
|
|
39
40
|
import {
|
|
40
41
|
setPreviewMediaMuted,
|
|
41
42
|
setPreviewPlaybackRate,
|
|
@@ -90,12 +91,17 @@ export function useTimelinePlayer() {
|
|
|
90
91
|
// applyCachedSourceDurations re-applies the cached probe duration: re-derived
|
|
91
92
|
// elements (e.g. after a clip move) can arrive without sourceDuration, which
|
|
92
93
|
// otherwise makes trimmed waveforms lose their window.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
// Enforced CapCut zoning (overlay → main → audio): normalize track indices
|
|
95
|
+
// on every discovery. Idempotent — already-zoned input is returned as-is, so
|
|
96
|
+
// drops persist zoned indices and reloads re-zone to the same (no drift).
|
|
97
|
+
const mergedElements = normalizeToZones(
|
|
98
|
+
applyCachedSourceDurations(
|
|
99
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
100
|
+
state.elements,
|
|
101
|
+
elements,
|
|
102
|
+
state.duration,
|
|
103
|
+
resolvedDuration,
|
|
104
|
+
),
|
|
99
105
|
),
|
|
100
106
|
);
|
|
101
107
|
|
|
@@ -207,49 +213,14 @@ export function useTimelinePlayer() {
|
|
|
207
213
|
}
|
|
208
214
|
}, []);
|
|
209
215
|
|
|
210
|
-
const stopReverseLoop =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const adapter = getAdapter();
|
|
218
|
-
if (adapter) {
|
|
219
|
-
const rawTime = adapter.getTime();
|
|
220
|
-
const dur = adapter.getDuration();
|
|
221
|
-
const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
|
|
222
|
-
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
223
|
-
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
224
|
-
const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
|
|
225
|
-
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
226
|
-
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
|
|
227
|
-
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
228
|
-
if (time >= loopEnd) {
|
|
229
|
-
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
230
|
-
// keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
|
|
231
|
-
adapter.seek(loopStart, { keepPlaying: true });
|
|
232
|
-
liveTime.notify(loopStart);
|
|
233
|
-
adapter.play();
|
|
234
|
-
setIsPlaying(true);
|
|
235
|
-
rafRef.current = requestAnimationFrame(tick);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (adapter.isPlaying()) adapter.pause();
|
|
239
|
-
setCurrentTime(time); // sync Zustand once at end
|
|
240
|
-
setIsPlaying(false);
|
|
241
|
-
cancelAnimationFrame(rafRef.current);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
rafRef.current = requestAnimationFrame(tick);
|
|
246
|
-
};
|
|
247
|
-
rafRef.current = requestAnimationFrame(tick);
|
|
248
|
-
}, [getAdapter, setCurrentTime, setIsPlaying]);
|
|
216
|
+
const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
|
|
217
|
+
rafRef,
|
|
218
|
+
reverseRafRef,
|
|
219
|
+
getAdapter,
|
|
220
|
+
setCurrentTime,
|
|
221
|
+
setIsPlaying,
|
|
222
|
+
});
|
|
249
223
|
|
|
250
|
-
const stopRAFLoop = useCallback(() => {
|
|
251
|
-
cancelAnimationFrame(rafRef.current);
|
|
252
|
-
}, []);
|
|
253
224
|
const applyPlaybackRate = useCallback((rate: number) => {
|
|
254
225
|
const iframe = iframeRef.current;
|
|
255
226
|
if (!iframe) return;
|
|
@@ -463,10 +434,24 @@ export function useTimelinePlayer() {
|
|
|
463
434
|
applyPreviewAudioState,
|
|
464
435
|
});
|
|
465
436
|
const saveSeekPosition = useCallback(() => {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
437
|
+
// Never DEGRADE the saved position. Overlapping reloads (e.g. an external
|
|
438
|
+
// file drop = upload reload + insert reload back-to-back) call this while
|
|
439
|
+
// the iframe from the FIRST reload is mid-teardown: getAdapter() can still
|
|
440
|
+
// return that dying document's adapter, whose getTime() reads 0 — and the
|
|
441
|
+
// store's currentTime can lag the visual playhead. Overwriting the
|
|
442
|
+
// still-unconsumed pendingSeek with either value is exactly how the
|
|
443
|
+
// playhead used to end up at 0 after a Finder drop (verified live via a
|
|
444
|
+
// currentTime write-trace). So: while a refresh is already in flight and a
|
|
445
|
+
// save exists, keep it; otherwise trust the live adapter, then the store.
|
|
446
|
+
const refreshInFlight = isRefreshingRef.current && pendingSeekRef.current != null;
|
|
447
|
+
if (!refreshInFlight) {
|
|
448
|
+
const adapter = getAdapter();
|
|
449
|
+
if (adapter) {
|
|
450
|
+
pendingSeekRef.current = adapter.getTime();
|
|
451
|
+
} else if (pendingSeekRef.current == null) {
|
|
452
|
+
pendingSeekRef.current = usePlayerStore.getState().currentTime ?? 0;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
470
455
|
isRefreshingRef.current = true;
|
|
471
456
|
stopRAFLoop();
|
|
472
457
|
stopReverseLoop();
|
|
@@ -476,6 +461,16 @@ export function useTimelinePlayer() {
|
|
|
476
461
|
const iframe = iframeRef.current;
|
|
477
462
|
if (!iframe) return;
|
|
478
463
|
saveSeekPosition();
|
|
464
|
+
// Hide the iframe across the full reload so the user never sees the reloading
|
|
465
|
+
// document's RAW DOM (every clip stacked and visible) in the window between the
|
|
466
|
+
// new document parsing and the runtime initializing + seeking. initializeAdapter
|
|
467
|
+
// reveals it again right after its restore seek renders the correct frame.
|
|
468
|
+
// Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
|
|
469
|
+
// the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
|
|
470
|
+
// blank is far less jarring than a burst of every asset appearing at once.
|
|
471
|
+
// Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
|
|
472
|
+
// the soft-reload path and never touch refreshPlayer.
|
|
473
|
+
iframe.style.visibility = "hidden";
|
|
479
474
|
const src = iframe.src;
|
|
480
475
|
const url = new URL(src, window.location.origin);
|
|
481
476
|
url.searchParams.set("_t", String(Date.now()));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forward playback loop for the timeline player.
|
|
3
|
+
*
|
|
4
|
+
* Owns the three requestAnimationFrame lifecycle callbacks that drive (and stop)
|
|
5
|
+
* playback:
|
|
6
|
+
* - startRAFLoop — the forward tick: advance liveTime, honour in/out loop
|
|
7
|
+
* points + loopEnabled, and pause + sync the store at the end.
|
|
8
|
+
* - stopRAFLoop — cancel the forward tick.
|
|
9
|
+
* - stopReverseLoop — cancel the reverse-shuttle tick (owned by the parent hook).
|
|
10
|
+
*
|
|
11
|
+
* Called unconditionally at the top level of useTimelinePlayer so its useCallback
|
|
12
|
+
* hooks run in a stable order; every dependency is passed in as an argument.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback } from "react";
|
|
16
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
17
|
+
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
18
|
+
|
|
19
|
+
interface UseTimelinePlayerLoopParams {
|
|
20
|
+
rafRef: React.MutableRefObject<number>;
|
|
21
|
+
reverseRafRef: React.MutableRefObject<number>;
|
|
22
|
+
getAdapter: () => PlaybackAdapter | null;
|
|
23
|
+
setCurrentTime: (v: number) => void;
|
|
24
|
+
setIsPlaying: (v: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface UseTimelinePlayerLoopResult {
|
|
28
|
+
startRAFLoop: () => void;
|
|
29
|
+
stopRAFLoop: () => void;
|
|
30
|
+
stopReverseLoop: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTimelinePlayerLoop({
|
|
34
|
+
rafRef,
|
|
35
|
+
reverseRafRef,
|
|
36
|
+
getAdapter,
|
|
37
|
+
setCurrentTime,
|
|
38
|
+
setIsPlaying,
|
|
39
|
+
}: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
|
|
40
|
+
const stopReverseLoop = useCallback(() => {
|
|
41
|
+
cancelAnimationFrame(reverseRafRef.current);
|
|
42
|
+
}, [reverseRafRef]);
|
|
43
|
+
|
|
44
|
+
const startRAFLoop = useCallback(() => {
|
|
45
|
+
// fallow-ignore-next-line complexity
|
|
46
|
+
const tick = () => {
|
|
47
|
+
const adapter = getAdapter();
|
|
48
|
+
if (adapter) {
|
|
49
|
+
const rawTime = adapter.getTime();
|
|
50
|
+
const dur = adapter.getDuration();
|
|
51
|
+
const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
|
|
52
|
+
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
53
|
+
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
54
|
+
const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
|
|
55
|
+
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
56
|
+
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
|
|
57
|
+
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
58
|
+
if (time >= loopEnd) {
|
|
59
|
+
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
60
|
+
// keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
|
|
61
|
+
adapter.seek(loopStart, { keepPlaying: true });
|
|
62
|
+
liveTime.notify(loopStart);
|
|
63
|
+
adapter.play();
|
|
64
|
+
setIsPlaying(true);
|
|
65
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (adapter.isPlaying()) adapter.pause();
|
|
69
|
+
setCurrentTime(time); // sync Zustand once at end
|
|
70
|
+
setIsPlaying(false);
|
|
71
|
+
cancelAnimationFrame(rafRef.current);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
76
|
+
};
|
|
77
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
78
|
+
}, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
|
|
79
|
+
|
|
80
|
+
const stopRAFLoop = useCallback(() => {
|
|
81
|
+
cancelAnimationFrame(rafRef.current);
|
|
82
|
+
}, [rafRef]);
|
|
83
|
+
|
|
84
|
+
return { startRAFLoop, stopRAFLoop, stopReverseLoop };
|
|
85
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
resolveReloadSeekTime,
|
|
5
|
+
resolveTimelineTotalDuration,
|
|
6
|
+
revealIframe,
|
|
7
|
+
} from "./useTimelineSyncCallbacks";
|
|
8
|
+
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
|
|
9
|
+
|
|
10
|
+
// Minimal stand-in — revealIframe only touches `style.visibility`. Avoids
|
|
11
|
+
// depending on a DOM environment (this test file runs under node).
|
|
12
|
+
function fakeIframe(visibility: string): HTMLIFrameElement {
|
|
13
|
+
return { style: { visibility } } as unknown as HTMLIFrameElement;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("revealIframe", () => {
|
|
17
|
+
it("clears a hidden iframe's visibility (undoes refreshPlayer's hide)", () => {
|
|
18
|
+
const iframe = fakeIframe("hidden");
|
|
19
|
+
revealIframe(iframe);
|
|
20
|
+
expect(iframe.style.visibility).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("leaves an already-visible iframe untouched (idempotent)", () => {
|
|
24
|
+
const iframe = fakeIframe("");
|
|
25
|
+
revealIframe(iframe);
|
|
26
|
+
expect(iframe.style.visibility).toBe("");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("no-ops on a null iframe", () => {
|
|
30
|
+
expect(() => revealIframe(null)).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveReloadSeekTime", () => {
|
|
35
|
+
it("restores the pending seek saved by refreshPlayer (the primary reload path)", () => {
|
|
36
|
+
expect(
|
|
37
|
+
resolveReloadSeekTime({
|
|
38
|
+
pendingSeek: 7.2,
|
|
39
|
+
requestedSeek: null,
|
|
40
|
+
storeCurrentTime: 7.2,
|
|
41
|
+
duration: 20,
|
|
42
|
+
}),
|
|
43
|
+
).toBe(7.2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("honors a deep-link seek request when no pending seek exists", () => {
|
|
47
|
+
expect(
|
|
48
|
+
resolveReloadSeekTime({
|
|
49
|
+
pendingSeek: null,
|
|
50
|
+
requestedSeek: 12.5,
|
|
51
|
+
storeCurrentTime: 0,
|
|
52
|
+
duration: 20,
|
|
53
|
+
}),
|
|
54
|
+
).toBe(12.5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("THE BUG: a second overlapping reload (pending seek already consumed) restores the store playhead, not 0", () => {
|
|
58
|
+
// Drop → reload #1 consumes pendingSeek and seeks/syncs to 7.2. A staggered
|
|
59
|
+
// second reload (refreshPreviewDocumentVersion 80/300ms bumps) then finds the
|
|
60
|
+
// slot empty — the old code reset the playhead to 0 here.
|
|
61
|
+
expect(
|
|
62
|
+
resolveReloadSeekTime({
|
|
63
|
+
pendingSeek: null,
|
|
64
|
+
requestedSeek: null,
|
|
65
|
+
storeCurrentTime: 7.2,
|
|
66
|
+
duration: 20,
|
|
67
|
+
}),
|
|
68
|
+
).toBe(7.2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("fresh project load starts at 0 (store resets currentTime on project switch)", () => {
|
|
72
|
+
expect(
|
|
73
|
+
resolveReloadSeekTime({
|
|
74
|
+
pendingSeek: null,
|
|
75
|
+
requestedSeek: null,
|
|
76
|
+
storeCurrentTime: 0,
|
|
77
|
+
duration: 20,
|
|
78
|
+
}),
|
|
79
|
+
).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps to duration when content shrank past the playhead (the one sanctioned move)", () => {
|
|
83
|
+
expect(
|
|
84
|
+
resolveReloadSeekTime({
|
|
85
|
+
pendingSeek: 18,
|
|
86
|
+
requestedSeek: null,
|
|
87
|
+
storeCurrentTime: 18,
|
|
88
|
+
duration: 9,
|
|
89
|
+
}),
|
|
90
|
+
).toBe(9);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a pending seek of 0 is an explicit position, not a missing value", () => {
|
|
94
|
+
expect(
|
|
95
|
+
resolveReloadSeekTime({
|
|
96
|
+
pendingSeek: 0,
|
|
97
|
+
requestedSeek: 12,
|
|
98
|
+
storeCurrentTime: 5,
|
|
99
|
+
duration: 20,
|
|
100
|
+
}),
|
|
101
|
+
).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns the guarded target unclamped when the duration is non-finite (no seek(NaN))", () => {
|
|
105
|
+
// Mid-reload the adapter can report a NaN duration; Math.min(target, NaN) would
|
|
106
|
+
// be NaN and seek(NaN). The guarded target must pass through unclamped instead.
|
|
107
|
+
expect(
|
|
108
|
+
resolveReloadSeekTime({
|
|
109
|
+
pendingSeek: 7.2,
|
|
110
|
+
requestedSeek: null,
|
|
111
|
+
storeCurrentTime: 7.2,
|
|
112
|
+
duration: Number.NaN,
|
|
113
|
+
}),
|
|
114
|
+
).toBe(7.2);
|
|
115
|
+
// A zero/negative duration is equally unusable — pass the target through.
|
|
116
|
+
expect(
|
|
117
|
+
resolveReloadSeekTime({
|
|
118
|
+
pendingSeek: 7.2,
|
|
119
|
+
requestedSeek: null,
|
|
120
|
+
storeCurrentTime: 7.2,
|
|
121
|
+
duration: 0,
|
|
122
|
+
}),
|
|
123
|
+
).toBe(7.2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("guards against non-finite and negative targets", () => {
|
|
127
|
+
expect(
|
|
128
|
+
resolveReloadSeekTime({
|
|
129
|
+
pendingSeek: Number.NaN,
|
|
130
|
+
requestedSeek: null,
|
|
131
|
+
storeCurrentTime: 5,
|
|
132
|
+
duration: 20,
|
|
133
|
+
}),
|
|
134
|
+
).toBe(0);
|
|
135
|
+
expect(
|
|
136
|
+
resolveReloadSeekTime({
|
|
137
|
+
pendingSeek: -3,
|
|
138
|
+
requestedSeek: null,
|
|
139
|
+
storeCurrentTime: 5,
|
|
140
|
+
duration: 20,
|
|
141
|
+
}),
|
|
142
|
+
).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("resolveTimelineTotalDuration", () => {
|
|
147
|
+
it("THE BUG: a clip-manifest total shorter than the authored root duration never wins (stale '0:44/0:40')", () => {
|
|
148
|
+
// Fixture shape: root data-duration=44.5, furthest clip ends at 40. A runtime
|
|
149
|
+
// that measures the manifest from the furthest clip end reports 40s; playback
|
|
150
|
+
// still runs the full 44.5s, so the transport total must stay 44.5, not 40.
|
|
151
|
+
expect(
|
|
152
|
+
resolveTimelineTotalDuration({
|
|
153
|
+
manifestDurationSeconds: 40,
|
|
154
|
+
authoredRootDurationSeconds: 44.5,
|
|
155
|
+
}),
|
|
156
|
+
).toBe(44.5);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("lets clips extend the total PAST the authored root (content can grow the timeline)", () => {
|
|
160
|
+
expect(
|
|
161
|
+
resolveTimelineTotalDuration({
|
|
162
|
+
manifestDurationSeconds: 50,
|
|
163
|
+
authoredRootDurationSeconds: 44.5,
|
|
164
|
+
}),
|
|
165
|
+
).toBe(50);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to the manifest total when the root authors no duration", () => {
|
|
169
|
+
expect(
|
|
170
|
+
resolveTimelineTotalDuration({
|
|
171
|
+
manifestDurationSeconds: 40,
|
|
172
|
+
authoredRootDurationSeconds: 0,
|
|
173
|
+
}),
|
|
174
|
+
).toBe(40);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses the authored root when the manifest is loop-inflated / non-finite", () => {
|
|
178
|
+
expect(
|
|
179
|
+
resolveTimelineTotalDuration({
|
|
180
|
+
manifestDurationSeconds: Number.POSITIVE_INFINITY,
|
|
181
|
+
authoredRootDurationSeconds: 44.5,
|
|
182
|
+
}),
|
|
183
|
+
).toBe(44.5);
|
|
184
|
+
expect(
|
|
185
|
+
resolveTimelineTotalDuration({
|
|
186
|
+
manifestDurationSeconds: 9000, // beyond the 7200s sanity cap
|
|
187
|
+
authoredRootDurationSeconds: 44.5,
|
|
188
|
+
}),
|
|
189
|
+
).toBe(44.5);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns 0 when neither source yields a usable duration", () => {
|
|
193
|
+
expect(
|
|
194
|
+
resolveTimelineTotalDuration({
|
|
195
|
+
manifestDurationSeconds: Number.NaN,
|
|
196
|
+
authoredRootDurationSeconds: -1,
|
|
197
|
+
}),
|
|
198
|
+
).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("floors the manifest at the authored root read from the fixture's DOM shape", () => {
|
|
202
|
+
// Reconstruct the fixture: root authored at 44.5s, last clip (v-letters)
|
|
203
|
+
// ends at 34 + 6 = 40s. readTimelineDurationFromDocument must report the
|
|
204
|
+
// authored 44.5, and flooring the 40s manifest total against it yields 44.5.
|
|
205
|
+
const doc = document.implementation.createHTMLDocument("fixture");
|
|
206
|
+
doc.body.innerHTML =
|
|
207
|
+
'<div id="main" data-composition-id="main" data-duration="44.5">' +
|
|
208
|
+
'<video class="clip" data-start="34" data-duration="6"></video>' +
|
|
209
|
+
"</div>";
|
|
210
|
+
const authoredRootDurationSeconds = readTimelineDurationFromDocument(doc);
|
|
211
|
+
expect(authoredRootDurationSeconds).toBe(44.5);
|
|
212
|
+
expect(
|
|
213
|
+
resolveTimelineTotalDuration({
|
|
214
|
+
manifestDurationSeconds: 1200 / 30, // durationInFrames measured from clip end
|
|
215
|
+
authoredRootDurationSeconds,
|
|
216
|
+
}),
|
|
217
|
+
).toBe(44.5);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createImplicitTimelineLayersFromDOM,
|
|
20
20
|
buildStandaloneRootTimelineElement,
|
|
21
21
|
getTimelineElementSelector,
|
|
22
|
+
readTimelineDurationFromDocument,
|
|
22
23
|
} from "../lib/timelineDOM";
|
|
23
24
|
import {
|
|
24
25
|
normalizePreviewViewport,
|
|
@@ -41,6 +42,76 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
41
42
|
applyPreviewAudioState: () => void;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Where should the player seek when the preview (re)loads?
|
|
47
|
+
* Priority: explicit pending seek (saved by refreshPlayer right before a
|
|
48
|
+
* reload) → store-level seek request (deep-link `?t=` hydration) → the store's
|
|
49
|
+
* last known playhead. The last fallback makes the playhead RELOAD-INVARIANT:
|
|
50
|
+
* edits persist + reload the preview, sometimes more than once (App's
|
|
51
|
+
* refreshPreviewDocumentVersion staggers extra bumps at 80/300ms), and the
|
|
52
|
+
* consume-once pendingSeekRef meant any reload after the first found the slot
|
|
53
|
+
* empty and reset the playhead to 0 — the "dropped a file and the playhead
|
|
54
|
+
* jumped to 0" bug. Falling back to the store's playhead means every reload
|
|
55
|
+
* restores position; a fresh project load still starts at 0 because the store
|
|
56
|
+
* resets currentTime on project switch. Invariant: an edit NEVER moves the
|
|
57
|
+
* playhead (the clamp below is the one sanctioned move — content shrank past it).
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
|
|
61
|
+
* Safe to call when the iframe was never hidden (idempotent no-op). Every reload
|
|
62
|
+
* completion + failure path funnels through here so the preview can never get
|
|
63
|
+
* stuck invisible.
|
|
64
|
+
*/
|
|
65
|
+
export function revealIframe(iframe: HTMLIFrameElement | null): void {
|
|
66
|
+
if (iframe && iframe.style.visibility === "hidden") {
|
|
67
|
+
iframe.style.visibility = "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveReloadSeekTime(input: {
|
|
72
|
+
pendingSeek: number | null;
|
|
73
|
+
requestedSeek: number | null;
|
|
74
|
+
storeCurrentTime: number;
|
|
75
|
+
duration: number;
|
|
76
|
+
}): number {
|
|
77
|
+
const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
|
|
78
|
+
if (!Number.isFinite(target) || target <= 0) return 0;
|
|
79
|
+
// Only clamp to duration when it's a usable positive number. A non-finite or
|
|
80
|
+
// non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
|
|
81
|
+
// Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
|
|
82
|
+
// unclamped instead so the playhead lands at the intended position.
|
|
83
|
+
if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
|
|
84
|
+
return Math.min(target, input.duration);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
|
|
88
|
+
function sanitizeDurationSeconds(value: number): number {
|
|
89
|
+
return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The transport TOTAL a clip-manifest message should write to the store.
|
|
94
|
+
*
|
|
95
|
+
* The manifest's `durationInFrames` measures the runtime timeline; some runtimes
|
|
96
|
+
* report only the furthest clip end and ignore the root composition's authored
|
|
97
|
+
* `data-duration`. When that manifest total is SHORTER than the authored root
|
|
98
|
+
* duration, writing it makes the readout stale (playback still runs the full
|
|
99
|
+
* authored window — the user saw "0:44/0:40" on a root authored at 44.5s whose
|
|
100
|
+
* last clip ends at 40s). The authored root duration is the floor for the total,
|
|
101
|
+
* so the readout can never sit below what the file declares. A manifest total
|
|
102
|
+
* that is LONGER (clips extend past the root) still wins — content can only grow
|
|
103
|
+
* the timeline, never shrink it below the authored window.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTimelineTotalDuration(input: {
|
|
106
|
+
manifestDurationSeconds: number;
|
|
107
|
+
authoredRootDurationSeconds: number;
|
|
108
|
+
}): number {
|
|
109
|
+
return Math.max(
|
|
110
|
+
sanitizeDurationSeconds(input.manifestDurationSeconds),
|
|
111
|
+
sanitizeDurationSeconds(input.authoredRootDurationSeconds),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
44
115
|
export function useTimelineSyncCallbacks({
|
|
45
116
|
iframeRef,
|
|
46
117
|
probeIntervalRef,
|
|
@@ -154,8 +225,14 @@ export function useTimelineSyncCallbacks({
|
|
|
154
225
|
const rawDuration = data.durationInFrames / 30;
|
|
155
226
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
156
227
|
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
157
|
-
// explicit data-duration on the root composition.
|
|
158
|
-
|
|
228
|
+
// explicit data-duration on the root composition. Floor the manifest total
|
|
229
|
+
// at the authored root `data-duration` so a runtime that measures only the
|
|
230
|
+
// furthest clip end (shorter than the authored window) can't leave a stale,
|
|
231
|
+
// too-short total in the transport (the "0:44/0:40" bug).
|
|
232
|
+
const newDuration = resolveTimelineTotalDuration({
|
|
233
|
+
manifestDurationSeconds: rawDuration,
|
|
234
|
+
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
|
|
235
|
+
});
|
|
159
236
|
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
|
160
237
|
const clampedEls =
|
|
161
238
|
effectiveDuration > 0
|
|
@@ -218,12 +295,32 @@ export function useTimelineSyncCallbacks({
|
|
|
218
295
|
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
|
219
296
|
// deep-linked `?t=` land instead of starting at 0.
|
|
220
297
|
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
|
221
|
-
const
|
|
298
|
+
const startTime = resolveReloadSeekTime({
|
|
299
|
+
pendingSeek: pendingSeekRef.current,
|
|
300
|
+
requestedSeek: storeSeek,
|
|
301
|
+
storeCurrentTime: usePlayerStore.getState().currentTime,
|
|
302
|
+
duration: adapter.getDuration(),
|
|
303
|
+
});
|
|
222
304
|
pendingSeekRef.current = null;
|
|
223
305
|
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
224
|
-
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
225
306
|
|
|
307
|
+
// Force a REAL render at startTime, not a no-op. After a post-edit reload the
|
|
308
|
+
// freshly rebuilt GSAP timeline can already report being at `startTime`
|
|
309
|
+
// internally (the reload restores the same playhead), so a single
|
|
310
|
+
// `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
|
|
311
|
+
// doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
|
|
312
|
+
// the user nudged the playhead: its element's state was never applied at the
|
|
313
|
+
// restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
|
|
314
|
+
// when startTime is already ~0) guarantees the follow-up seek to `startTime`
|
|
315
|
+
// crosses a time boundary and re-renders every clip — including the new one.
|
|
316
|
+
const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
|
|
317
|
+
adapter.seek(guardTime);
|
|
226
318
|
adapter.seek(startTime);
|
|
319
|
+
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid
|
|
320
|
+
// for the reload, so the user sees the restored frame directly (never the raw
|
|
321
|
+
// all-clips DOM). Cleared unconditionally: any later failure path must not leave
|
|
322
|
+
// the preview stuck invisible.
|
|
323
|
+
revealIframe(iframeRef.current);
|
|
227
324
|
// Keep non-React listeners such as the capture link and time display in sync
|
|
228
325
|
// with the initial adapter seek on iframe load.
|
|
229
326
|
liveTime.notify(startTime);
|
|
@@ -332,6 +429,9 @@ export function useTimelineSyncCallbacks({
|
|
|
332
429
|
trySettle();
|
|
333
430
|
}
|
|
334
431
|
window.removeEventListener("message", onMessage);
|
|
432
|
+
// Never leave the preview stuck invisible if the runtime never settled
|
|
433
|
+
// (initializeAdapter reveals on success; this covers the give-up case).
|
|
434
|
+
revealIframe(iframeRef.current);
|
|
335
435
|
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
336
436
|
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
|
|
337
437
|
|
package/src/player/index.ts
CHANGED
|
@@ -6,11 +6,12 @@ export { VideoThumbnail } from "./components/VideoThumbnail";
|
|
|
6
6
|
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
|
7
7
|
|
|
8
8
|
// Hooks
|
|
9
|
-
export { useTimelinePlayer
|
|
9
|
+
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
|
10
|
+
export { resolveIframe } from "./lib/timelineDOM";
|
|
10
11
|
|
|
11
12
|
// Store
|
|
12
13
|
export { usePlayerStore, liveTime } from "./store/playerStore";
|
|
13
|
-
export type {
|
|
14
|
+
export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
|
|
14
15
|
|
|
15
16
|
// Utils
|
|
16
17
|
export { formatTime } from "./lib/time";
|