@hyperframes/studio 0.8.144 → 0.8.146

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-Bw_jIaSX.js → hyperframes-player-tkxyWf5H.js} +3 -3
  2. package/dist/assets/{index-Cj7W_c7p.js → index-BrdqOkqP.js} +1 -1
  3. package/dist/assets/{index-BVM9NuWl.js → index-Cg48BxAo.js} +145 -145
  4. package/dist/assets/{index-DyH9dT7b.js → index-Dllxg4aU.js} +1 -1
  5. package/dist/{chunk-EEXSYY5F.js → chunk-C5BDT7NG.js} +0 -1
  6. package/dist/{chunk-PEAKCOUW.js → chunk-HZDCMUG4.js} +2 -100
  7. package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
  8. package/dist/{chunk-G3EDKRZH.js → chunk-PHGBG7OG.js} +100 -5
  9. package/dist/{chunk-Y2OHHNM2.js → chunk-PVFBY7KR.js} +0 -1
  10. package/dist/{chunk-3GHTHPZD.js → chunk-T43DM7Q6.js} +101 -3
  11. package/dist/{domEditingLayers-PYST4G45.js → domEditingLayers-SMVA3FEW.js} +3 -4
  12. package/dist/index.css +0 -1
  13. package/dist/index.d.ts +8 -5
  14. package/dist/index.html +1 -1
  15. package/dist/index.js +509 -529
  16. package/dist/player.js +4 -5
  17. package/dist/styles/tailwind-preset.js +1 -2
  18. package/dist/ui.js +3 -4
  19. package/package.json +8 -8
  20. package/src/captions/components/CaptionOverlay.tsx +1 -1
  21. package/src/captions/hooks/useCaptionSync.ts +1 -1
  22. package/src/components/LinkedSelectionToggle.tsx +2 -2
  23. package/src/components/LintModal.tsx +1 -5
  24. package/src/components/MediaPreview.tsx +2 -2
  25. package/src/components/ProjectUnreachableBanner.tsx +1 -1
  26. package/src/components/StudioLeftPanels.tsx +2 -2
  27. package/src/components/StudioRightPanels.tsx +1 -1
  28. package/src/components/TimelineToolbar.test.tsx +2 -2
  29. package/src/components/TimelineToolbar.tsx +6 -6
  30. package/src/components/editor/AnimationCard.tsx +3 -3
  31. package/src/components/editor/AnimationCardParts.tsx +1 -1
  32. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  33. package/src/components/editor/ComputedTweenNotice.tsx +2 -2
  34. package/src/components/editor/FileTree.tsx +1 -1
  35. package/src/components/editor/GsapAnimationList.tsx +1 -1
  36. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  37. package/src/components/editor/LayersPanel.test.ts +4 -4
  38. package/src/components/editor/LayersPanel.tsx +2 -2
  39. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  40. package/src/components/editor/PromotableControl.tsx +1 -1
  41. package/src/components/editor/PropertyPanel.tsx +2 -2
  42. package/src/components/editor/SnapToolbar.tsx +2 -2
  43. package/src/components/editor/TopologyLens.test.tsx +5 -4
  44. package/src/components/editor/Transform3DCube.tsx +1 -1
  45. package/src/components/editor/gsapAnimationConstants.ts +2 -2
  46. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  47. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  48. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  49. package/src/components/editor/propertyPanelFill.tsx +1 -1
  50. package/src/components/editor/propertyPanelFlatMotionSection.tsx +3 -3
  51. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  52. package/src/components/editor/propertyPanelFlatSelectRow.tsx +1 -1
  53. package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
  54. package/src/components/editor/propertyPanelFont.tsx +3 -3
  55. package/src/components/editor/propertyPanelFxBandRuler.tsx +2 -2
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +2 -2
  57. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  58. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  59. package/src/components/editor/useColorGradingController.ts +1 -1
  60. package/src/components/panels/SlideshowSubPanels.tsx +2 -2
  61. package/src/components/panels/VariablesBindElement.tsx +1 -1
  62. package/src/components/panels/VariablesPanel.tsx +3 -3
  63. package/src/components/renders/GpuEncodingToggle.tsx +28 -0
  64. package/src/components/renders/RenderQueue.test.tsx +20 -2
  65. package/src/components/renders/RenderQueue.tsx +15 -3
  66. package/src/components/renders/RenderQueuePanel.tsx +2 -1
  67. package/src/components/renders/renderSettings.ts +5 -2
  68. package/src/components/renders/useRenderQueue.ts +6 -3
  69. package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
  70. package/src/components/sidebar/AssetCard.tsx +2 -2
  71. package/src/components/sidebar/AudioRow.tsx +1 -1
  72. package/src/components/sidebar/BlocksTab.tsx +2 -2
  73. package/src/components/sidebar/CompositionsTab.tsx +2 -2
  74. package/src/components/sidebar/PromptPreviewModal.tsx +1 -1
  75. package/src/hooks/timelineAudioGroupCreate.ts +1 -1
  76. package/src/hooks/timelineEditingHelpers.ts +1 -1
  77. package/src/hooks/useBlockHandlers.ts +1 -1
  78. package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
  79. package/src/hooks/useConsoleErrorCapture.ts +3 -2
  80. package/src/hooks/useDomEditSession.ts +1 -1
  81. package/src/hooks/useEditorSave.ts +1 -1
  82. package/src/hooks/useFrameCapture.ts +1 -1
  83. package/src/hooks/useGestureCommit.ts +4 -4
  84. package/src/hooks/useGsapScriptCommits.ts +1 -1
  85. package/src/index.ts +1 -0
  86. package/src/player/components/AudioGainDialog.test.tsx +1 -1
  87. package/src/player/components/AudioGainDialog.tsx +2 -1
  88. package/src/player/components/EditModal.tsx +2 -2
  89. package/src/player/components/KeyframeDiamondContextMenu.tsx +1 -1
  90. package/src/player/components/OutOfSyncBadge.tsx +1 -1
  91. package/src/player/components/Player.test.ts +44 -0
  92. package/src/player/components/Player.tsx +12 -1
  93. package/src/player/components/ShortcutsPanel.tsx +2 -2
  94. package/src/player/components/Timeline.virtualization.test.tsx +1 -1
  95. package/src/player/components/TimelineAutomationLaneSlot.tsx +1 -1
  96. package/src/player/components/TimelineClip.tsx +1 -1
  97. package/src/player/components/TimelineFxButton.tsx +2 -2
  98. package/src/player/components/TimelineTrackHeader.test.tsx +3 -3
  99. package/src/player/components/timelineClipDragCommit.ts +2 -2
  100. package/src/player/components/trackHeaderLabelRows.tsx +1 -1
  101. package/src/player/lib/playbackAdapter.ts +1 -1
  102. package/src/player/lib/playbackTypes.ts +1 -0
  103. package/src/player/sceneSwap.test.ts +27 -1
  104. package/src/player/sceneSwap.ts +16 -2
  105. package/src/utils/hmrStableContext.ts +1 -1
  106. package/src/utils/razorSplitTransaction.ts +0 -2
  107. package/src/utils/sdkCutover.linkedTiming.test.ts +1 -1
  108. package/dist/chunk-3GHTHPZD.js.map +0 -1
  109. package/dist/chunk-EEXSYY5F.js.map +0 -1
  110. package/dist/chunk-G3EDKRZH.js.map +0 -1
  111. package/dist/chunk-PEAKCOUW.js.map +0 -1
  112. package/dist/chunk-PZ5AY32C.js.map +0 -1
  113. package/dist/chunk-Y2OHHNM2.js.map +0 -1
  114. package/dist/domEditingLayers-PYST4G45.js.map +0 -1
  115. package/dist/index.css.map +0 -1
  116. package/dist/index.js.map +0 -1
  117. package/dist/player.js.map +0 -1
  118. package/dist/styles/tailwind-preset.js.map +0 -1
  119. package/dist/ui.js.map +0 -1
