@hyperframes/studio 0.6.6 → 0.6.7

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 (55) hide show
  1. package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
  3. package/dist/assets/index-Yvtxngdi.js +116 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +54 -31
  7. package/src/components/StudioGlobalDragOverlay.tsx +26 -0
  8. package/src/components/StudioRightPanel.tsx +0 -2
  9. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  10. package/src/components/editor/DomEditOverlay.tsx +2 -1
  11. package/src/components/editor/PropertyPanel.tsx +27 -36
  12. package/src/components/editor/domEditingElement.ts +1 -0
  13. package/src/components/editor/manualEdits.test.ts +39 -466
  14. package/src/components/editor/manualEdits.ts +6 -168
  15. package/src/components/editor/manualEditsDom.ts +361 -1
  16. package/src/components/editor/manualEditsParsing.ts +2 -240
  17. package/src/components/editor/manualEditsTypes.ts +1 -40
  18. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  19. package/src/components/nle/NLEPreview.tsx +1 -1
  20. package/src/components/sidebar/CompositionsTab.tsx +9 -3
  21. package/src/contexts/DomEditContext.tsx +3 -0
  22. package/src/contexts/FileManagerContext.tsx +3 -0
  23. package/src/hooks/useAppHotkeys.ts +1 -4
  24. package/src/hooks/useDomEditCommits.ts +82 -77
  25. package/src/hooks/useDomEditSession.ts +4 -16
  26. package/src/hooks/useFileManager.ts +10 -1
  27. package/src/hooks/useManifestPersistence.ts +51 -187
  28. package/src/hooks/usePanelLayout.ts +10 -3
  29. package/src/hooks/usePreviewInteraction.ts +0 -1
  30. package/src/hooks/useStudioUrlState.ts +188 -0
  31. package/src/player/components/Player.tsx +15 -1
  32. package/src/player/components/PlayerControls.test.ts +17 -0
  33. package/src/player/components/PlayerControls.tsx +61 -0
  34. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  35. package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
  36. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  37. package/src/player/hooks/useTimelinePlayer.ts +76 -18
  38. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  39. package/src/player/lib/playbackAdapter.test.ts +50 -0
  40. package/src/player/lib/playbackAdapter.ts +2 -2
  41. package/src/player/lib/playbackTypes.ts +1 -1
  42. package/src/player/lib/timelineDOM.ts +4 -2
  43. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  44. package/src/player/store/playerStore.test.ts +105 -1
  45. package/src/player/store/playerStore.ts +12 -1
  46. package/src/utils/projectRouting.test.ts +15 -0
  47. package/src/utils/projectRouting.ts +46 -9
  48. package/src/utils/sourcePatcher.ts +50 -14
  49. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  50. package/src/utils/studioPreviewHelpers.ts +51 -13
  51. package/src/utils/studioUiPreferences.test.ts +3 -0
  52. package/src/utils/studioUiPreferences.ts +4 -0
  53. package/src/utils/studioUrlState.test.ts +249 -0
  54. package/src/utils/studioUrlState.ts +135 -0
  55. package/dist/assets/index-DYqqzECY.js +0 -117
@@ -1,15 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import {
4
- STUDIO_MANUAL_EDITS_PATH,
5
- applyStudioManualEditManifest,
6
- emptyStudioManualEditManifest,
7
4
  installStudioManualEditSeekReapply,
8
- isStudioManualEditManifestPath,
9
- parseStudioManualEditManifest,
5
+ reapplyPositionEditsAfterSeek,
10
6
  readStudioFileChangePath,
11
- serializeStudioManualEditManifest,
12
- type StudioManualEditManifest,
13
7
  } from "../components/editor/manualEdits";
