@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.
Files changed (91) hide show
  1. package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
  2. package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
  3. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
  6. package/dist/index.d.ts +124 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15686 -14882
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +48 -6
  33. package/src/components/nle/NLEPreview.test.ts +67 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/components/nle/useCompositionStack.ts +1 -1
  39. package/src/hooks/useCompositionDimensions.test.tsx +37 -0
  40. package/src/hooks/useCompositionDimensions.ts +17 -1
  41. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  42. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  43. package/src/hooks/useFileTree.test.tsx +56 -0
  44. package/src/hooks/useFileTree.ts +8 -1
  45. package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
  46. package/src/hooks/useSdkSession.ts +77 -14
  47. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  48. package/src/hooks/useServerConnection.ts +81 -8
  49. package/src/index.ts +21 -1
  50. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  51. package/src/player/components/Player.test.ts +175 -13
  52. package/src/player/components/Player.tsx +58 -2
  53. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  54. package/src/player/components/Timeline.tsx +23 -572
  55. package/src/player/components/TimelineCanvas.tsx +9 -30
  56. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  57. package/src/player/components/TimelineOverlays.test.ts +67 -32
  58. package/src/player/components/TimelineOverlays.tsx +46 -107
  59. package/src/player/components/TimelineProvider.tsx +191 -0
  60. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  62. package/src/player/components/testRootHarness.ts +18 -0
  63. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  64. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  65. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  66. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  67. package/src/player/components/useTimelineProviderState.tsx +583 -0
  68. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  69. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  70. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  71. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  72. package/src/player/hooks/usePlayerHandle.ts +94 -0
  73. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  74. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  75. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  78. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  79. package/src/player/index.ts +7 -0
  80. package/src/telemetry/events.test.ts +18 -0
  81. package/src/telemetry/events.ts +10 -0
  82. package/src/telemetry/system.test.ts +49 -0
  83. package/src/telemetry/system.ts +6 -0
  84. package/src/telemetry/tabId.test.ts +43 -0
  85. package/src/telemetry/tabId.ts +44 -0
  86. package/src/utils/previewCompositionSize.ts +26 -0
  87. package/src/utils/reloadDebug.ts +2 -4
  88. package/src/utils/sourcePatcher.ts +1 -1
  89. package/src/utils/studioTelemetry.test.ts +14 -0
  90. package/src/utils/studioTelemetry.ts +4 -0
  91. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -0,0 +1,98 @@