@@ -182,11 +182,11 @@ export function useGestureCommit({
182
182
  frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0;
183
183
 
184
184
  if (frozenSamples.length <= 2) {
185
- showToast("No gesture detected — move the pointer while recording", "error");
185
+ showToast("No gesture detected. Move the pointer while recording", "error");
186
186
  return;
187
187
  }
188
188
  if (duration <= 0) {
189
- showToast("Recording too short — try again", "error");
189
+ showToast("Recording too short. Try again", "error");
190
190
  return;
191
191
  }
192
192
 
@@ -213,7 +213,7 @@ export function useGestureCommit({
213
213
  // would record the gesture onto every sibling sharing it.
214
214
  const selector = sel.id ? idSelector(sel.id) : sel.selector;
215
215
  if (!selector) {
216
- showToast("Cannot save — element has no selector", "error");
216
+ showToast("Cannot save: element has no selector", "error");
217
217
  return;
218
218
  }
219
219
  // A recorded gesture becomes a NEW tween, so its target must address one
@@ -412,7 +412,7 @@ export function useGestureCommit({
412
412
  }
413
413
  const iframe = previewIframeRef.current;
414
414
  if (!iframe) {
415
- showToast("Preview not ready — try again", "error");
415
+ showToast("Preview not ready. Try again", "error");
416
416
  return;
417
417
  }
418
418
 
@@ -81,7 +81,7 @@ async function runMutationRequest(
81
81
  ): Promise<MutationResult | undefined> {
82
82
  const unsafeFields = mutations.flatMap((mutation) => findUnsafeMutationValues(mutation));
83
83
  if (unsafeFields.length > 0) {
84
- showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
84
+ showToast?.("Couldn't read element layout. Try again at a different playhead time", "error");
85
85
  if (options.skipReload) return;
86
86
  throw markStudioSaveErrorAlreadyToasted(
87
87
  new Error(
package/src/index.ts CHANGED
@@ -202,6 +202,7 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
202
202
  export { useDomEditSession } from "./hooks/useDomEditSession";
203
203
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
204
204
  export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
205
+ export { announcePreviewDocumentLoaded } from "./player/sceneSwap";
205
206
  export type { PreviewPromotion } from "./player/sceneSwap";
206
207
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
207
208
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
@@ -126,7 +126,7 @@ describe("AudioGainDialog", () => {
126
126
  it("disables the peak options when the clip cannot be measured", async () => {
127
127
  stubPeaks({});
128
128
  open([clip("unmeasured")]);
129
- await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —"));
129
+ await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: Unavailable"));
130
130
  expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true);
131
131
  expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false);
132
132
  });
@@ -185,7 +185,8 @@ export function AudioGainDialog({
185
185
  onSelect={() => setMode("loudness")}
186
186
  />
187
187
  <div className="mt-3 text-neutral-400" data-testid="audio-gain-peak">
188
- Peak Amplitude: {peaks === null ? "measuring…" : peakDb === null ? "—" : dbText(peakDb)}
188
+ Peak Amplitude:{" "}
189
+ {peaks === null ? "measuring…" : peakDb === null ? "Unavailable" : dbText(peakDb)}
189
190
  </div>
190
191
  {outOfRange && <div className="mt-1 text-red-400">Peak targets must be 0 dB or lower.</div>}
191
192
  <div className="mt-4 flex justify-end gap-2">
@@ -134,7 +134,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
134
134
  <div className="flex items-center gap-2">
135
135
  <div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
136
136
  <span className="text-[11px] font-medium text-neutral-300">
137
- {formatTime(start)} — {formatTime(end)}
137
+ {formatTime(start)} to {formatTime(end)}
138
138
  </span>
139
139
  </div>
140
140
  <span className="text-[10px] text-neutral-600">
@@ -175,7 +175,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
175
175
  {/* Action */}
176
176
  {copyError && (
177
177
  <p className="px-3 pb-2 text-[10px] text-danger-ink" role="alert">
178
- Copy failed — check clipboard permissions and try again.
178
+ Copy failed. Check clipboard permissions and try again.
179
179
  </p>
180
180
  )}
181
181
  <div className="grid grid-cols-2 gap-2 px-3 pb-3">
@@ -142,7 +142,7 @@ export function KeyframeDiamondContextMenu({
142
142
  {copyStatus === "copied"
143
143
  ? "Copied!"
144
144
  : copyStatus === "failed"
145
- ? "Copy failed — check permissions"
145
+ ? "Copy failed. Check permissions"
146
146
  : "Copy Properties"}
147
147
  </button>
148
148
  )}
@@ -103,7 +103,7 @@ export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: Timelin
103
103
  tabIndex={-1}
104
104
  aria-haspopup="menu"
105
105
  aria-label={`Out of sync with ${partnerName} by ${label} frames`}
106
- title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
106
+ title={`Out of sync with ${partnerName} by ${label}. Click for Move or Slip into Sync`}
107
107
  data-testid="out-of-sync-badge"
108
108
  className="absolute bottom-0.5 left-1 z-[12] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
109
109
  onPointerDown={stop}
@@ -10,6 +10,7 @@ import {
10
10
  shouldShowCompositionLoadingOverlay,
11
11
  } from "./Player";
12
12
  import { usePlayerStore } from "../store/playerStore";
13
+ import { onPreviewDocumentLoaded } from "../sceneSwap";
13
14
 
14
15
  vi.mock("@hyperframes/player", () => ({}));
15
16
 
@@ -196,6 +197,49 @@ describe("callbacks after the player is already mounted", () => {
196
197
  expect(first).not.toHaveBeenCalled();
197
198
  });
198
199
 
200
+ it("loads the preview on the runtime's ready while a stalled font holds the window's load", async () => {
201
+ const onLoad = vi.fn();
202
+ const { player } = await mountPlayer({ onLoad });
203
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
204
+ let page = {};
205
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
206
+ Object.defineProperty(iframe, "contentWindow", { get: () => ({ __playerReady: true }) });
207
+ const announced = vi.fn();
208
+ onPreviewDocumentLoaded(iframe, announced);
209
+
210
+ act(() => void player.dispatchEvent(new Event("ready")));
211
+ expect(onLoad).toHaveBeenCalledTimes(1);
212
+
213
+ act(() => void iframe.dispatchEvent(new Event("load")));
214
+ act(() => void player.dispatchEvent(new Event("ready")));
215
+ expect(onLoad).toHaveBeenCalledTimes(1);
216
+
217
+ page = {};
218
+ act(() => void player.dispatchEvent(new Event("ready")));
219
+ expect(onLoad).toHaveBeenCalledTimes(2);
220
+ expect(announced).toHaveBeenCalledTimes(2);
221
+ });
222
+
223
+ it("leaves a ready that arrives before the page's runtime boots to the page's own load", async () => {
224
+ const onLoad = vi.fn();
225
+ const { player } = await mountPlayer({ onLoad });
226
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
227
+ const page = {};
228
+ let runtime: { __playerReady?: boolean } = {};
229
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
230
+ Object.defineProperty(iframe, "contentWindow", { get: () => runtime });
231
+
232
+ act(() => void player.dispatchEvent(new Event("ready")));
233
+ expect(onLoad).not.toHaveBeenCalled();
234
+
235
+ act(() => void iframe.dispatchEvent(new Event("load")));
236
+ expect(onLoad).toHaveBeenCalledTimes(1);
237
+
238
+ runtime = { __playerReady: true };
239
+ act(() => void player.dispatchEvent(new Event("ready")));
240
+ expect(onLoad).toHaveBeenCalledTimes(1);
241
+ });
242
+
199
243
  it("reports the preview error cause", async () => {
200
244
  const onPreviewError = vi.fn();
201
245
  const { player } = await mountPlayer({ onPreviewError });
@@ -5,6 +5,8 @@ import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
6
6
  import { HyperframesLoader } from "../../components/ui";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import { announcePreviewDocumentLoaded } from "../sceneSwap";
9
+ import type { IframeWindow } from "../lib/playbackTypes";
8
10
  // Importing "@hyperframes/player" registers a class extending HTMLElement at
9
11
  // module load, which throws under SSR, hence the dynamic import behind a
10
12
  // `typeof window` guard. Kicking it here rather than in the mount effect puts
@@ -128,6 +130,9 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
128
130
  }
129
131
  }
130
132
 
133
+ const runtimeBootedIn = (iframe: HTMLIFrameElement) =>
134
+ (iframe.contentWindow as IframeWindow | null)?.__playerReady === true;
135
+
131
136
  /**
132
137
  * Renders a composition preview using the <hyperframes-player> web component.
133
138
  *
@@ -228,9 +233,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
228
233
  const loading = getShaderTransitionLoading(event);
229
234
  if (loading !== null) setShaderTransitionLoading(loading);
230
235
  };
236
+ let loadedDocument: Document | null = null;
231
237
  const handleReady = () => {
232
238
  setPreviewError(null);
233
239
  setCompositionLoading(false);
240
+ if (iframe.contentDocument && runtimeBootedIn(iframe)) handleLoad();
234
241
  };
235
242
  const handlePainted = () => {
236
243
  setPainted(true);
@@ -243,6 +250,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
243
250
  setCompositionLoading(false);
244
251
  };
245
252
  const handleLoad = () => {
253
+ const doc = iframe.contentDocument;
254
+ if (doc && doc === loadedDocument) return;
255
+ loadedDocument = doc;
246
256
  loadCountRef.current++;
247
257
  setLoaded(true);
248
258
  setPreviewError(null);
@@ -258,6 +268,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
258
268
  container.addEventListener("animationend", onEnd, { once: true });
259
269
  }
260
270
  onLoadRef.current();
271
+ announcePreviewDocumentLoaded(iframe);
261
272
 
262
273
  // Show a loading overlay until every `<video>`/`<audio>` and Lottie
263
274
  // asset is ready. Without this users can click play before audio has
@@ -287,7 +298,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
287
298
  if (!lastUnloaded || attempts > 100) {
288
299
  if (lastUnloaded && attempts > 100) {
289
300
  console.debug(
290
- "[studio] asset readiness poll hit the 10s cap — continuing with unloaded assets",
301
+ "[studio] asset readiness poll hit the 10s cap, continuing with unloaded assets",
291
302
  );
292
303
  }
293
304
  if (assetPollRef.current) clearInterval(assetPollRef.current);
@@ -182,7 +182,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
182
182
  </Tooltip>
183
183
  </>
184
184
  ) : (
185
- <span className="text-[10px] text-neutral-600">—</span>
185
+ <span className="text-[10px] text-neutral-600">Not set</span>
186
186
  )}
187
187
  </div>
188
188
  </div>
@@ -220,7 +220,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
220
220
  </Tooltip>
221
221
  </>
222
222
  ) : (
223
- <span className="text-[10px] text-neutral-600">—</span>
223
+ <span className="text-[10px] text-neutral-600">Not set</span>
224
224
  )}
225
225
  </div>
226
226
  </div>
@@ -167,7 +167,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
167
167
  const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
168
168
  expect(scroller).not.toBeNull();
169
169
  expect(clip).not.toBeNull();
170
- expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
170
+ expect(clip?.title).toBe("Clip 0 • 0.0s to 10.0s");
171
171
  const picture = host.querySelector("[data-rich-content]");
172
172
  expect(picture).not.toBeNull();
173
173
 
@@ -110,7 +110,7 @@ function ClipAutomationLanes({
110
110
  // — because "not editable" without that reads as broken.
111
111
  readOnlyNote={
112
112
  isCarveLane(lane.target, bound.chain)
113
- ? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
113
+ ? "Owned by the voiceover carve and re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
114
114
  : bound.readOnly
115
115
  ? "Read-only here."
116
116
  : undefined
@@ -170,7 +170,7 @@ export const TimelineClip = memo(function TimelineClip({
170
170
  title={
171
171
  isComposition
172
172
  ? `${el.compositionSrc} • Double-click to open`
173
- : `${displayLabel} • ${startLabel}s – ${endLabel}s`
173
+ : `${displayLabel} • ${startLabel}s to ${endLabel}s`
174
174
  }
175
175
  onPointerEnter={onHoverStart}
176
176
  onPointerLeave={onHoverEnd}
@@ -213,7 +213,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
213
213
  type="button"
214
214
  tabIndex={-1}
215
215
  ref={buttonRef}
216
- aria-label="Effects — group these clips first"
216
+ aria-label="Effects: group these clips first"
217
217
  title="Group these clips to add effects to all of them"
218
218
  className="flex h-6 items-center justify-center rounded-sm border-0 bg-transparent px-1 text-[10px] font-semibold text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
219
219
  onPointerDown={(event) => event.stopPropagation()}
@@ -253,7 +253,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
253
253
  type="button"
254
254
  tabIndex={-1}
255
255
  ref={buttonRef}
256
- aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
256
+ aria-label={nodeCount > 0 ? `Effects: ${nodeCount} applied` : "Effects"}
257
257
  title="Effects"
258
258
  className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
259
259
  open || nodeCount > 0
@@ -851,7 +851,7 @@ describe("TimelineTrackHeader", () => {
851
851
  isAudioTrack: true,
852
852
  };
853
853
  const pointer = (host: HTMLElement) =>
854
- host.querySelector('button[aria-label="Effects — group these clips first"]');
854
+ host.querySelector('button[aria-label="Effects: group these clips first"]');
855
855
  const view = renderHeader(opts);
856
856
  expect(pointer(view.host)).not.toBeNull();
857
857
  // One clip needs no grouping — the real FX button takes its place.
@@ -886,7 +886,7 @@ describe("TimelineTrackHeader", () => {
886
886
  };
887
887
  const pointer = (host: HTMLElement) =>
888
888
  host.querySelector<HTMLButtonElement>(
889
- 'button[aria-label="Effects — group these clips first"]',
889
+ 'button[aria-label="Effects: group these clips first"]',
890
890
  );
891
891
  const view = renderHeader(opts);
892
892
  const button = pointer(view.host);
@@ -955,7 +955,7 @@ describe("TimelineTrackHeader", () => {
955
955
  const controls = line?.lastElementChild as HTMLElement | null;
956
956
  expect(controls?.className).toContain("ml-auto");
957
957
  expect(
958
- controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
958
+ controls?.querySelector('button[aria-label="Effects: group these clips first"]'),
959
959
  ).not.toBeNull();
960
960
  // And the clip count is beside the name, not out with the controls.
961
961
  expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull();
@@ -130,8 +130,8 @@ export function persistMoveEdits(
130
130
  if (!onMoveElements) {
131
131
  console.warn(
132
132
  onMoveElement
133
- ? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired — this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)`
134
- : `[Timeline] persistMoveEdits: no move persist handler wired — ${edits.length} edit(s) applied to the store only, not saved`,
133
+ ? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired, so this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)`
134
+ : `[Timeline] persistMoveEdits: no move persist handler wired, so ${edits.length} edit(s) applied to the store only, not saved`,
135
135
  );
136
136
  }
137
137
  const prev = edits.map((e) => ({
@@ -286,7 +286,7 @@ export function AutomationLaneHeaderRow({
286
286
  <span
287
287
  data-automation-lane-also=""
288
288
  className="truncate text-[9px] text-warning-ink"
289
- title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
289
+ title={`${alsoAutomatedBy} is also fading this. The two multiply.`}
290
290
  >
291
291
  {alsoAutomatedBy} is also fading this.
292
292
  </span>
@@ -183,7 +183,7 @@ export function resolveStaticSeekFallback(opts: {
183
183
  if (!warned.current) {
184
184
  warned.current = true;
185
185
  console.warn(
186
- `[useTimelinePlayer] Selected adapter duration (${getAdapterDuration(bestAdapter)}s) does not cover the document duration (${docDuration}s); falling back to seek-driven playback, which never starts media elements or WebAudio. Audio will not play in preview — extend the GSAP timeline to cover the declared data-duration.`,
186
+ `[useTimelinePlayer] Selected adapter duration (${getAdapterDuration(bestAdapter)}s) does not cover the document duration (${docDuration}s); falling back to seek-driven playback, which never starts media elements or WebAudio. Audio will not play in preview. Extend the GSAP timeline to cover the declared data-duration.`,
187
187
  );
188
188
  }
189
189
  const adapter = createStaticSeekPlaybackAdapter(
@@ -59,6 +59,7 @@ export interface ClipManifest {
59
59
 
60
60
  export type IframeWindow = Window & {
61
61
  __player?: RuntimePlaybackAdapter;
62
+ __playerReady?: boolean;
62
63
  __timeline?: TimelineLike;
63
64
  __timelines?: Record<string, TimelineLike>;
64
65
  __clipManifest?: ClipManifest;
@@ -1,6 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { SCENE_SWAP_MS, sceneSwapFor } from "./sceneSwap";
3
+ import {
4
+ SCENE_SWAP_MS,
5
+ announcePreviewDocumentLoaded,
6
+ onPreviewContentReplaced,
7
+ sceneSwapFor,
8
+ } from "./sceneSwap";
4
9
 
5
10
  afterEach(() => {
6
11
  vi.useRealTimers();
@@ -93,3 +98,24 @@ describe("sceneSwapFor", () => {
93
98
  expect(swap).toHaveBeenCalledWith("<html></html>", expect.anything());
94
99
  });
95
100
  });
101
+
102
+ describe("onPreviewContentReplaced", () => {
103
+ it("reports Player's document-loaded announcement and a scene swap, never a raw load", () => {
104
+ const iframe = document.createElement("iframe");
105
+ const replaced = vi.fn();
106
+ const stop = onPreviewContentReplaced(iframe, replaced);
107
+
108
+ iframe.dispatchEvent(new Event("load"));
109
+ expect(replaced).not.toHaveBeenCalled();
110
+
111
+ announcePreviewDocumentLoaded(iframe);
112
+ expect(replaced).toHaveBeenCalledTimes(1);
113
+
114
+ iframe.dispatchEvent(new Event("hf-scenes-swapped"));
115
+ expect(replaced).toHaveBeenCalledTimes(2);
116
+
117
+ stop();
118
+ announcePreviewDocumentLoaded(iframe);
119
+ expect(replaced).toHaveBeenCalledTimes(2);
120
+ });
121
+ });
@@ -6,14 +6,28 @@ type SwapWindow = Window & {
6
6
  const SCENES_SWAPPED = "hf-scenes-swapped";
7
7
  export const SCENE_SWAP_MS = 5000;
8
8
 
9
+ const PREVIEW_DOCUMENT_LOADED = "hf-preview-document-loaded";
10
+
11
+ export function announcePreviewDocumentLoaded(iframe: HTMLIFrameElement): void {
12
+ iframe.dispatchEvent(new Event(PREVIEW_DOCUMENT_LOADED));
13
+ }
14
+
15
+ export function onPreviewDocumentLoaded(
16
+ iframe: HTMLIFrameElement,
17
+ onLoaded: () => void,
18
+ ): () => void {
19
+ iframe.addEventListener(PREVIEW_DOCUMENT_LOADED, onLoaded);
20
+ return () => iframe.removeEventListener(PREVIEW_DOCUMENT_LOADED, onLoaded);
21
+ }
22
+
9
23
  export function onPreviewContentReplaced(
10
24
  iframe: HTMLIFrameElement,
11
25
  onReplaced: () => void,
12
26
  ): () => void {
13
- iframe.addEventListener("load", onReplaced);
27
+ const stopLoaded = onPreviewDocumentLoaded(iframe, onReplaced);
14
28
  iframe.addEventListener(SCENES_SWAPPED, onReplaced);
15
29
  return () => {
16
- iframe.removeEventListener("load", onReplaced);
30
+ stopLoaded();
17
31
  iframe.removeEventListener(SCENES_SWAPPED, onReplaced);
18
32
  };
19
33
  }
@@ -51,7 +51,7 @@ export function createStableContext<T>(name: string, defaultValue: T): Context<T
51
51
  // Not on the HMR path: a re-evaluated module hands back the SAME default it
52
52
  // registered, which is how a hot reload keeps its context alive.
53
53
  console.warn(
54
- `[hmrStableContext] "${name}" was registered twice with different defaults — ` +
54
+ `[hmrStableContext] "${name}" was registered twice with different defaults: ` +
55
55
  "two contexts are sharing one identity. Use module path + export name.",
56
56
  );
57
57
  }
@@ -18,7 +18,6 @@ interface CutTarget {
18
18
  elementDuration: number;
19
19
  playbackStart?: number;
20
20
  playbackRate?: number;
21
- isComposition?: boolean;
22
21
  track?: number;
23
22
  }
24
23
 
@@ -71,7 +70,6 @@ function buildCutTarget(
71
70
  elementDuration: element.duration,
72
71
  ...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
73
72
  ...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
74
- ...(element.kind === "composition" ? { isComposition: true } : {}),
75
73
  // Pin both halves to the current track: unstamped, the runtime's positional
76
74
  // fallback (parseAuthoredTrack) renumbers the new sibling onto a new row.
77
75
  track: resolveElementTrack(element),
@@ -43,7 +43,7 @@ describe("a linked trim the SDK refuses", () => {
43
43
  thrown = error;
44
44
  }
45
45
  expect(getStudioSaveErrorMessage(thrown)).toBe(
46
- "Linked audio would start after the new end — unlink or trim the audio first.",
46
+ "Linked audio would start after the new end. Unlink or trim the audio first.",
47
47
  );
48
48
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
49
49
  expect(deps.editHistory.recordEdit).not.toHaveBeenCalled();