@hyperframes/studio 0.8.56 → 0.8.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-zGyOcwwh.js +464 -0
- package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
- package/dist/index.d.ts +124 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15686 -14882
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +48 -6
- package/src/components/nle/NLEPreview.test.ts +67 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +37 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
- package/src/hooks/useSdkSession.ts +77 -14
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +21 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +175 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/player/index.ts +7 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -3,6 +3,8 @@ 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 { useShadowPreviewReload } from "./useShadowPreviewReload";
|
|
7
|
+
import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
|
|
6
8
|
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
|
|
7
9
|
import { logReload } from "../../utils/reloadDebug";
|
|
8
10
|
|
|
@@ -24,18 +26,8 @@ export {
|
|
|
24
26
|
} from "../lib/playbackShortcuts";
|
|
25
27
|
|
|
26
28
|
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
wrapTimeline,
|
|
30
|
-
getDefaultStaticSeekPlaybackClock,
|
|
31
|
-
releaseStaticSeekCache,
|
|
32
|
-
resolveStaticSeekFallback,
|
|
33
|
-
type StaticSeekCacheEntry,
|
|
34
|
-
} from "../lib/playbackAdapter";
|
|
35
|
-
import {
|
|
36
|
-
readTimelineDurationFromDocument,
|
|
37
|
-
mergeTimelineElementsPreservingDowngrades,
|
|
38
|
-
} from "../lib/timelineDOM";
|
|
29
|
+
import { releaseStaticSeekCache, type StaticSeekCacheEntry } from "../lib/playbackAdapter";
|
|
30
|
+
import { mergeTimelineElementsPreservingDowngrades } from "../lib/timelineDOM";
|
|
39
31
|
import { normalizeToZones } from "../components/timelineZones";
|
|
40
32
|
import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
|
41
33
|
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
|
@@ -47,7 +39,17 @@ import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
|
47
39
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
48
40
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
49
41
|
|
|
50
|
-
export
|
|
42
|
+
export interface UseTimelinePlayerOptions {
|
|
43
|
+
/** Runs right after a reloaded preview becomes the live iframe. */
|
|
44
|
+
onShadowPromoted?: () => void;
|
|
45
|
+
/** A reload was abandoned (cause in the message); the previous preview is still showing. */
|
|
46
|
+
onPreviewReloadFailed?: (message: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTimelinePlayer({
|
|
50
|
+
onShadowPromoted,
|
|
51
|
+
onPreviewReloadFailed,
|
|
52
|
+
}: UseTimelinePlayerOptions = {}) {
|
|
51
53
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
52
54
|
const rafRef = useRef<number>(0);
|
|
53
55
|
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
@@ -128,84 +130,23 @@ export function useTimelinePlayer() {
|
|
|
128
130
|
[setElements, requestTimelineReady, setDuration],
|
|
129
131
|
);
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const playerAdapter =
|
|
140
|
-
win.__player && typeof win.__player.play === "function" ? win.__player : null;
|
|
141
|
-
const docDuration = readTimelineDurationFromDocument(iframe.contentDocument);
|
|
142
|
-
const adapterDur = getAdapterDuration(playerAdapter);
|
|
143
|
-
|
|
144
|
-
if (adapterDur > 0 && docDuration <= adapterDur) {
|
|
145
|
-
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
146
|
-
return playerAdapter;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
let timelineAdapter: PlaybackAdapter | null = null;
|
|
150
|
-
if (win.__timeline) {
|
|
151
|
-
const adapter = wrapTimeline(win.__timeline);
|
|
152
|
-
const dur = getAdapterDuration(adapter);
|
|
153
|
-
if (dur > 0 && docDuration <= dur) {
|
|
154
|
-
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
155
|
-
return adapter;
|
|
156
|
-
}
|
|
157
|
-
if (dur > 0) timelineAdapter ??= adapter;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (win.__timelines) {
|
|
161
|
-
const keys = Object.keys(win.__timelines);
|
|
162
|
-
if (keys.length > 0) {
|
|
163
|
-
// Resolve the root composition id from the DOM — the outermost [data-composition-id]
|
|
164
|
-
// is the master; otherwise Object.keys() order lets a sub-composition hijack transport.
|
|
165
|
-
const rootId = iframe?.contentDocument
|
|
166
|
-
?.querySelector("[data-composition-id]")
|
|
167
|
-
?.getAttribute("data-composition-id");
|
|
168
|
-
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
|
|
169
|
-
const adapter = wrapTimeline(win.__timelines[key]);
|
|
170
|
-
const dur = getAdapterDuration(adapter);
|
|
171
|
-
if (dur > 0 && docDuration <= dur) {
|
|
172
|
-
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
173
|
-
return adapter;
|
|
174
|
-
}
|
|
175
|
-
if (dur > 0) timelineAdapter ??= adapter;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// The document timeline extends past every native adapter's duration.
|
|
180
|
-
// Wrap the best available adapter with the effective duration so the
|
|
181
|
-
// seek slider, seek clamping, and duration display cover the full range.
|
|
182
|
-
const bestAdapter = playerAdapter ?? timelineAdapter;
|
|
183
|
-
const effectiveDuration = Math.max(
|
|
184
|
-
usePlayerStore.getState().duration,
|
|
185
|
-
docDuration,
|
|
186
|
-
adapterDur,
|
|
187
|
-
);
|
|
188
|
-
if (
|
|
189
|
-
bestAdapter &&
|
|
190
|
-
effectiveDuration > 0 &&
|
|
191
|
-
("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
|
|
192
|
-
) {
|
|
193
|
-
return resolveStaticSeekFallback({
|
|
133
|
+
const getAdapter = useCallback(
|
|
134
|
+
(overrideIframe?: HTMLIFrameElement | null): PlaybackAdapter | null => {
|
|
135
|
+
try {
|
|
136
|
+
// undefined means "no override"; an explicit null override means "no adapter".
|
|
137
|
+
const iframe = overrideIframe !== undefined ? overrideIframe : iframeRef.current;
|
|
138
|
+
const win = iframe?.contentWindow as IframeWindow | null;
|
|
139
|
+
if (!iframe || !win) return null;
|
|
140
|
+
return resolvePlaybackAdapter(iframe, win, {
|
|
194
141
|
cache: staticSeekAdapterRef,
|
|
195
142
|
warned: staticSeekWarnedRef,
|
|
196
|
-
bestAdapter,
|
|
197
|
-
effectiveDuration,
|
|
198
|
-
docDuration,
|
|
199
|
-
clock: getDefaultStaticSeekPlaybackClock(win),
|
|
200
|
-
getPlaybackRate: () => usePlayerStore.getState().playbackRate,
|
|
201
143
|
});
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
202
146
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
}, []);
|
|
147
|
+
},
|
|
148
|
+
[],
|
|
149
|
+
);
|
|
209
150
|
|
|
210
151
|
const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
|
|
211
152
|
rafRef,
|
|
@@ -435,6 +376,32 @@ export function useTimelinePlayer() {
|
|
|
435
376
|
attachIframeShortcutListeners,
|
|
436
377
|
applyPreviewAudioState,
|
|
437
378
|
});
|
|
379
|
+
|
|
380
|
+
// Full-reload edits load behind a hidden shadow iframe (useShadowPreviewReload.ts).
|
|
381
|
+
const {
|
|
382
|
+
previewSlots,
|
|
383
|
+
onShadowIframeLoad,
|
|
384
|
+
onShadowReadyChange,
|
|
385
|
+
onShadowError,
|
|
386
|
+
setShadowIframeNode,
|
|
387
|
+
beginShadowReload,
|
|
388
|
+
resetPreviewSlots,
|
|
389
|
+
} = useShadowPreviewReload({
|
|
390
|
+
iframeRef,
|
|
391
|
+
getAdapter,
|
|
392
|
+
pendingSeekRef,
|
|
393
|
+
isRefreshingRef,
|
|
394
|
+
syncTimelineElements,
|
|
395
|
+
setDuration,
|
|
396
|
+
setCurrentTime,
|
|
397
|
+
requestTimelineReady,
|
|
398
|
+
setIsPlaying,
|
|
399
|
+
attachIframeShortcutListeners,
|
|
400
|
+
applyPreviewAudioState,
|
|
401
|
+
onPromoted: onShadowPromoted,
|
|
402
|
+
onReloadFailed: onPreviewReloadFailed,
|
|
403
|
+
});
|
|
404
|
+
|
|
438
405
|
const saveSeekPosition = useCallback(() => {
|
|
439
406
|
// Never DEGRADE the saved position. Overlapping reloads (e.g. an external
|
|
440
407
|
// file drop = upload reload + insert reload back-to-back) call this while
|
|
@@ -464,22 +431,15 @@ export function useTimelinePlayer() {
|
|
|
464
431
|
if (!iframe) return;
|
|
465
432
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
466
433
|
saveSeekPosition();
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
//
|
|
470
|
-
// reveals it again right after its restore seek renders the correct frame.
|
|
471
|
-
// Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
|
|
472
|
-
// the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
|
|
473
|
-
// blank is far less jarring than a burst of every asset appearing at once.
|
|
474
|
-
// Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
|
|
475
|
-
// the soft-reload path and never touch refreshPlayer.
|
|
476
|
-
iframe.style.visibility = "hidden";
|
|
434
|
+
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
435
|
+
getAdapter()?.pause();
|
|
436
|
+
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
477
437
|
const src = iframe.src;
|
|
478
438
|
const url = new URL(src, window.location.origin);
|
|
479
439
|
url.searchParams.set("_t", String(Date.now()));
|
|
480
440
|
applyPreviewVariablesToUrl(url);
|
|
481
|
-
|
|
482
|
-
}, [saveSeekPosition]);
|
|
441
|
+
beginShadowReload(url.toString());
|
|
442
|
+
}, [saveSeekPosition, getAdapter, beginShadowReload]);
|
|
483
443
|
const getAdapterRef = useRef(getAdapter);
|
|
484
444
|
getAdapterRef.current = getAdapter;
|
|
485
445
|
|
|
@@ -557,5 +517,11 @@ export function useTimelinePlayer() {
|
|
|
557
517
|
refreshPlayer,
|
|
558
518
|
saveSeekPosition,
|
|
559
519
|
resetPlayer,
|
|
520
|
+
previewSlots,
|
|
521
|
+
onShadowIframeLoad,
|
|
522
|
+
onShadowReadyChange,
|
|
523
|
+
onShadowError,
|
|
524
|
+
setShadowIframeNode,
|
|
525
|
+
resetPreviewSlots,
|
|
560
526
|
};
|
|
561
527
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - onIframeLoad — orchestrates initializeAdapter with a message-based fallback
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useCallback } from "react";
|
|
11
|
+
import { useCallback, useRef } from "react";
|
|
12
12
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
13
13
|
import type { TimelineElement } from "../store/playerStore";
|
|
14
14
|
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
// Re-exported for the tests and callers that have always imported it from here.
|
|
34
34
|
export { resolveReloadSeekTime } from "./timelineSyncHydration";
|
|
35
35
|
|
|
36
|
-
interface UseTimelineSyncCallbacksParams {
|
|
36
|
+
export interface UseTimelineSyncCallbacksParams {
|
|
37
37
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
38
38
|
probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
|
|
39
39
|
pendingSeekRef: React.MutableRefObject<number | null>;
|
|
@@ -46,6 +46,19 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
46
46
|
setIsPlaying: (v: boolean) => void;
|
|
47
47
|
attachIframeShortcutListeners: () => void;
|
|
48
48
|
applyPreviewAudioState: () => void;
|
|
49
|
+
/**
|
|
50
|
+
* Fires once the restore-seek is issued and owns when `commit` (the store hydration) runs.
|
|
51
|
+
* The default reveals the iframe and commits at once; a shadow reload defers it to promotion.
|
|
52
|
+
*/
|
|
53
|
+
onAdapterReady?: (
|
|
54
|
+
iframe: HTMLIFrameElement | null,
|
|
55
|
+
context: number | undefined,
|
|
56
|
+
commit: () => void,
|
|
57
|
+
) => void;
|
|
58
|
+
/** False when the load for `context` was superseded: it must then cause no side effects. */
|
|
59
|
+
isCurrent?: (context?: number) => boolean;
|
|
60
|
+
/** Fires when no adapter appeared within the wait window. */
|
|
61
|
+
onLoadGiveUp?: (context?: number) => void;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/**
|
|
@@ -62,18 +75,54 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
62
75
|
* resets currentTime on project switch. Invariant: an edit NEVER moves the
|
|
63
76
|
* playhead (the clamp below is the one sanctioned move — content shrank past it).
|
|
64
77
|
*/
|
|
65
|
-
/**
|
|
66
|
-
* Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
|
|
67
|
-
* Safe to call when the iframe was never hidden (idempotent no-op). Every reload
|
|
68
|
-
* completion + failure path funnels through here so the preview can never get
|
|
69
|
-
* stuck invisible.
|
|
70
|
-
*/
|
|
78
|
+
/** Undo a hidden `visibility` on an iframe; idempotent no-op otherwise. */
|
|
71
79
|
export function revealIframe(iframe: HTMLIFrameElement | null): void {
|
|
72
80
|
if (iframe && iframe.style.visibility === "hidden") {
|
|
73
81
|
iframe.style.visibility = "";
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
function revealAndCommit(
|
|
86
|
+
iframe: HTMLIFrameElement | null,
|
|
87
|
+
_context: number | undefined,
|
|
88
|
+
commit: () => void,
|
|
89
|
+
): void {
|
|
90
|
+
revealIframe(iframe);
|
|
91
|
+
commit();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type PreviewIframeRole = "live" | "shadow";
|
|
95
|
+
|
|
96
|
+
export interface PreviewIframeSlot {
|
|
97
|
+
gen: number;
|
|
98
|
+
role: PreviewIframeRole;
|
|
99
|
+
url?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Queue a hidden shadow slot for the reload URL, replacing any earlier unpromoted shadow. */
|
|
103
|
+
export function planShadowReload(
|
|
104
|
+
slots: PreviewIframeSlot[],
|
|
105
|
+
nextGen: number,
|
|
106
|
+
url: string,
|
|
107
|
+
): PreviewIframeSlot[] {
|
|
108
|
+
const live = slots.find((slot) => slot.role === "live");
|
|
109
|
+
return live ? [live, { gen: nextGen, role: "shadow", url }] : [{ gen: nextGen, role: "live" }];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Drop any pending shadow; the live slot (and its key) is untouched. */
|
|
113
|
+
export function planShadowDiscard(slots: PreviewIframeSlot[]): PreviewIframeSlot[] {
|
|
114
|
+
return slots.filter((slot) => slot.role === "live");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Atomically make the ready shadow the only (live) slot; a stale readyGen is a no-op. */
|
|
118
|
+
export function planShadowPromotion(
|
|
119
|
+
slots: PreviewIframeSlot[],
|
|
120
|
+
readyGen: number,
|
|
121
|
+
): PreviewIframeSlot[] {
|
|
122
|
+
const ready = slots.find((slot) => slot.gen === readyGen && slot.role === "shadow");
|
|
123
|
+
return ready ? [{ ...ready, role: "live" }] : slots;
|
|
124
|
+
}
|
|
125
|
+
|
|
77
126
|
/**
|
|
78
127
|
* The transport TOTAL a clip-manifest message should write to the store.
|
|
79
128
|
*
|
|
@@ -110,6 +159,9 @@ export function useTimelineSyncCallbacks({
|
|
|
110
159
|
setIsPlaying,
|
|
111
160
|
attachIframeShortcutListeners,
|
|
112
161
|
applyPreviewAudioState,
|
|
162
|
+
onAdapterReady = revealAndCommit,
|
|
163
|
+
isCurrent,
|
|
164
|
+
onLoadGiveUp,
|
|
113
165
|
}: UseTimelineSyncCallbacksParams) {
|
|
114
166
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
115
167
|
const processTimelineMessage = useCallback(
|
|
@@ -183,94 +235,112 @@ export function useTimelineSyncCallbacks({
|
|
|
183
235
|
} catch {}
|
|
184
236
|
}, [iframeRef, syncTimelineElements]);
|
|
185
237
|
|
|
186
|
-
const initializeAdapter = useCallback(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
238
|
+
const initializeAdapter = useCallback(
|
|
239
|
+
(context?: number) => {
|
|
240
|
+
if (isCurrent && !isCurrent(context)) return true;
|
|
241
|
+
const adapter = getAdapter();
|
|
242
|
+
if (!adapter || adapter.getDuration() <= 0) return false;
|
|
243
|
+
|
|
244
|
+
adapter.pause();
|
|
245
|
+
const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
|
|
246
|
+
const commit = () => {
|
|
247
|
+
// Keep non-React listeners such as the capture link and time display in sync
|
|
248
|
+
// with the initial adapter seek on iframe load.
|
|
249
|
+
liveTime.notify(startTime);
|
|
250
|
+
syncAdapterDuration(adapter, setDuration);
|
|
251
|
+
setCurrentTime(startTime);
|
|
252
|
+
if (!isRefreshingRef.current) {
|
|
253
|
+
// Enables Play from actual play-readiness, not just a known duration —
|
|
254
|
+
// a click before this resolves used to start the timeline with media,
|
|
255
|
+
// images or fonts still loading and never recover.
|
|
256
|
+
requestTimelineReady(safeContentDocument(iframeRef.current));
|
|
257
|
+
}
|
|
258
|
+
isRefreshingRef.current = false;
|
|
259
|
+
setIsPlaying(false);
|
|
260
|
+
|
|
261
|
+
hydrateTimelineFromPreview({
|
|
262
|
+
iframe: iframeRef.current,
|
|
263
|
+
adapter,
|
|
264
|
+
processTimelineMessage,
|
|
265
|
+
enrichMissingCompositions,
|
|
266
|
+
applyPreviewAudioState,
|
|
267
|
+
attachIframeShortcutListeners,
|
|
268
|
+
syncTimelineElements,
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
onAdapterReady(iframeRef.current, context, commit);
|
|
272
|
+
return true;
|
|
273
|
+
},
|
|
274
|
+
[
|
|
275
|
+
getAdapter,
|
|
276
|
+
setDuration,
|
|
277
|
+
setCurrentTime,
|
|
278
|
+
requestTimelineReady,
|
|
279
|
+
setIsPlaying,
|
|
214
280
|
processTimelineMessage,
|
|
215
281
|
enrichMissingCompositions,
|
|
216
|
-
applyPreviewAudioState,
|
|
217
|
-
attachIframeShortcutListeners,
|
|
218
282
|
syncTimelineElements,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
enrichMissingCompositions,
|
|
229
|
-
syncTimelineElements,
|
|
230
|
-
attachIframeShortcutListeners,
|
|
231
|
-
applyPreviewAudioState,
|
|
232
|
-
iframeRef,
|
|
233
|
-
isRefreshingRef,
|
|
234
|
-
pendingSeekRef,
|
|
235
|
-
]);
|
|
236
|
-
|
|
237
|
-
const onIframeLoad = useCallback(() => {
|
|
238
|
-
applyPreviewAudioState();
|
|
239
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
283
|
+
attachIframeShortcutListeners,
|
|
284
|
+
applyPreviewAudioState,
|
|
285
|
+
onAdapterReady,
|
|
286
|
+
isCurrent,
|
|
287
|
+
iframeRef,
|
|
288
|
+
isRefreshingRef,
|
|
289
|
+
pendingSeekRef,
|
|
290
|
+
],
|
|
291
|
+
);
|
|
240
292
|
|
|
241
|
-
|
|
242
|
-
|
|
293
|
+
// Drops the previous load's readiness listener so a superseded load cannot settle later.
|
|
294
|
+
const stopWaitingRef = useRef<(() => void) | null>(null);
|
|
243
295
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
296
|
+
const onIframeLoad = useCallback(
|
|
297
|
+
(context?: number) => {
|
|
298
|
+
applyPreviewAudioState();
|
|
299
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
300
|
+
stopWaitingRef.current?.();
|
|
301
|
+
stopWaitingRef.current = null;
|
|
248
302
|
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
if (initializeAdapter()) {
|
|
252
|
-
settled = true;
|
|
253
|
-
window.removeEventListener("message", onMessage);
|
|
254
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
255
|
-
}
|
|
256
|
-
};
|
|
303
|
+
// Fast path: adapter already available (in-place reloads, cached compositions)
|
|
304
|
+
if (initializeAdapter(context)) return;
|
|
257
305
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
306
|
+
// The runtime posts "state" or "timeline" messages once ready.
|
|
307
|
+
// Listen for those instead of polling.
|
|
308
|
+
const iframe = iframeRef.current;
|
|
309
|
+
let settled = false;
|
|
262
310
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
311
|
+
const trySettle = () => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
if (initializeAdapter(context)) {
|
|
314
|
+
settled = true;
|
|
315
|
+
window.removeEventListener("message", onMessage);
|
|
316
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const onMessage = (e: MessageEvent) => {
|
|
321
|
+
if (isPreviewReadinessMessage(e, iframe)) trySettle();
|
|
322
|
+
};
|
|
323
|
+
window.addEventListener("message", onMessage);
|
|
324
|
+
stopWaitingRef.current = () => window.removeEventListener("message", onMessage);
|
|
325
|
+
|
|
326
|
+
// Safety net: if no message arrives within 5s, try one last time then give up.
|
|
327
|
+
probeIntervalRef.current = setTimeout(() => {
|
|
328
|
+
if (!settled) {
|
|
329
|
+
trySettle();
|
|
330
|
+
}
|
|
331
|
+
window.removeEventListener("message", onMessage);
|
|
332
|
+
if (!settled) onLoadGiveUp?.(context);
|
|
333
|
+
revealIframe(iframeRef.current);
|
|
334
|
+
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
335
|
+
},
|
|
336
|
+
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const cancelPendingLoad = useCallback(() => {
|
|
340
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
341
|
+
stopWaitingRef.current?.();
|
|
342
|
+
stopWaitingRef.current = null;
|
|
343
|
+
}, [probeIntervalRef]);
|
|
274
344
|
|
|
275
345
|
// Stable refs so mount-effect closures always call the latest version
|
|
276
346
|
const processTimelineMessageRef = { current: processTimelineMessage };
|
|
@@ -283,5 +353,6 @@ export function useTimelineSyncCallbacks({
|
|
|
283
353
|
enrichMissingCompositionsRef,
|
|
284
354
|
initializeAdapter,
|
|
285
355
|
onIframeLoad,
|
|
356
|
+
cancelPendingLoad,
|
|
286
357
|
};
|
|
287
358
|
}
|
package/src/player/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
|
|
7
7
|
|
|
8
8
|
// Hooks
|
|
9
9
|
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
|
10
|
+
export { usePlayerHandle } from "./hooks/usePlayerHandle";
|
|
11
|
+
export type {
|
|
12
|
+
PlayerHandle,
|
|
13
|
+
PlayerHandleElement,
|
|
14
|
+
PlayerHandleListener,
|
|
15
|
+
PlayerHandleTimeListener,
|
|
16
|
+
} from "./hooks/usePlayerHandle";
|
|
10
17
|
export { resolveIframe } from "./lib/timelineDOM";
|
|
11
18
|
|
|
12
19
|
// Store
|
|
@@ -9,6 +9,7 @@ vi.mock("./client", () => ({
|
|
|
9
9
|
|
|
10
10
|
const {
|
|
11
11
|
trackStudioSessionStart,
|
|
12
|
+
trackPreviewFirstFrame,
|
|
12
13
|
trackStudioRenderStart,
|
|
13
14
|
trackStudioRazorSplit,
|
|
14
15
|
trackStudioExpandedClipEdit,
|
|
@@ -34,6 +35,23 @@ describe("studio telemetry events", () => {
|
|
|
34
35
|
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
it("trackPreviewFirstFrame emits timing, composition duration, and version", () => {
|
|
39
|
+
trackPreviewFirstFrame({
|
|
40
|
+
duration_ms: 842,
|
|
41
|
+
composition_seconds: 12.5,
|
|
42
|
+
clip_count: 8,
|
|
43
|
+
media_clip_count: 5,
|
|
44
|
+
studio_version: "0.8.56",
|
|
45
|
+
});
|
|
46
|
+
expect(trackEvent).toHaveBeenCalledWith("preview_first_frame", {
|
|
47
|
+
duration_ms: 842,
|
|
48
|
+
composition_seconds: 12.5,
|
|
49
|
+
clip_count: 8,
|
|
50
|
+
media_clip_count: 5,
|
|
51
|
+
studio_version: "0.8.56",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
37
55
|
it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
|
|
38
56
|
trackStudioRenderStart({
|
|
39
57
|
fps: 30,
|
package/src/telemetry/events.ts
CHANGED
|
@@ -11,6 +11,16 @@ export function trackStudioSessionStart(props: { has_project: boolean }): void {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function trackPreviewFirstFrame(props: {
|
|
15
|
+
duration_ms: number;
|
|
16
|
+
composition_seconds: number;
|
|
17
|
+
clip_count: number;
|
|
18
|
+
media_clip_count: number;
|
|
19
|
+
studio_version: string;
|
|
20
|
+
}): void {
|
|
21
|
+
trackEvent("preview_first_frame", props);
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export function trackStudioRenderStart(props: {
|
|
15
25
|
fps: number;
|
|
16
26
|
quality: string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// The `studio_*` transport carries browser metadata built here, and this is its
|
|
5
|
+
// only test. tab_id has a module test of its own, but nothing there fails if
|
|
6
|
+
// this file stops attaching it — the two are separate facts.
|
|
7
|
+
describe("browser system meta", () => {
|
|
8
|
+
// Both modules are imported AFTER resetModules so they share one tabId
|
|
9
|
+
// instance; a top-level import would be a different copy of the memo.
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
async function load() {
|
|
15
|
+
const [{ getBrowserSystemMeta }, tab] = await Promise.all([
|
|
16
|
+
import("./system"),
|
|
17
|
+
import("./tabId"),
|
|
18
|
+
]);
|
|
19
|
+
return { getBrowserSystemMeta, resolveTabId: tab.resolveTabId };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it("carries the tab id, from the same source as the studio:* transport", async () => {
|
|
23
|
+
const { getBrowserSystemMeta, resolveTabId } = await load();
|
|
24
|
+
expect(getBrowserSystemMeta().tab_id).toBe(resolveTabId());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps the meta memoized, so one page reports one tab id", async () => {
|
|
28
|
+
const { getBrowserSystemMeta } = await load();
|
|
29
|
+
const first = getBrowserSystemMeta();
|
|
30
|
+
expect(getBrowserSystemMeta()).toBe(first);
|
|
31
|
+
expect(getBrowserSystemMeta().tab_id).toBe(first.tab_id);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("has a tab id in the no-DOM fallback, not an empty string", async () => {
|
|
35
|
+
// The empty-meta branch is taken on SSR. An empty or missing tab_id would
|
|
36
|
+
// read in PostHog as "this event predates the property" rather than "this
|
|
37
|
+
// event has no page", which is the encoding mistake this property exists
|
|
38
|
+
// to avoid. Stubbing the globals is what actually reaches that branch —
|
|
39
|
+
// happy-dom otherwise satisfies the `typeof window` check every time.
|
|
40
|
+
vi.stubGlobal("navigator", undefined);
|
|
41
|
+
vi.stubGlobal("window", undefined);
|
|
42
|
+
const { getBrowserSystemMeta } = await load();
|
|
43
|
+
const meta = getBrowserSystemMeta();
|
|
44
|
+
expect(meta.user_agent).toBe("");
|
|
45
|
+
expect(typeof meta.tab_id).toBe("string");
|
|
46
|
+
expect(meta.tab_id.length).toBeGreaterThan(0);
|
|
47
|
+
vi.unstubAllGlobals();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/telemetry/system.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
7
|
import { agentRuntimeProperty } from "./agentRuntime";
|
|
8
|
+
import { tabIdProperty } from "./tabId";
|
|
8
9
|
|
|
9
10
|
export interface BrowserSystemMeta {
|
|
10
11
|
user_agent: string;
|
|
@@ -17,6 +18,8 @@ export interface BrowserSystemMeta {
|
|
|
17
18
|
studio_version: string;
|
|
18
19
|
/** Which coding agent is driving this Studio, or null when a person is. */
|
|
19
20
|
agent_runtime: string;
|
|
21
|
+
/** One id per page load. distinct_id identifies a browser, not a page. */
|
|
22
|
+
tab_id: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const EMPTY_META: BrowserSystemMeta = {
|
|
@@ -29,6 +32,7 @@ const EMPTY_META: BrowserSystemMeta = {
|
|
|
29
32
|
is_mobile: false,
|
|
30
33
|
studio_version: "dev",
|
|
31
34
|
agent_runtime: "none",
|
|
35
|
+
tab_id: "unknown",
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
let cached: BrowserSystemMeta | null = null;
|
|
@@ -54,6 +58,8 @@ export function getBrowserSystemMeta(): BrowserSystemMeta {
|
|
|
54
58
|
// Same value the `studio:*` transport attaches, from the same accessor —
|
|
55
59
|
// two families that disagreed here would split every agent breakdown.
|
|
56
60
|
agent_runtime: agentRuntimeProperty(),
|
|
61
|
+
// Same shared module as the `studio:*` transport, for the same reason.
|
|
62
|
+
tab_id: tabIdProperty(),
|
|
57
63
|
};
|
|
58
64
|
return cached;
|
|
59
65
|
}
|