1
+ // Picks the PlaybackAdapter an iframe's runtime exposes, wrapping it for static seek when needed.
2
+ import type { MutableRefObject } from "react";
3
+ import { usePlayerStore } from "../store/playerStore";
4
+ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
5
+ import {
6
+ getAdapterDuration,
7
+ wrapTimeline,
8
+ getDefaultStaticSeekPlaybackClock,
9
+ releaseStaticSeekCache,
10
+ resolveStaticSeekFallback,
11
+ type StaticSeekCacheEntry,
12
+ } from "../lib/playbackAdapter";
13
+ import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
14
+
15
+ /**
16
+ * Timeline-based adapters (`__timeline`, then `__timelines`): `match` is the first that covers
17
+ * the document duration; otherwise `fallback` is the first with any duration.
18
+ */
19
+ function pickTimelineAdapter(
20
+ iframe: HTMLIFrameElement,
21
+ win: IframeWindow,
22
+ docDuration: number,
23
+ ): { match: PlaybackAdapter | null; fallback: PlaybackAdapter | null } {
24
+ let fallback: PlaybackAdapter | null = null;
25
+ for (const adapter of timelineCandidates(iframe, win)) {
26
+ const dur = getAdapterDuration(adapter);
27
+ if (dur > 0 && docDuration <= dur) return { match: adapter, fallback };
28
+ if (dur > 0) fallback ??= adapter;
29
+ }
30
+ return { match: null, fallback };
31
+ }
32
+
33
+ function* timelineCandidates(
34
+ iframe: HTMLIFrameElement,
35
+ win: IframeWindow,
36
+ ): Generator<PlaybackAdapter> {
37
+ if (win.__timeline) yield wrapTimeline(win.__timeline);
38
+ const keys = win.__timelines ? Object.keys(win.__timelines) : [];
39
+ if (!win.__timelines || keys.length === 0) return;
40
+ // The outermost [data-composition-id] is the master; Object.keys() order would let a
41
+ // sub-composition hijack transport.
42
+ const rootId = iframe.contentDocument
43
+ ?.querySelector("[data-composition-id]")
44
+ ?.getAttribute("data-composition-id");
45
+ const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
46
+ yield wrapTimeline(win.__timelines[key]);
47
+ }
48
+
49
+ interface StaticSeekRefs {
50
+ cache: MutableRefObject<StaticSeekCacheEntry | null>;
51
+ warned: MutableRefObject<boolean>;
52
+ }
53
+
54
+ export function resolvePlaybackAdapter(
55
+ iframe: HTMLIFrameElement,
56
+ win: IframeWindow,
57
+ refs: StaticSeekRefs,
58
+ ): PlaybackAdapter | null {
59
+ const playerAdapter =
60
+ win.__player && typeof win.__player.play === "function" ? win.__player : null;
61
+ const docDuration = readTimelineDurationFromDocument(iframe.contentDocument);
62
+ const adapterDur = getAdapterDuration(playerAdapter);
63
+
64
+ if (adapterDur > 0 && docDuration <= adapterDur) {
65
+ releaseStaticSeekCache(refs.cache, refs.warned);
66
+ return playerAdapter;
67
+ }
68
+
69
+ const timelines = pickTimelineAdapter(iframe, win, docDuration);
70
+ if (timelines.match) {
71
+ releaseStaticSeekCache(refs.cache, refs.warned);
72
+ return timelines.match;
73
+ }
74
+ const timelineAdapter = timelines.fallback;
75
+
76
+ // The document timeline extends past every native adapter's duration.
77
+ // Wrap the best available adapter with the effective duration so the
78
+ // seek slider, seek clamping, and duration display cover the full range.
79
+ const bestAdapter = playerAdapter ?? timelineAdapter;
80
+ const effectiveDuration = Math.max(usePlayerStore.getState().duration, docDuration, adapterDur);
81
+ if (
82
+ bestAdapter &&
83
+ effectiveDuration > 0 &&
84
+ ("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
85
+ ) {
86
+ return resolveStaticSeekFallback({
87
+ cache: refs.cache,
88
+ warned: refs.warned,
89
+ bestAdapter,
90
+ effectiveDuration,
91
+ docDuration,
92
+ clock: getDefaultStaticSeekPlaybackClock(win),
93
+ getPlaybackRate: () => usePlayerStore.getState().playbackRate,
94
+ });
95
+ }
96
+
97
+ return bestAdapter;
98
+ }
@@ -0,0 +1,120 @@
1
+ // Shared test harness: mounts the real useTimelinePlayer and fakes an iframe
2
+ // whose window exposes a playback adapter.
3
+ import React, { act, useEffect } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { vi } from "vitest";
6
+ import { useTimelinePlayer, type UseTimelinePlayerOptions } from "./useTimelinePlayer";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+
9
+ export type TimelinePlayerApi = ReturnType<typeof useTimelinePlayer>;
10
+
11
+ export function resetPlayerStore() {
12
+ usePlayerStore.getState().reset();
13
+ usePlayerStore.setState({ requestedSeekTime: null });
14
+ }
15
+
16
+ function TimelinePlayerHarness({
17
+ onValue,
18
+ options,
19
+ }: {
20
+ onValue: (value: TimelinePlayerApi) => void;
21
+ options?: UseTimelinePlayerOptions;
22
+ }) {
23
+ const value = useTimelinePlayer(options);
24
+ useEffect(() => {
25
+ onValue(value);
26
+ }, [onValue, value]);
27
+ return null;
28
+ }
29
+
30
+ /** `api` is the first render's snapshot; `getApi()` returns the latest render's value. */
31
+ export function renderTimelinePlayerHarness(options?: UseTimelinePlayerOptions) {
32
+ let latest: TimelinePlayerApi | null = null;
33
+ const host = document.createElement("div");
34
+ document.body.append(host);
35
+ const root = createRoot(host);
36
+
37
+ act(() => {
38
+ root.render(
39
+ React.createElement(TimelinePlayerHarness, { onValue: (value) => (latest = value), options }),
40
+ );
41
+ });
42
+
43
+ if (!latest) throw new Error("useTimelinePlayer did not mount");
44
+ const rerender = (next?: UseTimelinePlayerOptions) =>
45
+ act(() => {
46
+ root.render(
47
+ React.createElement(TimelinePlayerHarness, {
48
+ onValue: (value) => (latest = value),
49
+ options: next,
50
+ }),
51
+ );
52
+ });
53
+ return {
54
+ api: latest as TimelinePlayerApi,
55
+ getApi: () => latest as TimelinePlayerApi,
56
+ root,
57
+ rerender,
58
+ };
59
+ }
60
+
61
+ export function makeFakeIframe(iframeWindow: Record<string, unknown>): HTMLIFrameElement {
62
+ const iframe = document.createElement("iframe");
63
+ Object.defineProperty(iframe, "contentWindow", { value: iframeWindow, configurable: true });
64
+ Object.defineProperty(iframe, "contentDocument", {
65
+ value: document.implementation.createHTMLDocument("preview"),
66
+ configurable: true,
67
+ });
68
+ return iframe;
69
+ }
70
+
71
+ export function attachIframeWindow(api: TimelinePlayerApi, iframeWindow: Record<string, unknown>) {
72
+ const iframe = makeFakeIframe(iframeWindow);
73
+ act(() => {
74
+ api.iframeRef.current = iframe;
75
+ api.onIframeLoad();
76
+ });
77
+ }
78
+
79
+ export function makeAdapterWindow(
80
+ options: {
81
+ postMessage?: (message: unknown, targetOrigin: string) => void;
82
+ timelines?: Record<string, unknown>;
83
+ duration?: number;
84
+ } = {},
85
+ ) {
86
+ let currentTime = 0;
87
+ let playing = false;
88
+ const adapter = {
89
+ play: vi.fn(() => {
90
+ playing = true;
91
+ }),
92
+ pause: vi.fn(() => {
93
+ playing = false;
94
+ }),
95
+ seek: (time: number) => {
96
+ currentTime = time;
97
+ },
98
+ getTime: () => currentTime,
99
+ getDuration: () => options.duration ?? 30,
100
+ isPlaying: () => playing,
101
+ };
102
+ const win = {
103
+ __player: adapter,
104
+ __timelines: options.timelines,
105
+ postMessage: options.postMessage ?? (() => {}),
106
+ scrollTo: () => {},
107
+ addEventListener: () => {},
108
+ removeEventListener: () => {},
109
+ };
110
+ return { adapter, win };
111
+ }
112
+
113
+ export function attachIframeAdapter(
114
+ api: TimelinePlayerApi,
115
+ options: Parameters<typeof makeAdapterWindow>[0] = {},
116
+ ) {
117
+ const { adapter, win } = makeAdapterWindow(options);
118
+ attachIframeWindow(api, win);
119
+ return adapter;
120
+ }
@@ -0,0 +1,152 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { useEffect } from "react";
4
+ import { act } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
8
+ import { liveTime, usePlayerStore } from "../store/playerStore";
9
+ import { makeAdapterWindow, makeFakeIframe } from "./timelinePlayerTestHarness";
10
+ import { usePlayerHandle, type PlayerHandle } from "./usePlayerHandle";
11
+
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
13
+
14
+ function renderHandle() {
15
+ let latest: PlayerHandle | null = null;
16
+ let context: NLEContextValue | null = null;
17
+ let renders = 0;
18
+ const host = document.createElement("div");
19
+ document.body.append(host);
20
+ const root = createRoot(host);
21
+ act(() => {
22
+ root.render(
23
+ <NLEProvider projectId="player-handle-test">
24
+ <HandleHarness
25
+ onContext={(value) => (context = value)}
26
+ onValue={(value) => (latest = value)}
27
+ onRender={() => renders++}
28
+ />
29
+ </NLEProvider>,
30
+ );
31
+ });
32
+ if (!latest || !context) throw new Error("usePlayerHandle did not mount");
33
+ return {
34
+ root,
35
+ getHandle: () => latest as PlayerHandle,
36
+ getContext: () => context as NLEContextValue,
37
+ getRenderCount: () => renders,
38
+ };
39
+ }
40
+
41
+ function HandleHarness({
42
+ onContext,
43
+ onValue,
44
+ onRender,
45
+ }: {
46
+ onContext: (value: NLEContextValue) => void;
47
+ onValue: (value: PlayerHandle) => void;
48
+ onRender: () => void;
49
+ }) {
50
+ onRender();
51
+ const context = useNLEContext();
52
+ const handle = usePlayerHandle();
53
+ useEffect(() => {
54
+ onContext(context);
55
+ onValue(handle);
56
+ }, [context, handle, onContext, onValue]);
57
+ return null;
58
+ }
59
+
60
+ afterEach(() => {
61
+ usePlayerStore.getState().reset();
62
+ document.body.innerHTML = "";
63
+ });
64
+
65
+ describe("usePlayerHandle", () => {
66
+ it("refuses seek before readiness and notifies structural changes independently", () => {
67
+ const { root, getHandle } = renderHandle();
68
+ const handle = getHandle();
69
+
70
+ expect(handle.ready).toBe(false);
71
+ expect(handle.seek(4)).toBe(false);
72
+
73
+ const structuralListener = vi.fn();
74
+ const unsubscribe = handle.subscribe(structuralListener);
75
+ act(() => usePlayerStore.setState({ timelineReady: true }));
76
+ expect(structuralListener).toHaveBeenCalledTimes(1);
77
+ act(() => usePlayerStore.setState({ duration: 8 }));
78
+ expect(structuralListener).toHaveBeenCalledTimes(2);
79
+ act(() =>
80
+ usePlayerStore.setState({
81
+ elements: [
82
+ {
83
+ id: "clip",
84
+ tag: "div",
85
+ start: 2,
86
+ duration: 3,
87
+ track: 0,
88
+ selector: "#clip",
89
+ sourceFile: "index.html",
90
+ compositionSrc: "compositions/intro.html",
91
+ },
92
+ ],
93
+ }),
94
+ );
95
+ expect(structuralListener).toHaveBeenCalledTimes(3);
96
+ expect(getHandle().elements).toEqual([
97
+ {
98
+ id: "clip",
99
+ start: 2,
100
+ end: 5,
101
+ selector: "#clip",
102
+ sourceFile: "index.html",
103
+ compositionSrc: "compositions/intro.html",
104
+ },
105
+ ]);
106
+
107
+ unsubscribe();
108
+ act(() => root.unmount());
109
+ });
110
+
111
+ it("uses the shell player to seek a real preview adapter after readiness", async () => {
112
+ const { root, getContext, getHandle } = renderHandle();
113
+ const { adapter, win } = makeAdapterWindow();
114
+ await act(async () => {
115
+ getContext().iframeRef.current = makeFakeIframe(win);
116
+ getContext().onIframeLoad();
117
+ await Promise.resolve();
118
+ });
119
+
120
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
121
+ expect(getHandle().seek(4)).toBe(true);
122
+ expect(adapter.getTime()).toBe(4);
123
+ expect(usePlayerStore.getState().currentTime).toBe(4);
124
+ act(() => getHandle().play());
125
+ expect(getHandle().playing).toBe(true);
126
+
127
+ act(() => root.unmount());
128
+ });
129
+
130
+ it("keeps playback time out of React renders and forwards live time notifications", () => {
131
+ const { root, getHandle, getRenderCount } = renderHandle();
132
+ const initialRenderCount = getRenderCount();
133
+ const timeListener = vi.fn();
134
+ const structuralListener = vi.fn();
135
+ const unsubscribeTime = getHandle().subscribeTime(timeListener);
136
+ const unsubscribe = getHandle().subscribe(structuralListener);
137
+
138
+ act(() => {
139
+ usePlayerStore.setState({ currentTime: 2.5 });
140
+ liveTime.notify(7.25);
141
+ });
142
+
143
+ expect(getRenderCount()).toBe(initialRenderCount);
144
+ expect(getHandle().currentTime).toBe(2.5);
145
+ expect(timeListener).toHaveBeenCalledWith(7.25);
146
+ expect(structuralListener).not.toHaveBeenCalled();
147
+
148
+ unsubscribeTime();
149
+ unsubscribe();
150
+ act(() => root.unmount());
151
+ });
152
+ });
@@ -0,0 +1,94 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { liveTime, usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import { useNLEContext } from "../../components/nle/NLEContext";
4
+
5
+ export interface PlayerHandleElement {
6
+ id: string;
7
+ start: number;
8
+ end: number;
9
+ selector?: string;
10
+ sourceFile?: string;
11
+ compositionSrc?: string;
12
+ }
13
+
14
+ export type PlayerHandleListener = () => void;
15
+ export type PlayerHandleTimeListener = (time: number) => void;
16
+
17
+ export interface PlayerHandle {
18
+ ready: boolean;
19
+ playing: boolean;
20
+ seek: (seconds: number) => boolean;
21
+ play: () => void;
22
+ pause: () => void;
23
+ togglePlay: () => void;
24
+ readonly currentTime: number;
25
+ duration: number;
26
+ elements: PlayerHandleElement[];
27
+ subscribe: (listener: PlayerHandleListener) => () => void;
28
+ subscribeTime: (listener: PlayerHandleTimeListener) => () => void;
29
+ }
30
+
31
+ function toHandleElement(element: TimelineElement): PlayerHandleElement {
32
+ return {
33
+ id: element.id,
34
+ start: element.start,
35
+ end: element.start + element.duration,
36
+ selector: element.selector,
37
+ sourceFile: element.sourceFile,
38
+ compositionSrc: element.compositionSrc,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Reads and controls the shell player. Call this under EditorShell/NLEProvider;
44
+ * the provider owns the mounted preview iframe and the single player instance.
45
+ */
46
+ export function usePlayerHandle(): PlayerHandle {
47
+ const { play, pause, togglePlay, seek: seekPlayer } = useNLEContext();
48
+ const ready = usePlayerStore((state) => state.timelineReady);
49
+ const playing = usePlayerStore((state) => state.isPlaying);
50
+ const duration = usePlayerStore((state) => state.duration);
51
+ const elements = usePlayerStore((state) => state.elements);
52
+
53
+ const seek = useCallback(
54
+ (seconds: number) => {
55
+ if (!ready) return false;
56
+ return seekPlayer(seconds);
57
+ },
58
+ [ready, seekPlayer],
59
+ );
60
+
61
+ const subscribe = useCallback((listener: PlayerHandleListener) => {
62
+ let previous = usePlayerStore.getState();
63
+ return usePlayerStore.subscribe((state) => {
64
+ const structuralChange =
65
+ state.timelineReady !== previous.timelineReady ||
66
+ state.isPlaying !== previous.isPlaying ||
67
+ state.duration !== previous.duration ||
68
+ state.elements !== previous.elements;
69
+ previous = state;
70
+ if (structuralChange) listener();
71
+ });
72
+ }, []);
73
+
74
+ const subscribeTime = useCallback((listener: PlayerHandleTimeListener) => {
75
+ return liveTime.subscribe(listener);
76
+ }, []);
77
+ const handleElements = useMemo(() => elements.map(toHandleElement), [elements]);
78
+
79
+ return {
80
+ ready,
81
+ playing,
82
+ seek,
83
+ play,
84
+ pause,
85
+ togglePlay,
86
+ get currentTime() {
87
+ return usePlayerStore.getState().currentTime;
88
+ },
89
+ duration,
90
+ elements: handleElements,
91
+ subscribe,
92
+ subscribeTime,
93
+ };
94
+ }
@@ -0,0 +1,51 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { parseTimelineFromDOM } from "../lib/timelineDOM";
3
+ import { readTimelineDurationFromDocument } from "../lib/timelineElementHelpers";
4
+ import type { PreviewIframeSlot } from "./useTimelineSyncCallbacks";
5
+ import { trackPreviewFirstFrame } from "../../telemetry/events";
6
+ import { getBrowserSystemMeta } from "../../telemetry/system";
7
+
8
+ export function readPreviewComplexity(doc: Document | null | undefined): {
9
+ clip_count: number;
10
+ media_clip_count: number;
11
+ } {
12
+ if (!doc) return { clip_count: 0, media_clip_count: 0 };
13
+ const elements = parseTimelineFromDOM(doc, readTimelineDurationFromDocument(doc));
14
+ return {
15
+ clip_count: elements.length,
16
+ media_clip_count: elements.filter(({ tag }) => tag === "video" || tag === "audio").length,
17
+ };
18
+ }
19
+
20
+ export function usePreviewFirstFrameTelemetry(previewSlots: PreviewIframeSlot[]) {
21
+ const previewSlotsRef = useRef(previewSlots);
22
+ previewSlotsRef.current = previewSlots;
23
+ const reportedPaintsRef = useRef(new Set<string>());
24
+
25
+ return useCallback(
26
+ (
27
+ slot: PreviewIframeSlot,
28
+ {
29
+ iframe,
30
+ startedAt,
31
+ loadId,
32
+ }: { iframe: HTMLIFrameElement; startedAt: number; loadId: number },
33
+ ) => {
34
+ const currentSlot = previewSlotsRef.current.find(
35
+ (candidate) => candidate.gen === slot.gen && candidate.role === slot.role,
36
+ );
37
+ if (!currentSlot) return;
38
+ const reportKey = `${slot.role}:${slot.gen}:${loadId}`;
39
+ if (reportedPaintsRef.current.has(reportKey)) return;
40
+ reportedPaintsRef.current.add(reportKey);
41
+ const complexity = readPreviewComplexity(iframe.contentDocument);
42
+ trackPreviewFirstFrame({
43
+ duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
44
+ composition_seconds: readTimelineDurationFromDocument(iframe.contentDocument),
45
+ ...complexity,
46
+ studio_version: getBrowserSystemMeta().studio_version,
47
+ });
48
+ },
49
+ [],
50
+ );
51
+ }
@@ -0,0 +1,183 @@
1
+ // Loads a full-reload edit in a hidden shadow iframe and promotes it once painted,
2
+ // so the live iframe never shows a blank frame.
3
+
4
+ import { useCallback, useRef, useState } from "react";
5
+ import { useMountEffect } from "../../hooks/useMountEffect";
6
+ import { logReload } from "../../utils/reloadDebug";
7
+ import {
8
+ useTimelineSyncCallbacks,
9
+ planShadowReload,
10
+ planShadowPromotion,
11
+ planShadowDiscard,
12
+ type PreviewIframeSlot,
13
+ type UseTimelineSyncCallbacksParams,
14
+ } from "./useTimelineSyncCallbacks";
15
+ import type { PlaybackAdapter } from "../lib/playbackTypes";
16
+
17
+ // The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
18
+ // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
19
+ export const SHADOW_READY_TIMEOUT_MS = 15_000;
20
+
21
+ type UseShadowPreviewReloadParams = Omit<
22
+ UseTimelineSyncCallbacksParams,
23
+ "probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
24
+ > & {
25
+ getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null;
26
+ /** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */
27
+ onPromoted?: () => void;
28
+ /** A shadow that never became ready was dropped; the live preview is unchanged. */
29
+ onReloadFailed?: (message: string) => void;
30
+ };
31
+
32
+ export function useShadowPreviewReload({
33
+ iframeRef,
34
+ getAdapter,
35
+ pendingSeekRef,
36
+ isRefreshingRef,
37
+ syncTimelineElements,
38
+ setDuration,
39
+ setCurrentTime,
40
+ requestTimelineReady,
41
+ setIsPlaying,
42
+ attachIframeShortcutListeners,
43
+ applyPreviewAudioState,
44
+ onPromoted,
45
+ onReloadFailed,
46
+ }: UseShadowPreviewReloadParams) {
47
+ const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
48
+ const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
49
+ // Invalidation counter, separate from the live slot's key: bumping it never remounts the live Player.
50
+ const shadowGenRef = useRef(0);
51
+ const pendingCommitRef = useRef<{ gen: number; commit: () => void } | null>(null);
52
+ const visuallyReadyGenRef = useRef<number | null>(null);
53
+ const onPromotedRef = useRef(onPromoted);
54
+ onPromotedRef.current = onPromoted;
55
+ const onReloadFailedRef = useRef(onReloadFailed);
56
+ onReloadFailedRef.current = onReloadFailed;
57
+ const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
58
+ const cancelPendingLoadRef = useRef<() => void>(() => {});
59
+ const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
60
+
61
+ const stopPendingShadow = useCallback(() => {
62
+ clearTimeout(readyTimerRef.current);
63
+ cancelPendingLoadRef.current();
64
+ pendingCommitRef.current = null;
65
+ visuallyReadyGenRef.current = null;
66
+ }, []);
67
+
68
+ const failShadow = useCallback(
69
+ (gen: number, cause: string) => {
70
+ if (gen !== shadowGenRef.current) return;
71
+ shadowGenRef.current += 1;
72
+ stopPendingShadow();
73
+ shadowIframeRef.current = null;
74
+ isRefreshingRef.current = false;
75
+ pendingSeekRef.current = null;
76
+ setPreviewSlots(planShadowDiscard);
77
+ const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
78
+ logReload("shadow-failed", { cause });
79
+ console.error(`[studio] ${message}`);
80
+ onReloadFailedRef.current?.(message);
81
+ },
82
+ [stopPendingShadow, isRefreshingRef, pendingSeekRef],
83
+ );
84
+
85
+ const promoteWhenReady = useCallback(
86
+ (gen: number) => {
87
+ const shadow = shadowIframeRef.current;
88
+ const pending = pendingCommitRef.current;
89
+ const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
90
+ if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
91
+ stopPendingShadow();
92
+ // The store takes the new document's timeline only now that it is the one on screen.
93
+ pending.commit();
94
+ iframeRef.current = shadow;
95
+ shadowIframeRef.current = null;
96
+ attachIframeShortcutListeners();
97
+ applyPreviewAudioState();
98
+ setPreviewSlots((prev) => planShadowPromotion(prev, gen));
99
+ onPromotedRef.current?.();
100
+ },
101
+ [stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
102
+ );
103
+
104
+ const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
105
+ const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
106
+ const markAdapterReady = useCallback(
107
+ (_iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
108
+ if (gen == null || gen !== shadowGenRef.current) return;
109
+ pendingCommitRef.current = { gen, commit };
110
+ promoteWhenReady(gen);
111
+ },
112
+ [promoteWhenReady],
113
+ );
114
+
115
+ const { onIframeLoad: onShadowIframeLoad, cancelPendingLoad } = useTimelineSyncCallbacks({
116
+ iframeRef: shadowIframeRef,
117
+ probeIntervalRef: shadowProbeIntervalRef,
118
+ pendingSeekRef,
119
+ isRefreshingRef,
120
+ getAdapter: getShadowAdapter,
121
+ syncTimelineElements,
122
+ setDuration,
123
+ setCurrentTime,
124
+ requestTimelineReady,
125
+ setIsPlaying,
126
+ // A hidden shadow gets neither: shortcuts and audio state apply once it is promoted.
127
+ attachIframeShortcutListeners: () => {},
128
+ applyPreviewAudioState: () => {},
129
+ onAdapterReady: markAdapterReady,
130
+ isCurrent: isCurrentShadow,
131
+ });
132
+ cancelPendingLoadRef.current = cancelPendingLoad;
133
+
134
+ // The Player reports whether loaders (shader transition, assets) are cleared right now.
135
+ const onShadowReadyChange = useCallback(
136
+ (gen: number, ready: boolean) => {
137
+ if (gen !== shadowGenRef.current) return;
138
+ visuallyReadyGenRef.current = ready ? gen : null;
139
+ if (ready) promoteWhenReady(gen);
140
+ },
141
+ [promoteWhenReady],
142
+ );
143
+
144
+ const setShadowIframeNode = useCallback((node: HTMLIFrameElement | null) => {
145
+ shadowIframeRef.current = node;
146
+ }, []);
147
+
148
+ const beginShadowReload = useCallback(
149
+ (url: string) => {
150
+ shadowGenRef.current += 1;
151
+ const gen = shadowGenRef.current;
152
+ stopPendingShadow();
153
+ readyTimerRef.current = setTimeout(
154
+ () => failShadow(gen, "it took too long to load"),
155
+ SHADOW_READY_TIMEOUT_MS,
156
+ );
157
+ setPreviewSlots((prev) => planShadowReload(prev, gen, url));
158
+ },
159
+ [stopPendingShadow, failShadow],
160
+ );
161
+
162
+ // Composition switch (not an edit reload): drop any in-flight shadow.
163
+ const resetPreviewSlots = useCallback(() => {
164
+ shadowGenRef.current += 1;
165
+ stopPendingShadow();
166
+ shadowIframeRef.current = null;
167
+ isRefreshingRef.current = false;
168
+ pendingSeekRef.current = null;
169
+ setPreviewSlots(planShadowDiscard);
170
+ }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
171
+
172
+ useMountEffect(() => stopPendingShadow);
173
+
174
+ return {
175
+ previewSlots,
176
+ onShadowIframeLoad,
177
+ onShadowReadyChange,
178
+ onShadowError: failShadow,
179
+ setShadowIframeNode,
180
+ beginShadowReload,
181
+ resetPreviewSlots,
182
+ };
183
+ }