@dailies-studio/shot-editor 0.1.0

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 (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,364 @@
1
+ // Clock-synced audio preview for the shot lane. A port of the motion editor's
2
+ // `editor/src/audio/preview.ts`, adapted to `Doc3D` and to this lane's monitor
3
+ // controls; the differences from that file are all noted where they occur.
4
+ //
5
+ // ── THE ENVELOPE IS THE ENGINE'S, NOT A SECOND ONE ──────────────────────
6
+ //
7
+ // `AudioClipRef` is deliberately engine's `AudioClip` field-for-field except
8
+ // for `frame` vs `at` (types.ts says so), so a clip is WIDENED onto that shape
9
+ // and handed to `audioClipWindow` / `audioSourceTimeAt` / `audioEnvelopePoints`
10
+ // unchanged. Those three are the ONE definition of what a clip sounds like at a
11
+ // frame, shared with both export tiers' offline mixes. A shot-flavoured copy of
12
+ // the fade maths is exactly how preview stops matching export, and the F3-A
13
+ // review caught that divergence in the wild once already.
14
+ //
15
+ // There are no gain TRACKS here. Doc3D has blocks, not tracks, and no block
16
+ // writes a clip's gain — so `sampledGain` is never supplied and the envelope
17
+ // falls back to the clip's static gain, which is what the shot exporter mixes.
18
+ //
19
+ // ── TIME MODEL ──────────────────────────────────────────────────────────
20
+ //
21
+ // The chrome's rAF transport is the authority; this anchors frame→audio-time as
22
+ // t(f) = anchorTime + framesToSeconds(f - anchor, fps) per (re)schedule. Both
23
+ // clocks are wall-clock and drift between them is NOT corrected: a reschedule
24
+ // (scrub, doc edit, unmute) re-anchors. Only edges, discontinuities and doc
25
+ // identity changes do work; the per-frame path just records the snapshot.
26
+ //
27
+ // ── AND IT MOUNTS FROM THE CHROME ───────────────────────────────────────
28
+ //
29
+ // Never from the stage. The stage is a GL document behind an iframe that must
30
+ // never remount, and audio is not part of the document: it is neither drawn nor
31
+ // sampled by `ShotRuntime`. Putting a live AudioContext in there would tie the
32
+ // score to the lifetime of a WebGPU adapter.
33
+ //
34
+ // No DOM lib types: every Web Audio surface is a minimal structural *Like type
35
+ // and every effect is injected, so the jsdom tests drive a hand-rolled fake.
36
+ import { audioClipWindow, audioEnvelopePoints, audioSourceTimeAt, framesToSeconds } from "@dailies-studio/engine";
37
+ import type { AudioClip, Rational } from "@dailies-studio/engine";
38
+ import { docDurationFrames } from "@dailies-studio/shot/doc";
39
+ import type { AudioClipRef, Doc3D } from "@dailies-studio/shot/doc";
40
+
41
+ // --- minimal structural Web Audio surface (fakes stay trivial) ---------------
42
+
43
+ export interface AudioParamLike {
44
+ value: number;
45
+ setValueAtTime(value: number, time: number): unknown;
46
+ linearRampToValueAtTime(value: number, time: number): unknown;
47
+ }
48
+
49
+ export interface GainNodeLike {
50
+ readonly gain: AudioParamLike;
51
+ connect(destination: unknown): unknown;
52
+ disconnect(): void;
53
+ }
54
+
55
+ export interface AudioBufferLike {
56
+ readonly duration: number;
57
+ }
58
+
59
+ export interface AudioBufferSourceNodeLike {
60
+ buffer: AudioBufferLike | null;
61
+ connect(destination: unknown): unknown;
62
+ start(when?: number, offset?: number, duration?: number): void;
63
+ stop(when?: number): void;
64
+ disconnect(): void;
65
+ }
66
+
67
+ export interface AudioContextLike {
68
+ readonly currentTime: number;
69
+ readonly destination: unknown;
70
+ createGain(): GainNodeLike;
71
+ createBufferSource(): AudioBufferSourceNodeLike;
72
+ resume?(): unknown;
73
+ close?(): unknown;
74
+ }
75
+
76
+ export interface AudioPreviewDeps {
77
+ /** Called lazily on the first audible play edge — a user gesture, so the
78
+ * real context is born unblocked by autoplay policy. */
79
+ createContext(): AudioContextLike;
80
+ fetchBytes(url: string): Promise<ArrayBuffer>;
81
+ decode(ctx: AudioContextLike, bytes: ArrayBuffer): Promise<AudioBufferLike>;
82
+ /** Audio-clock override for tests; defaults to ctx.currentTime. */
83
+ now?(): number;
84
+ }
85
+
86
+ /** The transport truth the host reads off the store on every change. */
87
+ export interface TransportSnapshot {
88
+ playing: boolean;
89
+ frame: number;
90
+ doc: Doc3D | null;
91
+ }
92
+
93
+ export interface AudioPreview {
94
+ /** Push the current transport snapshot; cheap unless an edge/discontinuity. */
95
+ update(snap: TransportSnapshot): void;
96
+ /** Master preview mute: mute tears the session down, unmute reschedules. */
97
+ setMasterMute(muted: boolean): void;
98
+ /**
99
+ * Monitoring level, 0..1, applied on a MASTER gain node.
100
+ *
101
+ * A live parameter write and NOT a reschedule, which is the whole reason the
102
+ * master node exists: dragging the volume slider during playback must not
103
+ * stop and restart every voice, and re-anchoring the clock on every pixel of
104
+ * a slider drag would make the audio stutter against the picture.
105
+ */
106
+ setMasterVolume(volume: number): void;
107
+ dispose(): void;
108
+ }
109
+
110
+ /** A frame jump beyond this (or any decrease) while playing is a scrub. */
111
+ export const SCRUB_JUMP_FRAMES = 10;
112
+
113
+ type CacheEntry =
114
+ | { status: "pending" }
115
+ | { status: "ready"; buffer: AudioBufferLike }
116
+ | { status: "failed" };
117
+
118
+ interface Voice {
119
+ source: AudioBufferSourceNodeLike;
120
+ gain: GainNodeLike;
121
+ }
122
+
123
+ /** `AudioClipRef` as engine's `AudioClip`: the same fields under the one name
124
+ * that differs, with this lane's absent-means-default filled in. */
125
+ function asEngineClip(clip: AudioClipRef): AudioClip {
126
+ return {
127
+ id: clip.id,
128
+ name: clip.id,
129
+ asset: clip.asset,
130
+ at: clip.frame,
131
+ offsetFrames: clip.offsetFrames ?? 0,
132
+ durationFrames: clip.durationFrames,
133
+ gain: clip.gain ?? 1,
134
+ fadeInFrames: clip.fadeInFrames ?? 0,
135
+ fadeOutFrames: clip.fadeOutFrames ?? 0,
136
+ ...(clip.muted === undefined ? {} : { muted: clip.muted }),
137
+ };
138
+ }
139
+
140
+ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
141
+ let ctx: AudioContextLike | null = null;
142
+ let ctxFailed = false;
143
+ let master: GainNodeLike | null = null;
144
+ let masterMuted = false;
145
+ let masterVolume = 1;
146
+ let disposed = false;
147
+ let last: TransportSnapshot = { playing: false, frame: 0, doc: null };
148
+
149
+ /**
150
+ * AudioBuffer cache by asset PATH. "failed" is terminal: one error, no retries.
151
+ *
152
+ * NOT by asset id. `ShotAsset.id` is a slug of the filename uniquified only
153
+ * against the ids already in ITS OWN document (`freshId(usedIdsOf(doc), …)`),
154
+ * so every shot that imports a `track.wav` mints `as_track`. This map outlives
155
+ * a document switch — `openShot` swaps `state.doc` in place and the bridge
156
+ * that owns this preview never remounts — so an id key serves one shot's
157
+ * audio to another shot with the same filename. `asset.path` is the
158
+ * content-addressed `<hash8>-<name>` the asset store writes, which is unique
159
+ * across every document and identical for identical bytes: keying on it also
160
+ * keeps the cache hit for a genuine re-import of the same file.
161
+ */
162
+ const buffers = new Map<string, CacheEntry>();
163
+
164
+ /** clipId -> live voice. Non-null exactly while an audible session runs —
165
+ * late decodes join it; mute/pause null it. */
166
+ let session: Map<string, Voice> | null = null;
167
+
168
+ function now(): number {
169
+ return deps.now ? deps.now() : ctx!.currentTime;
170
+ }
171
+
172
+ function ensureContext(): boolean {
173
+ if (ctxFailed) return false;
174
+ if (!ctx) {
175
+ try {
176
+ ctx = deps.createContext();
177
+ } catch (e) {
178
+ ctxFailed = true; // e.g. no Web Audio in this environment — stay silent
179
+ console.error("[audio] preview disabled: AudioContext unavailable", e);
180
+ return false;
181
+ }
182
+ // ONE master node for the session's whole life, so the slider writes a
183
+ // parameter rather than rebuilding the graph.
184
+ master = ctx.createGain();
185
+ master.gain.value = masterVolume;
186
+ master.connect(ctx.destination);
187
+ }
188
+ try {
189
+ void ctx.resume?.(); // autoplay policy: nudge on every play gesture
190
+ } catch {
191
+ // resume is best-effort; a suspended context just stays silent
192
+ }
193
+ return true;
194
+ }
195
+
196
+ /** Schedule one clip's voice, anchored at (anchorFrame ↔ anchorTime). */
197
+ function scheduleClip(
198
+ ref: AudioClipRef,
199
+ buffer: AudioBufferLike,
200
+ endFrameExclusive: number,
201
+ fps: Rational,
202
+ anchorFrame: number,
203
+ anchorTime: number,
204
+ ): void {
205
+ const clip = asEngineClip(ref);
206
+ const { start, end } = audioClipWindow(clip);
207
+ const schedEnd = Math.min(end, endFrameExclusive);
208
+ const first = Math.max(start, anchorFrame); // mid-window start advances the offset
209
+ if (first >= schedEnd) return;
210
+ const offset = audioSourceTimeAt(clip, first, fps);
211
+ if (offset >= buffer.duration) return; // trimmed past the source: silence is legal
212
+ const t = (f: number): number => anchorTime + framesToSeconds(f - anchorFrame, fps);
213
+ const c = ctx!;
214
+ const source = c.createBufferSource();
215
+ source.buffer = buffer;
216
+ const gain = c.createGain();
217
+ source.connect(gain);
218
+ gain.connect(master!);
219
+ // The preview===export envelope: the engine's ONE point list, rendered as
220
+ // set-then-ramps; the source stops at the last point's frame.
221
+ const points = audioEnvelopePoints(clip, first, endFrameExclusive);
222
+ if (points.length === 0) return;
223
+ gain.gain.setValueAtTime(points[0]!.gain, t(points[0]!.frame));
224
+ for (const p of points.slice(1)) {
225
+ gain.gain.linearRampToValueAtTime(p.gain, t(p.frame));
226
+ }
227
+ source.start(t(first), offset, framesToSeconds(points.at(-1)!.frame - first, fps));
228
+ session!.set(clip.id, { source, gain });
229
+ }
230
+
231
+ /** Begin an audible session from `fromFrame`: schedule every decoded clip
232
+ * whose window intersects [fromFrame, docDuration); kick decodes for the
233
+ * rest (their resolve handler joins the then-current session). */
234
+ function startSession(fromFrame: number): void {
235
+ const doc = last.doc;
236
+ if (doc === null || masterMuted) return;
237
+ if (!ensureContext()) return;
238
+ session = new Map();
239
+ const anchorTime = now();
240
+ const endFrame = docDurationFrames(doc);
241
+ for (const clip of Object.values(doc.audio)) {
242
+ if (clip.muted === true) continue; // gain would be 0 — skip the node entirely
243
+ const window = audioClipWindow(asEngineClip(clip));
244
+ if (Math.min(window.end, endFrame) <= Math.max(window.start, fromFrame)) continue;
245
+ const asset = doc.assets[clip.asset];
246
+ if (asset === undefined) continue; // validation requires it; a hole stays silent
247
+ const entry = buffers.get(asset.path);
248
+ if (entry?.status === "ready") {
249
+ scheduleClip(clip, entry.buffer, endFrame, doc.fps, fromFrame, anchorTime);
250
+ } else if (entry === undefined) {
251
+ requestDecode(asset.path);
252
+ }
253
+ // pending: scheduled on resolve if still playing; failed: already errored
254
+ }
255
+ }
256
+
257
+ function requestDecode(path: string): void {
258
+ const c = ctx!; // non-null: only called under an ensured context; captured
259
+ // so a dispose() while the fetch is in flight can't null it away.
260
+ buffers.set(path, { status: "pending" });
261
+ void deps
262
+ .fetchBytes(`/studio/assets/${encodeURIComponent(path)}`)
263
+ .then((bytes) => deps.decode(c, bytes))
264
+ .then(
265
+ (buffer) => {
266
+ buffers.set(path, { status: "ready", buffer });
267
+ onBufferReady(path, buffer);
268
+ },
269
+ (e: unknown) => {
270
+ buffers.set(path, { status: "failed" }); // terminal — never retried
271
+ console.error(`[audio] asset ${path} failed to load; its clips stay silent`, e);
272
+ },
273
+ );
274
+ }
275
+
276
+ /** A late decode joins the CURRENT session from the then-current store frame
277
+ * — never the (stale) frame play started at. No session = arrived after
278
+ * pause/mute: schedule nothing.
279
+ *
280
+ * Matched through the then-current document's own asset record, so a decode
281
+ * started in one shot cannot claim the same-named asset of the shot that
282
+ * replaced it while the fetch was in flight. */
283
+ function onBufferReady(path: string, buffer: AudioBufferLike): void {
284
+ if (disposed || session === null || !last.playing) return;
285
+ const doc = last.doc;
286
+ if (doc === null) return;
287
+ const anchorTime = now();
288
+ const endFrame = docDurationFrames(doc);
289
+ for (const clip of Object.values(doc.audio)) {
290
+ if (doc.assets[clip.asset]?.path !== path) continue;
291
+ if (clip.muted === true || session.has(clip.id)) continue;
292
+ scheduleClip(clip, buffer, endFrame, doc.fps, last.frame, anchorTime);
293
+ }
294
+ }
295
+
296
+ function teardown(): void {
297
+ if (session === null) return;
298
+ for (const { source, gain } of session.values()) {
299
+ try {
300
+ source.stop();
301
+ } catch {
302
+ // already ended — stopping is best-effort
303
+ }
304
+ source.disconnect();
305
+ gain.disconnect();
306
+ }
307
+ session = null;
308
+ }
309
+
310
+ return {
311
+ update(snap) {
312
+ if (disposed) return;
313
+ const prev = last;
314
+ last = snap;
315
+ if (!snap.playing) {
316
+ if (prev.playing) teardown();
317
+ return;
318
+ }
319
+ if (!prev.playing) {
320
+ // Play edge — the user-gesture moment; the context is created here.
321
+ startSession(snap.frame);
322
+ return;
323
+ }
324
+ // While playing: reschedule on document identity change (a gain or fade
325
+ // edit audibly applies) or a scrub-jump; the monotone +small frame path
326
+ // (the rAF clock ticking) does nothing but the snapshot record above.
327
+ const scrubbed = snap.frame < prev.frame || snap.frame - prev.frame > SCRUB_JUMP_FRAMES;
328
+ if (snap.doc !== prev.doc || scrubbed) {
329
+ teardown();
330
+ startSession(snap.frame);
331
+ }
332
+ },
333
+
334
+ setMasterMute(muted) {
335
+ if (disposed || muted === masterMuted) return;
336
+ masterMuted = muted;
337
+ if (muted) teardown();
338
+ else if (last.playing) startSession(last.frame);
339
+ },
340
+
341
+ setMasterVolume(volume) {
342
+ const next = Math.max(0, Math.min(1, volume));
343
+ if (disposed || next === masterVolume) return;
344
+ masterVolume = next;
345
+ // No reschedule: see the interface. A context that does not exist yet
346
+ // picks the level up when `ensureContext` builds the master node.
347
+ if (master !== null) master.gain.value = next;
348
+ },
349
+
350
+ dispose() {
351
+ if (disposed) return;
352
+ disposed = true;
353
+ teardown();
354
+ master?.disconnect();
355
+ master = null;
356
+ try {
357
+ void ctx?.close?.();
358
+ } catch {
359
+ // closing is best-effort
360
+ }
361
+ ctx = null;
362
+ },
363
+ };
364
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Chrome side of the stage bridge: the iframe loads /shot-stage.html (served
2
+ // through the PROJECT's Vite so the three stack resolves to the studio's
3
+ // pinned copies), and its `boot()` assigns window.__shotStudio BEFORE first
4
+ // paint. Module boot is async after the iframe's load event, so connecting
5
+ // POLLS for the global — the canvas lane's posture, for the same reason.
6
+ //
7
+ // connectStage also REGISTERS the handlers, because the stage defers its first
8
+ // render until a chrome is connected: there is nothing worth painting before
9
+ // one exists, and a stage that rendered first would show an empty frame that
10
+ // then jumps.
11
+ import { SHOT_BRIDGE_VERSION } from "./bridgeTypes.js";
12
+ import type { ShotBridgeHandlers, ShotStageBridge } from "./bridgeTypes.js";
13
+
14
+ const POLL_MS = 50;
15
+ /** Generous, because the stage's first paint waits on an async WebGPU adapter
16
+ * request that can take a while on a cold profile — and failing early here
17
+ * would look to the author like the editor is broken rather than starting. */
18
+ const DEFAULT_TIMEOUT_MS = 20_000;
19
+
20
+ export async function connectStage(
21
+ iframe: HTMLIFrameElement,
22
+ handlers: ShotBridgeHandlers,
23
+ opts: { timeoutMs?: number; signal?: AbortSignal } = {},
24
+ ): Promise<ShotStageBridge> {
25
+ const deadline = performance.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
26
+ for (;;) {
27
+ if (opts.signal?.aborted) throw new Error("stage connect aborted");
28
+ const bridge = (iframe.contentWindow as (Window & { __shotStudio?: ShotStageBridge }) | null)
29
+ ?.__shotStudio;
30
+ if (bridge) {
31
+ if (bridge.version !== SHOT_BRIDGE_VERSION) {
32
+ throw new Error(
33
+ `stage bridge version ${bridge.version}, chrome needs ${SHOT_BRIDGE_VERSION} — ` +
34
+ `rebuild @dailies-studio/shot-editor`,
35
+ );
36
+ }
37
+ bridge.connect(handlers);
38
+ return bridge;
39
+ }
40
+ if (performance.now() > deadline) {
41
+ throw new Error("stage bridge did not appear (is /shot-stage.html loading?)");
42
+ }
43
+ await new Promise((r) => setTimeout(r, POLL_MS));
44
+ }
45
+ }