14
8
  import {
15
9
  STUDIO_MOTION_PATH,
@@ -41,6 +35,11 @@ interface UseManifestPersistenceParams {
41
35
  recordEdit: (entry: RecordEditInput) => Promise<void>;
42
36
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
43
37
  activeCompPathRef: React.MutableRefObject<string | null>;
38
+ /** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
39
+ * Used to suppress SSE echoes so we don't double-reload after our own saves. */
40
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
41
+ /** Called to reload the preview after undo/redo or external file changes. */
42
+ reloadPreview: () => void;
44
43
  }
45
44
 
46
45
  // ── Hook ──
@@ -48,21 +47,19 @@ interface UseManifestPersistenceParams {
48
47
  export function useManifestPersistence({
49
48
  projectId,
50
49
  showToast,
51
- readOptionalProjectFile,
50
+ readOptionalProjectFile: _readOptionalProjectFile,
52
51
  writeProjectFile,
53
52
  recordEdit,
54
53
  previewIframeRef,
55
54
  activeCompPathRef,
55
+ domEditSaveTimestampRef,
56
+ reloadPreview,
56
57
  }: UseManifestPersistenceParams) {
57
- const [, setStudioMotionRevision] = useState(0);
58
+ void _readOptionalProjectFile;
58
59
 
59
- const domEditSaveTimestampRef = useRef(0);
60
+ const [, setStudioMotionRevision] = useState(0);
60
61
  const domTextCommitVersionRef = useRef(0);
61
62
  const domEditSaveQueueRef = useRef(Promise.resolve());
62
- const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
63
- emptyStudioManualEditManifest(),
64
- );
65
- const studioManualEditRevisionRef = useRef(0);
66
63
  const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
67
64
  const studioMotionRevisionRef = useRef(0);
68
65
  const applyStudioManualEditsToPreviewRef = useRef<
@@ -77,9 +74,7 @@ export function useManifestPersistence({
77
74
  options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
78
75
  ) => Promise<void>
79
76
  >(async () => {});
80
- const manifestBootstrappedRef = useRef(false);
81
77
  const motionBootstrappedRef = useRef(false);
82
- const studioManualEditProjectRef = useRef<string | null>(projectId);
83
78
 
84
79
  // Keep a ref to the latest projectId so async save callbacks always read the
85
80
  // current value, even when the callback was captured in a stale closure.
@@ -101,7 +96,7 @@ export function useManifestPersistence({
101
96
  await domEditSaveQueueRef.current.catch(() => undefined);
102
97
  }, []);
103
98
 
104
- // ── Apply manual edits ──
99
+ // ── Apply manual edits (HTML-baked — just install seek hooks) ──
105
100
 
106
101
  const applyCurrentStudioManualEditsToPreview = useCallback(
107
102
  (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
@@ -113,68 +108,38 @@ export function useManifestPersistence({
113
108
  return;
114
109
  }
115
110
  if (!doc) return;
116
- const previewDoc = doc;
117
111
 
118
- const applyManifest = () => {
119
- applyStudioManualEditManifest(
120
- previewDoc,
121
- studioManualEditManifestRef.current,
122
- activeCompPathRef.current,
123
- );
124
- };
125
- const applyAndInstallSeekHooks = () => {
126
- applyManifest();
127
- if (iframe.contentWindow) {
128
- installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
112
+ const reapply = () => {
113
+ let d: Document | null = null;
114
+ try {
115
+ d = iframe.contentDocument;
116
+ } catch {
117
+ return;
129
118
  }
119
+ if (d) reapplyPositionEditsAfterSeek(d);
120
+ };
121
+ const install = () => {
122
+ reapply();
123
+ if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
130
124
  };
131
125
 
132
126
  const win = iframe.contentWindow;
133
- applyAndInstallSeekHooks();
134
- win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
135
- win?.setTimeout?.(applyAndInstallSeekHooks, 80);
136
- win?.setTimeout?.(applyAndInstallSeekHooks, 250);
137
- win?.setTimeout?.(applyAndInstallSeekHooks, 500);
138
- win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
139
- win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
127
+ install();
128
+ win?.requestAnimationFrame?.(install);
129
+ win?.setTimeout?.(install, 80);
130
+ win?.setTimeout?.(install, 250);
131
+ win?.setTimeout?.(install, 500);
132
+ win?.setTimeout?.(install, 1000);
133
+ win?.setTimeout?.(install, 2000);
140
134
  },
141
- [activeCompPathRef, previewIframeRef],
135
+ [previewIframeRef],
142
136
  );
143
137
 
144
138
  const applyStudioManualEditsToPreview = useCallback(
145
- async (
146
- iframe: HTMLIFrameElement | null = previewIframeRef.current,
147
- options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
148
- ) => {
149
- // Bootstrap from disk on first apply per session; explicit flag avoids
150
- // re-reading disk after the user deletes all edits (async write race).
151
- const needsBootstrap = !manifestBootstrappedRef.current;
152
- if (needsBootstrap) manifestBootstrappedRef.current = true;
153
- const readFromDiskFirst = Boolean(
154
- options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
155
- );
156
- if (!readFromDiskFirst) {
157
- applyCurrentStudioManualEditsToPreview(iframe);
158
- return;
159
- }
160
- const readRevision = studioManualEditRevisionRef.current;
161
- let content: string;
162
- try {
163
- content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
164
- } catch (error) {
165
- const message =
166
- error instanceof Error ? error.message : "Failed to read manual edit manifest";
167
- showToast(message);
168
- applyCurrentStudioManualEditsToPreview(iframe);
169
- return;
170
- }
171
- if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
172
- studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
173
- if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
174
- }
139
+ async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
175
140
  applyCurrentStudioManualEditsToPreview(iframe);
176
141
  },
177
- [applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
142
+ [applyCurrentStudioManualEditsToPreview, previewIframeRef],
178
143
  );
179
144
  applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
180
145
 
@@ -230,7 +195,7 @@ export function useManifestPersistence({
230
195
  const readRevision = studioMotionRevisionRef.current;
231
196
  let content: string;
232
197
  try {
233
- content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
198
+ content = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
234
199
  } catch (error) {
235
200
  const message = error instanceof Error ? error.message : "Failed to read motion manifest";
236
201
  showToast(message);
@@ -244,80 +209,11 @@ export function useManifestPersistence({
244
209
  }
245
210
  applyCurrentStudioMotionToPreview(iframe);
246
211
  },
247
- [applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
212
+ [applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast],
248
213
  );
249
214
  applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
250
215
 
251
- // ── Optimistic commits ──
252
-
253
- const commitStudioManualEditManifestOptimistically = useCallback(
254
- (
255
- updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
256
- options: { label: string; coalesceKey: string },
257
- ) => {
258
- const previousManifest = studioManualEditManifestRef.current;
259
- const nextManifest = updateManifest(previousManifest);
260
- const previousContent = serializeStudioManualEditManifest(previousManifest);
261
- const nextContent = serializeStudioManualEditManifest(nextManifest);
262
- if (nextContent === previousContent) {
263
- return;
264
- }
265
-
266
- const revision = studioManualEditRevisionRef.current + 1;
267
- studioManualEditRevisionRef.current = revision;
268
- studioManualEditManifestRef.current = nextManifest;
269
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
270
-
271
- const save = async () => {
272
- const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
273
- const diskManifest = parseStudioManualEditManifest(originalContent);
274
- const nextDiskManifest = updateManifest(diskManifest);
275
- const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
276
- if (nextDiskContent === originalContent) {
277
- return;
278
- }
279
-
280
- const pid = projectIdRef.current;
281
- if (!pid) throw new Error("No active project");
282
- domEditSaveTimestampRef.current = Date.now();
283
- await saveProjectFilesWithHistory({
284
- projectId: pid,
285
- label: options.label,
286
- kind: "manual",
287
- coalesceKey: options.coalesceKey,
288
- files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
289
- readFile: async () => originalContent,
290
- writeFile: writeProjectFile,
291
- recordEdit,
292
- });
293
- domEditSaveTimestampRef.current = Date.now();
294
-
295
- if (studioManualEditRevisionRef.current === revision) {
296
- studioManualEditManifestRef.current = nextDiskManifest;
297
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
298
- }
299
- };
300
-
301
- void queueDomEditSave(save).catch((error) => {
302
- if (studioManualEditRevisionRef.current === revision) {
303
- studioManualEditRevisionRef.current += 1;
304
- studioManualEditManifestRef.current = previousManifest;
305
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
306
- }
307
- const message = error instanceof Error ? error.message : "Failed to save manual edit";
308
- showToast(message);
309
- });
310
- },
311
- [
312
- applyCurrentStudioManualEditsToPreview,
313
- recordEdit,
314
- queueDomEditSave,
315
- readOptionalProjectFile,
316
- showToast,
317
- writeProjectFile,
318
- previewIframeRef,
319
- ],
320
- );
216
+ // ── Optimistic motion commit ──
321
217
 
322
218
  const commitStudioMotionManifestOptimistically = useCallback(
323
219
  (
@@ -339,7 +235,7 @@ export function useManifestPersistence({
339
235
  applyCurrentStudioMotionToPreview(previewIframeRef.current);
340
236
 
341
237
  const save = async () => {
342
- const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
238
+ const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
343
239
  const diskManifest = parseStudioMotionManifest(originalContent);
344
240
  const nextDiskManifest = updateManifest(diskManifest);
345
241
  const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
@@ -384,10 +280,11 @@ export function useManifestPersistence({
384
280
  applyCurrentStudioMotionToPreview,
385
281
  recordEdit,
386
282
  queueDomEditSave,
387
- readOptionalProjectFile,
283
+ _readOptionalProjectFile,
388
284
  showToast,
389
285
  writeProjectFile,
390
286
  previewIframeRef,
287
+ domEditSaveTimestampRef,
391
288
  ],
392
289
  );
393
290
 
@@ -396,69 +293,40 @@ export function useManifestPersistence({
396
293
  const syncHistoryPreviewAfterApply = useCallback(
397
294
  async (paths: string[] | undefined) => {
398
295
  const changedPaths = paths ?? [];
399
- const manualManifestOnly =
400
- changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
401
296
  const motionManifestOnly =
402
297
  changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
403
298
 
404
- if (manualManifestOnly) {
405
- await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
406
- return;
407
- }
408
299
  if (motionManifestOnly) {
409
300
  await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
410
301
  return;
411
302
  }
412
303
 
413
- // Reload the iframe in-place rather than recreating the Player component.
414
- // This preserves the <hyperframes-player> web component and its shader
415
- // transition cache — only the iframe document reloads, so transitions that
416
- // weren't touched by the undo/redo don't need to rebuild from scratch.
417
- const iframe = previewIframeRef.current;
418
- if (iframe?.contentWindow) {
419
- try {
420
- iframe.contentWindow.location.reload();
421
- return;
422
- } catch {
423
- // Cross-origin or detached — fall through to full refresh
424
- }
425
- }
304
+ // Reload via refreshKey so NLELayout saves seek position before the iframe reloads.
305
+ reloadPreview();
426
306
  },
427
- [applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
307
+ [applyStudioMotionToPreview, previewIframeRef, reloadPreview],
428
308
  );
429
309
 
430
310
  // ── Reset manifests when project changes ──
431
311
 
312
+ const projectTrackerRef = useRef<string | null>(projectId);
313
+
432
314
  // eslint-disable-next-line no-restricted-syntax
433
315
  useEffect(() => {
434
- const previousProjectId = studioManualEditProjectRef.current;
435
- studioManualEditProjectRef.current = projectId;
316
+ const previousProjectId = projectTrackerRef.current;
317
+ projectTrackerRef.current = projectId;
436
318
  if (!previousProjectId || previousProjectId === projectId) return;
437
- studioManualEditManifestRef.current = emptyStudioManualEditManifest();
438
- studioManualEditRevisionRef.current += 1;
439
319
  studioMotionManifestRef.current = emptyStudioMotionManifest();
440
320
  studioMotionRevisionRef.current += 1;
441
321
  setStudioMotionRevision((revision) => revision + 1);
442
- manifestBootstrappedRef.current = motionBootstrappedRef.current = false;
322
+ motionBootstrappedRef.current = false;
443
323
  }, [projectId]);
444
324
 
445
325
  // ── Listen for external file changes (HMR / SSE) ──
446
- // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
447
- // Suppress file-change events that echo back from a recent DOM edit save —
448
- // those changes are already applied to the iframe DOM and a full reload
449
- // would flash the preview.
450
326
  useMountEffect(() => {
451
327
  const handler = (payload?: unknown) => {
452
328
  const changedPath = readStudioFileChangePath(payload);
453
329
  const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
454
- if (isStudioManualEditManifestPath(changedPath)) {
455
- if (!recentDomEditSave) {
456
- void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
457
- forceFromDisk: true,
458
- });
459
- }
460
- return;
461
- }
462
330
  if (isStudioMotionManifestPath(changedPath)) {
463
331
  if (!recentDomEditSave) {
464
332
  void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
@@ -467,9 +335,10 @@ export function useManifestPersistence({
467
335
  }
468
336
  return;
469
337
  }
470
- // Non-manifest file changes are not handled here the caller is
471
- // responsible for triggering a preview refresh via onExternalFileChange
472
- // if needed. This hook only suppresses echoes and handles manifest reloads.
338
+ // Non-motion external file change reload unless it's an echo of our own save.
339
+ if (!recentDomEditSave) {
340
+ reloadPreview();
341
+ }
473
342
  };
474
343
  if (import.meta.hot) {
475
344
  import.meta.hot.on("hf:file-change", handler);
@@ -482,23 +351,18 @@ export function useManifestPersistence({
482
351
  });
483
352
 
484
353
  return {
485
- domEditSaveTimestampRef,
486
354
  domTextCommitVersionRef,
487
355
  domEditSaveQueueRef,
488
- studioManualEditManifestRef,
489
- studioManualEditRevisionRef,
490
356
  studioMotionManifestRef,
491
357
  studioMotionRevisionRef,
492
358
  applyStudioManualEditsToPreviewRef,
493
359
  applyStudioMotionToPreviewRef,
494
- studioManualEditProjectRef,
495
360
  queueDomEditSave,
496
361
  waitForPendingDomEditSaves,
497
362
  applyCurrentStudioManualEditsToPreview,
498
363
  applyStudioManualEditsToPreview,
499
364
  applyCurrentStudioMotionToPreview,
500
365
  applyStudioMotionToPreview,
501
- commitStudioManualEditManifestOptimistically,
502
366
  commitStudioMotionManifestOptimistically,
503
367
  syncHistoryPreviewAfterApply,
504
368
  };
@@ -2,14 +2,21 @@ import { useState, useCallback, useRef } from "react";
2
2
  import type { RightPanelTab } from "../utils/studioHelpers";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
4
4
 
5
- export function usePanelLayout() {
5
+ export interface InitialPanelLayoutState {
6
+ rightCollapsed?: boolean | null;
7
+ rightPanelTab?: RightPanelTab | null;
8
+ }
9
+
10
+ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
6
11
  const [leftWidth, setLeftWidth] = useState(240);
7
12
  const [rightWidth, setRightWidth] = useState(400);
8
13
  const [leftCollapsed, setLeftCollapsed] = useState(
9
14
  () => readStudioUiPreferences().leftCollapsed ?? false,
10
15
  );
11
- const [rightCollapsed, setRightCollapsed] = useState(true);
12
- const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
16
+ const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true);
17
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
18
+ initialState?.rightPanelTab ?? "renders",
19
+ );
13
20
  const panelDragRef = useRef<{
14
21
  side: "left" | "right";
15
22
  startX: number;
@@ -18,7 +18,6 @@ export interface UsePreviewInteractionParams {
18
18
  captionEditMode: boolean;
19
19
  compositionLoading: boolean;
20
20
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
- activeCompPath: string | null;
22
21
  showToast: (message: string, tone?: "error" | "info") => void;
23
22
 
24
23
  // From useDomSelection
@@ -0,0 +1,188 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { usePlayerStore } from "../player";
3
+ import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
4
+ import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
5
+ import {
6
+ buildStudioHash,
7
+ type StudioUrlSelectionState,
8
+ type StudioUrlState,
9
+ } from "../utils/studioUrlState";
10
+
11
+ interface UseStudioUrlStateParams {
12
+ projectId: string | null;
13
+ activeCompPath: string | null;
14
+ currentTime: number;
15
+ duration: number;
16
+ isPlaying: boolean;
17
+ compositionLoading: boolean;
18
+ refreshKey: number;
19
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
20
+ rightPanelTab: RightPanelTab;
21
+ rightCollapsed: boolean;
22
+ timelineVisible: boolean;
23
+ activeCompPathHydrated: boolean;
24
+ domEditSelection: DomEditSelection | null;
25
+ buildDomSelectionFromTarget: (
26
+ target: HTMLElement,
27
+ options?: { preferClipAncestor?: boolean },
28
+ ) => DomEditSelection | null;
29
+ applyDomSelection: (
30
+ selection: DomEditSelection | null,
31
+ options?: {
32
+ revealPanel?: boolean;
33
+ additive?: boolean;
34
+ preserveGroup?: boolean;
35
+ },
36
+ ) => void;
37
+ initialState: StudioUrlState;
38
+ }
39
+
40
+ function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSelectionState | null {
41
+ if (!selection) return null;
42
+ if (!selection.id && !selection.selector) return null;
43
+ return {
44
+ sourceFile: selection.sourceFile || undefined,
45
+ id: selection.id || undefined,
46
+ selector: selection.selector || undefined,
47
+ selectorIndex: selection.selectorIndex ?? undefined,
48
+ };
49
+ }
50
+
51
+ function replaceHash(nextHash: string) {
52
+ if (typeof window === "undefined") return;
53
+ if (window.location.hash === nextHash) return;
54
+ window.history.replaceState(null, "", nextHash);
55
+ }
56
+
57
+ export function useStudioUrlState({
58
+ projectId,
59
+ activeCompPath,
60
+ currentTime,
61
+ duration,
62
+ isPlaying,
63
+ compositionLoading,
64
+ refreshKey,
65
+ previewIframeRef,
66
+ rightPanelTab,
67
+ rightCollapsed,
68
+ timelineVisible,
69
+ activeCompPathHydrated,
70
+ domEditSelection,
71
+ buildDomSelectionFromTarget,
72
+ applyDomSelection,
73
+ initialState,
74
+ }: UseStudioUrlStateParams) {
75
+ const hydratedSeekRef = useRef(initialState.currentTime == null);
76
+ const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
77
+ const hydratedSelectionRef = useRef(initialState.selection == null);
78
+ const pendingSelectionRef = useRef(initialState.selection);
79
+ const stableTimeRef = useRef<number | null>(initialState.currentTime);
80
+
81
+ const buildUrlState = useCallback(
82
+ (): StudioUrlState => ({
83
+ activeCompPath,
84
+ currentTime: stableTimeRef.current,
85
+ rightPanelTab,
86
+ rightCollapsed,
87
+ timelineVisible,
88
+ selection: hydratedSelectionRef.current
89
+ ? toPersistedSelection(domEditSelection)
90
+ : pendingSelectionRef.current,
91
+ }),
92
+ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
93
+ );
94
+
95
+ useEffect(() => {
96
+ if (!projectId || hydratedSeekRef.current || compositionLoading) return;
97
+ const nextTime =
98
+ duration > 0
99
+ ? clampNumber(initialState.currentTime ?? 0, 0, duration)
100
+ : Math.max(0, initialState.currentTime ?? 0);
101
+ usePlayerStore.getState().requestSeek(nextTime);
102
+ stableTimeRef.current = nextTime;
103
+ hydratedSeekRef.current = true;
104
+ }, [projectId, compositionLoading, duration, initialState.currentTime]);
105
+
106
+ useEffect(() => {
107
+ if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
108
+ if (!hydratedSeekRef.current) return;
109
+ const targetTime = initialState.currentTime;
110
+ if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return;
111
+
112
+ const pendingSelection = pendingSelectionRef.current;
113
+ if (!pendingSelection) {
114
+ hydratedSelectionRef.current = true;
115
+ return;
116
+ }
117
+
118
+ let doc: Document | null = null;
119
+ try {
120
+ doc = previewIframeRef.current?.contentDocument ?? null;
121
+ } catch {
122
+ return;
123
+ }
124
+ if (!doc) return;
125
+
126
+ const element = findElementForSelection(
127
+ doc,
128
+ {
129
+ sourceFile: pendingSelection.sourceFile ?? "",
130
+ id: pendingSelection.id,
131
+ selector: pendingSelection.selector,
132
+ selectorIndex: pendingSelection.selectorIndex,
133
+ },
134
+ activeCompPath,
135
+ );
136
+ if (!element) {
137
+ applyDomSelection(null, { revealPanel: false });
138
+ hydratedSelectionRef.current = true;
139
+ pendingSelectionRef.current = null;
140
+ return;
141
+ }
142
+
143
+ const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
144
+ applyDomSelection(selection, { revealPanel: false });
145
+ hydratedSelectionRef.current = true;
146
+ pendingSelectionRef.current = null;
147
+ }, [
148
+ activeCompPath,
149
+ applyDomSelection,
150
+ buildDomSelectionFromTarget,
151
+ compositionLoading,
152
+ currentTime,
153
+ initialState.currentTime,
154
+ previewIframeRef,
155
+ projectId,
156
+ refreshKey,
157
+ ]);
158
+
159
+ useEffect(() => {
160
+ if (hydratedInitialTimeRef.current) return;
161
+ const targetTime = stableTimeRef.current;
162
+ if (targetTime == null) {
163
+ hydratedInitialTimeRef.current = true;
164
+ return;
165
+ }
166
+ if (Math.abs(currentTime - targetTime) > 0.05) return;
167
+ hydratedInitialTimeRef.current = true;
168
+ }, [currentTime]);
169
+
170
+ useEffect(() => {
171
+ if (!activeCompPathHydrated) return;
172
+ if (!hydratedSeekRef.current) return;
173
+ if (!hydratedInitialTimeRef.current) return;
174
+ if (!projectId || isPlaying) return;
175
+ const handle = window.setTimeout(() => {
176
+ stableTimeRef.current = clampNumber(currentTime, 0, Math.max(0, duration));
177
+ replaceHash(buildStudioHash(projectId, buildUrlState()));
178
+ }, 200);
179
+
180
+ return () => window.clearTimeout(handle);
181
+ }, [activeCompPathHydrated, buildUrlState, currentTime, duration, isPlaying, projectId]);
182
+
183
+ useEffect(() => {
184
+ if (!activeCompPathHydrated) return;
185
+ if (!projectId) return;
186
+ replaceHash(buildStudioHash(projectId, buildUrlState()));
187
+ }, [activeCompPathHydrated, buildUrlState, projectId]);
188
+ }
@@ -163,7 +163,21 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
163
163
  player.style.width = "100%";
164
164
  player.style.height = "100%";
165
165
  player.style.display = "block";
166
+ player.style.background = "transparent";
166
167
  container.appendChild(player);
168
+
169
+ // Inject pasteboard shadow: let the shadow around the canvas bleed
170
+ // into the surrounding pasteboard area (overflow: visible on the container)
171
+ // and add a subtle outline + drop-shadow so the canvas boundary reads
172
+ // against the gray pasteboard, consistent with professional editors.
173
+ if (player.shadowRoot) {
174
+ const pasteboardStyle = document.createElement("style");
175
+ pasteboardStyle.textContent =
176
+ ".hfp-container{overflow:visible}" +
177
+ ".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
178
+ player.shadowRoot.appendChild(pasteboardStyle);
179
+ }
180
+
167
181
  enableInteractiveIframe(player);
168
182
 
169
183
  // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
@@ -309,7 +323,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
309
323
 
310
324
  return (
311
325
  <div
312
- className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
326
+ className="relative w-full h-full max-w-full max-h-full overflow-hidden flex items-center justify-center"
313
327
  style={style}
314
328
  >
315
329
  <div ref={containerRef} className="w-full h-full" />
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { resolveSeekPercent } from "./PlayerControls";
3
+ import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
3
4
 
4
5
  describe("resolveSeekPercent", () => {
5
6
  it("returns 0 when the track width is invalid", () => {
@@ -18,3 +19,19 @@ describe("resolveSeekPercent", () => {
18
19
  expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
19
20
  });
20
21
  });
22
+
23
+ describe("shouldMutePreviewAudio", () => {
24
+ it("mutes when the user toggled audio off", () => {
25
+ expect(shouldMutePreviewAudio(true, 1)).toBe(true);
26
+ });
27
+
28
+ it("auto-mutes above 1x playback", () => {
29
+ expect(shouldMutePreviewAudio(false, 1.5)).toBe(true);
30
+ expect(shouldMutePreviewAudio(false, 2)).toBe(true);
31
+ });
32
+
33
+ it("keeps audio on at 1x or slower when the user has not muted it", () => {
34
+ expect(shouldMutePreviewAudio(false, 1)).toBe(false);
35
+ expect(shouldMutePreviewAudio(false, 0.5)).toBe(false);
36
+ });
37
+ });