@hyperframes/studio 0.8.107 → 0.8.108

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 (99) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
  4. package/dist/assets/{index-zoqPmBF6.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
  9. package/dist/chunk-AOWVEVEU.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +11 -2
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +12416 -11195
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +1 -0
  21. package/src/components/EditorShell.tsx +2 -0
  22. package/src/components/editor/CropPresetBar.tsx +69 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  25. package/src/components/editor/carveOutput.ts +54 -0
  26. package/src/components/editor/cropPresetStore.ts +33 -0
  27. package/src/components/editor/cropPresets.test.ts +41 -0
  28. package/src/components/editor/cropPresets.ts +24 -0
  29. package/src/components/editor/domEditingLayers.test.ts +21 -0
  30. package/src/components/editor/domEditingLayers.ts +2 -0
  31. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  32. package/src/components/editor/manualOffsetDrag.ts +11 -0
  33. package/src/components/editor/mediaAudioEdits.test.ts +59 -0
  34. package/src/components/editor/mediaAudioEdits.ts +40 -11
  35. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  36. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
  38. package/src/components/editor/useDomEditNudge.ts +17 -12
  39. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  40. package/src/components/editor/useFxCarve.ts +3 -61
  41. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  42. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  43. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  44. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  45. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  46. package/src/contexts/TimelineEditContext.tsx +1 -0
  47. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  48. package/src/hooks/useEditHistoryActions.ts +13 -4
  49. package/src/hooks/useElementPicker.test.ts +62 -13
  50. package/src/hooks/useElementPicker.ts +10 -2
  51. package/src/hooks/useFreezeFrame.test.ts +56 -0
  52. package/src/hooks/useFreezeFrame.ts +121 -0
  53. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  54. package/src/hooks/usePersistentEditHistory.ts +1 -1
  55. package/src/hooks/useRazorSplit.ts +14 -1
  56. package/src/hooks/useRenderClipContent.test.ts +102 -0
  57. package/src/hooks/useRenderClipContent.ts +30 -23
  58. package/src/hooks/useTimelineEditing.ts +2 -2
  59. package/src/index.ts +1 -0
  60. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  61. package/src/player/components/ClipBadges.test.tsx +82 -0
  62. package/src/player/components/ClipBadges.tsx +67 -0
  63. package/src/player/components/ClipContextMenu.tsx +22 -0
  64. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  65. package/src/player/components/ClipPeakMarks.tsx +71 -0
  66. package/src/player/components/TimelineClip.tsx +2 -0
  67. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  68. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  69. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  70. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  71. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  72. package/src/player/components/clipMenuDuck.test.ts +117 -0
  73. package/src/player/components/clipMenuDuck.ts +142 -0
  74. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  75. package/src/player/components/clipMenuNormalize.ts +96 -0
  76. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  77. package/src/player/components/clipMenuToolItems.tsx +235 -0
  78. package/src/player/components/clipPeakRuns.test.ts +38 -0
  79. package/src/player/components/clipPeakRuns.ts +59 -0
  80. package/src/player/components/clipToolAttrs.test.ts +161 -0
  81. package/src/player/components/clipToolAttrs.ts +187 -0
  82. package/src/player/components/timelineCallbacks.ts +1 -0
  83. package/src/player/components/useClipToolState.ts +92 -0
  84. package/src/player/lib/timelineElementHelpers.ts +9 -0
  85. package/src/timelineChromeExports.test.tsx +7 -0
  86. package/src/utils/audioMeterMath.test.ts +37 -0
  87. package/src/utils/audioMeterMath.ts +9 -3
  88. package/src/utils/exportPeakCeiling.ts +1 -0
  89. package/src/utils/previewRelativePath.ts +19 -0
  90. package/src/utils/sourcePatcher.ts +1 -1
  91. package/src/utils/studioPendingEdits.test.ts +66 -0
  92. package/src/utils/studioPendingEdits.ts +45 -11
  93. package/src/utils/timelineInspector.test.ts +33 -2
  94. package/src/utils/timelineInspector.ts +16 -4
  95. package/dist/chunk-46XP7J3B.js.map +0 -1
  96. package/dist/chunk-4OZC426H.js.map +0 -1
  97. package/dist/chunk-DI4LCJHL.js +0 -102
  98. package/dist/chunk-DI4LCJHL.js.map +0 -1
  99. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -20,6 +20,11 @@ import {
20
20
  resolveAudioGroups,
21
21
  resolveCarveSourceIds,
22
22
  } from "@hyperframes/core/audio-groups";
23
+ import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
24
+
25
+ export function isCarveVoiceElement(el: Element | null): el is Element {
26
+ return el !== null && (el.tagName === "AUDIO" || isAudibleVideoNode(el));
27
+ }
23
28
 
24
29
  /**
25
30
  * An id for a new voiceover group, de-duped against every id already in the
@@ -130,7 +135,7 @@ function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string)
130
135
 
131
136
  export function collectCarveCandidates(
132
137
  doc: Document,
133
- others: readonly HTMLAudioElement[],
138
+ others: readonly Element[],
134
139
  overlapsBed: (a: Element) => boolean,
135
140
  /** The bed's own id, so neither it nor the group it belongs to is offered. */
136
141
  bedId?: string,
@@ -141,7 +146,7 @@ export function collectCarveCandidates(
141
146
  const excluded = excludedFor(groupByMember, bedId);
142
147
  const offeredGroupIds = new Set<string>();
143
148
  const described: CarveCandidate[] = [];
144
- for (const a of others) {
149
+ for (const a of others.filter(isCarveVoiceElement)) {
145
150
  const group = groupByMember.get(a.id);
146
151
  if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
147
152
  if (!group) {
@@ -161,7 +166,7 @@ export function collectCarveCandidates(
161
166
  // iframe document, so the constructor is a different realm's and the
162
167
  // instanceof is false for every one (mirrors resolveCarveVoices in
163
168
  // useFxCarve.ts).
164
- .filter((el): el is HTMLElement => el?.tagName === "AUDIO");
169
+ .filter((el): el is HTMLElement => isCarveVoiceElement(el));
165
170
  if (!members.some(overlapsBed)) continue;
166
171
  offeredGroupIds.add(group.id);
167
172
  described.push({
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
14
  type AudioMeterStripProps,
15
+ CLIP_LATCH_PEAK,
16
+ stepClipLatch,
15
17
  evictGoneMeterState,
16
18
  followMeterHook,
17
19
  stepAndPaintStrips,
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
416
418
  expect(state.has("loud")).toBe(true);
417
419
  });
418
420
  });
421
+
422
+ describe("CLIP light", () => {
423
+ const hookAt = (peak: number) => ({
424
+ start: vi.fn(),
425
+ stop: vi.fn(),
426
+ read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
427
+ });
428
+
429
+ it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
430
+ usePlayerStore.setState({ elements: [clip({})] });
431
+ const hook = hookAt(1);
432
+ setHook(hook);
433
+ const { host } = mount();
434
+ const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
435
+ expect(light?.dataset.lit).toBe("false");
436
+ tick();
437
+ expect(light?.dataset.lit).toBe("true");
438
+ hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
439
+ tick();
440
+ expect(light?.dataset.lit).toBe("true");
441
+ act(() => light?.click());
442
+ expect(light?.dataset.lit).toBe("false");
443
+ tick();
444
+ expect(light?.dataset.lit).toBe("false");
445
+ });
446
+
447
+ it("stays dark in the red band just under the ceiling", () => {
448
+ usePlayerStore.setState({ elements: [clip({})] });
449
+ setHook(hookAt(10 ** (-1.2 / 20)));
450
+ const { host } = mount();
451
+ tick();
452
+ expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
453
+ "false",
454
+ );
455
+ });
456
+ });
457
+
458
+ describe("stepClipLatch", () => {
459
+ it("lights exactly at -1 dBFS on either channel, not below", () => {
460
+ expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
461
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
462
+ expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
463
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
464
+ expect(stepClipLatch(false, undefined)).toBe(false);
465
+ });
466
+
467
+ it("holds once lit, whatever comes next", () => {
468
+ expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
469
+ expect(stepClipLatch(true, undefined)).toBe(true);
470
+ });
471
+ });
@@ -5,6 +5,7 @@ import {
5
5
  useMemo,
6
6
  useRef,
7
7
  type PointerEvent as ReactPointerEvent,
8
+ type ReactNode,
8
9
  type Ref,
9
10
  type RefObject,
10
11
  } from "react";
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
20
21
  import { usePlayerStore } from "../../player";
21
22
  import { clampNumber } from "../../utils/studioHelpers";
22
23
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
24
+ import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
23
25
  import { useStudioShellContextOptional } from "../../contexts/StudioContext";
24
26
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
27
  import {
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
49
51
 
50
52
  const MONITOR_LABEL = "Monitor";
51
53
 
54
+ /** The CLIP light latches at the export ceiling, stricter than RED_AT. */
55
+ export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
56
+
57
+ export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
58
+ if (latched || !master) return latched;
59
+ return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
60
+ }
61
+
62
+ function paintClipLight(el: HTMLElement | null, lit: boolean): void {
63
+ if (!el) return;
64
+ el.dataset.lit = String(lit);
65
+ el.style.setProperty(
66
+ "background-color",
67
+ lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
68
+ );
69
+ el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
70
+ }
71
+
52
72
  /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
53
73
  const AMBER_AT = markFraction(-6);
54
74
  const RED_AT = markFraction(-3);
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
178
198
  }
179
199
 
180
200
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
201
+ interface ClipLatch {
202
+ lit: boolean;
203
+ light: HTMLElement | null;
204
+ }
205
+
181
206
  function useMeterLoop(
182
207
  strips: Strip[],
183
208
  bars: RefObject<Map<string | null, StripBars>>,
184
209
  iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
210
+ clip: RefObject<ClipLatch>,
185
211
  ) {
186
212
  const shell = useStudioShellContextOptional();
187
213
  const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
@@ -200,6 +226,11 @@ function useMeterLoop(
200
226
  last = now;
201
227
  evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
202
228
  stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
229
+ const lit = stepClipLatch(clip.current.lit, levels?.master);
230
+ if (lit !== clip.current.lit) {
231
+ clip.current.lit = lit;
232
+ paintClipLight(clip.current.light, lit);
233
+ }
203
234
  };
204
235
  raf = requestAnimationFrame(tick);
205
236
  return () => {
@@ -210,7 +241,29 @@ function useMeterLoop(
210
241
  // Preview iframe already gone.
211
242
  }
212
243
  };
213
- }, [previewIframeRef, bars]);
244
+ }, [previewIframeRef, bars, clip]);
245
+ }
246
+
247
+ function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
248
+ return (
249
+ <button
250
+ type="button"
251
+ data-testid="meter-clip-light"
252
+ data-lit="false"
253
+ title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
254
+ ref={(el) => {
255
+ clip.current.light = el;
256
+ paintClipLight(el, clip.current.lit);
257
+ }}
258
+ onClick={() => {
259
+ clip.current.lit = false;
260
+ paintClipLight(clip.current.light, false);
261
+ }}
262
+ className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
263
+ >
264
+ CLIP
265
+ </button>
266
+ );
214
267
  }
215
268
 
216
269
  function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
@@ -341,11 +394,13 @@ function MeterStrip({
341
394
  register,
342
395
  onLive,
343
396
  onCommit,
397
+ clipLight,
344
398
  }: {
345
399
  strip: Strip;
346
400
  register: (id: string | null, bars: StripBars | null) => void;
347
401
  onLive: (id: string | null, volume: number) => void;
348
402
  onCommit: (id: string | null, volume: number) => void;
403
+ clipLight?: ReactNode;
349
404
  }) {
350
405
  const refs = [
351
406
  useRef<HTMLDivElement>(null),
@@ -364,6 +419,7 @@ function MeterStrip({
364
419
  }, [strip.id, register]);
365
420
  return (
366
421
  <div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
422
+ {clipLight}
367
423
  <div
368
424
  className="flex min-h-0 flex-1 items-stretch gap-1.5"
369
425
  aria-label={`${strip.label} level`}
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
421
477
  else bars.current.delete(id);
422
478
  }).current;
423
479
  const { onLive, onCommit } = useVolumeHandlers();
424
- useMeterLoop(strips, bars, previewIframeRef);
480
+ const clip = useRef<ClipLatch>({ lit: false, light: null });
481
+ useMeterLoop(strips, bars, previewIframeRef, clip);
425
482
  return (
426
483
  <div
427
484
  data-testid="audio-meter-strip"
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
434
491
  register={register}
435
492
  onLive={onLive}
436
493
  onCommit={onCommit}
494
+ clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
437
495
  />
438
496
  ))}
439
497
  </div>
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
53
53
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
54
54
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
55
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56
+ handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
56
57
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
57
58
  handleGroupClips?: (
58
59
  clipIds: readonly string[],
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
132
133
  handleTimelineElementSplit,
133
134
  handleRazorSplit,
134
135
  handleRazorSplitAll,
136
+ handleFreezeFrame,
135
137
  handleGroupClips,
136
138
  setElementFxAttribute,
137
139
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
227
229
  onSplitElement: handleTimelineElementSplit,
228
230
  onRazorSplit: handleRazorSplit,
229
231
  onRazorSplitAll: handleRazorSplitAll,
232
+ onFreezeFrame: handleFreezeFrame,
230
233
  onDeleteAllKeyframes: (element, animationId) => {
231
234
  // Hold the element where it is (collapse keyframes to a static set) rather
232
235
  // than deleting the whole animation — deleting strands a stale GSAP base
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
423
426
  handleTimelineElementSplit,
424
427
  handleRazorSplit,
425
428
  handleRazorSplitAll,
429
+ handleFreezeFrame,
426
430
  handleGsapRemoveAllKeyframes,
427
431
  resolveElementAnimations,
428
432
  resolveKeyframeTarget,
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
42
42
  onSplitElement: true,
43
43
  onRazorSplit: true,
44
44
  onRazorSplitAll: true,
45
+ onFreezeFrame: true,
45
46
  onDeleteKeyframe: true,
46
47
  onDeleteAllKeyframes: true,
47
48
  onMoveKeyframeToPlayhead: true,
@@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
206
206
  const s = await studio();
207
207
  await s.edit();
208
208
  resetNudgeKeys();
209
+ let finish!: () => void;
209
210
  const save = vi.fn(async () => {
211
+ await new Promise<void>((resolve) => (finish = resolve));
210
212
  writeFileSync(s.path, NUDGED);
211
213
  await s.history().recordEdit({
212
214
  label: "Move layer",
@@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
218
220
  window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
219
221
  });
220
222
 
223
+ const nudge = () => s.element("box").style.getPropertyValue("translate");
224
+ expect(nudge()).not.toBe("");
225
+
221
226
  const undone = s.actions().undo();
222
227
  expect(s.box()).toBe("50px");
228
+ expect(nudge()).toBe("");
229
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
230
+ expect(nudge()).toBe("");
231
+ finish();
223
232
  await act(() => undone);
224
233
  expect(save).toHaveBeenCalledTimes(1);
225
234
  expect(s.file()).toBe(AFTER);
226
235
  expect(s.box()).toBe("50px");
236
+ expect(nudge()).toBe("");
237
+ });
238
+
239
+ it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
240
+ const s = await studio();
241
+ await s.edit();
242
+ resetNudgeKeys();
243
+ let finish!: () => void;
244
+ const save = vi.fn(async () => {
245
+ await new Promise<void>((resolve) => (finish = resolve));
246
+ writeFileSync(s.path, NUDGED);
247
+ await s.history().recordEdit({
248
+ label: "Move layer",
249
+ files: { "index.html": { before: AFTER, after: NUDGED } },
250
+ });
251
+ });
252
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
253
+ act(() => {
254
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
255
+ });
256
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
257
+ const nudge = () => s.element("box").style.getPropertyValue("translate");
258
+ expect(nudge()).not.toBe("");
259
+
260
+ const undone = s.actions().undo();
261
+ expect(nudge()).toBe("");
262
+ expect(s.box()).toBe("50px");
263
+ finish();
264
+ await act(() => undone);
265
+ expect(s.file()).toBe(AFTER);
266
+ expect(nudge()).toBe("");
267
+ expect(s.box()).toBe("50px");
227
268
  });
228
269
 
229
270
  it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
3
3
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
+ import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
6
7
 
7
8
  interface HistoryResult {
8
9
  ok: boolean;
@@ -74,7 +75,10 @@ export function useEditHistoryActions({
74
75
  const noun = direction === "undo" ? "Undo" : "Redo";
75
76
  // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
76
77
  const predicted = editHistory.predict?.(direction) ?? null;
77
- const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
78
+ const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
79
+ const pendingEditShown =
80
+ !predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
81
+ const putBack = predictedShown ?? pendingEditShown;
78
82
  let result: HistoryResult = { ok: false, reason: "failed" };
79
83
  let serverSteppedShown = false;
80
84
  try {
@@ -83,8 +87,10 @@ export function useEditHistoryActions({
83
87
  readFile: readHistoryFile,
84
88
  serialize: serializeHistoryFiles,
85
89
  });
86
- serverSteppedShown =
87
- Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
90
+ const stepped = Boolean(result.ok && result.label);
91
+ serverSteppedShown = predictedShown
92
+ ? stepped && result.undoes === predicted?.id
93
+ : stepped && Boolean(pendingEditShown);
88
94
  } finally {
89
95
  if (putBack && !serverSteppedShown) putBack();
90
96
  }
@@ -100,7 +106,10 @@ export function useEditHistoryActions({
100
106
  return;
101
107
  }
102
108
  if (result.ok && result.label) {
103
- const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
109
+ const files =
110
+ serverSteppedShown && predictedShown
111
+ ? fromShown(result.files, predicted!.files)
112
+ : result.files;
104
113
  const restore = { paths: result.paths, files };
105
114
  onAfterUndoRedo?.(restore);
106
115
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
@@ -19,6 +19,22 @@ afterEach(() => {
19
19
  document.body.innerHTML = "";
20
20
  });
21
21
 
22
+ // The runtime's element-picked message, posted by `from`.
23
+ const picked = (from: Window | null, selector: string) =>
24
+ act(() => {
25
+ window.dispatchEvent(
26
+ new MessageEvent("message", {
27
+ source: from,
28
+ data: {
29
+ source: "hf-preview",
30
+ type: "element-picked",
31
+ elementInfo: { selector, tagName: "h1" },
32
+ ...runtimeProtocolMetadata(30),
33
+ },
34
+ }),
35
+ );
36
+ });
37
+
22
38
  // The preview page: the saved markup plus what the runtime adds to it.
23
39
  function mountPreview(mounted = ""): HTMLIFrameElement {
24
40
  const iframe = document.createElement("iframe");
@@ -61,19 +77,7 @@ function mountPicker(
61
77
  }
62
78
  root = createRoot(document.createElement("div"));
63
79
  act(() => root?.render(React.createElement(Harness)));
64
- act(() => {
65
- window.dispatchEvent(
66
- new MessageEvent("message", {
67
- source: iframe.contentWindow,
68
- data: {
69
- source: "hf-preview",
70
- type: "element-picked",
71
- elementInfo: { selector, tagName: "h1" },
72
- ...runtimeProtocolMetadata(30),
73
- },
74
- }),
75
- );
76
- });
80
+ picked(iframe.contentWindow, selector);
77
81
  const picker = () => api as ReturnType<typeof useElementPicker>;
78
82
  return {
79
83
  picker,
@@ -170,3 +174,48 @@ describe("an edit to a picked element without an id", () => {
170
174
  expect(synced).toEqual([]);
171
175
  });
172
176
  });
177
+
178
+ describe("a pick across a reload that swaps the preview frame", () => {
179
+ function mountSwapping() {
180
+ const live = mountPreview();
181
+ const ref: { current: HTMLIFrameElement | null } = { current: live };
182
+ let api: ReturnType<typeof useElementPicker> | null = null;
183
+ function Harness() {
184
+ api = useElementPicker(ref);
185
+ return null;
186
+ }
187
+ root = createRoot(document.createElement("div"));
188
+ act(() => root?.render(React.createElement(Harness)));
189
+ return { live, ref, picker: () => api as ReturnType<typeof useElementPicker> };
190
+ }
191
+
192
+ it("keeps a pick posted by the frame it was made in after a new frame took its place", () => {
193
+ const { live, ref, picker } = mountSwapping();
194
+ picked(live.contentWindow, "h1");
195
+ act(() => picker().clearPick());
196
+ // The reload promotes its frame before the old frame's pick message is handled.
197
+ const old = live.contentWindow;
198
+ ref.current = mountPreview();
199
+ live.remove();
200
+ picked(old, "h1.next");
201
+ expect(picker().pickedElement?.selector).toBe("h1.next");
202
+ });
203
+
204
+ it("does not keep trusting a zoomed frame once the zoom is gone", () => {
205
+ const { picker } = mountSwapping();
206
+ const zoom = mountPreview();
207
+ act(() => picker().setActiveIframe(zoom));
208
+ picked(zoom.contentWindow, "h1");
209
+ expect(picker().pickedElement?.selector).toBe("h1");
210
+ act(() => picker().setActiveIframe(null));
211
+ picked(zoom.contentWindow, "h1.late");
212
+ expect(picker().pickedElement?.selector).toBe("h1");
213
+ });
214
+
215
+ it("still ignores a frame it never showed", () => {
216
+ const { picker } = mountSwapping();
217
+ const stranger = mountPreview();
218
+ picked(stranger.contentWindow, "h1");
219
+ expect(picker().pickedElement).toBeNull();
220
+ });
221
+ });
@@ -97,6 +97,8 @@ export function useElementPicker(
97
97
  setPickedElement(null);
98
98
  }, []);
99
99
 
100
+ const windowsThatWerePrimary = useRef(new WeakSet<MessageEventSource>());
101
+
100
102
  // Listen for picker messages from the iframe
101
103
  useMountEffect(() => {
102
104
  // One guard per message field, then one branch per message type.
@@ -105,10 +107,16 @@ export function useElementPicker(
105
107
  const data = e.data;
106
108
  if (data?.source !== "hf-preview") return;
107
109
  if (!acceptStudioRuntimeMessage(data)) return;
108
- // Accept events from either the primary iframe or the active override
110
+ // The primary frame, the active override, or a primary frame a reload just swapped out.
109
111
  const activeIframe = getActiveIframe();
110
112
  if (!activeIframe) return;
111
- if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
113
+ if (!e.source) return;
114
+ if (e.source === iframeRef.current?.contentWindow)
115
+ windowsThatWerePrimary.current.add(e.source);
116
+ else if (
117
+ e.source !== activeIframe.contentWindow &&
118
+ !windowsThatWerePrimary.current.has(e.source)
119
+ )
112
120
  return;
113
121
 
114
122
  if (data.type === "element-picked" && data.elementInfo) {
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import { requestFreezeFrame } from "./useFreezeFrame";
4
+
5
+ afterEach(() => vi.unstubAllGlobals());
6
+
7
+ const element: TimelineElement = {
8
+ id: "talk",
9
+ domId: "talk",
10
+ tag: "video",
11
+ start: 5,
12
+ duration: 6,
13
+ track: 0,
14
+ parentCompositionStart: 4,
15
+ };
16
+
17
+ function stubFetch(freezeResponse: { status: number; body: unknown }) {
18
+ const calls: Array<{ url: string; body: unknown }> = [];
19
+ vi.stubGlobal(
20
+ "fetch",
21
+ vi.fn(async (url: string, init?: { body?: string }) => {
22
+ calls.push({ url, body: init?.body ? JSON.parse(init.body) : null });
23
+ if (url.includes("/files/")) return new Response(JSON.stringify({ version: "v1" }));
24
+ return new Response(JSON.stringify(freezeResponse.body), { status: freezeResponse.status });
25
+ }),
26
+ );
27
+ return calls;
28
+ }
29
+
30
+ describe("requestFreezeFrame", () => {
31
+ it("posts the clip target, file version and an authored-time playhead", async () => {
32
+ const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
33
+ const result = await requestFreezeFrame({
34
+ projectId: "p",
35
+ path: "scene.html",
36
+ element,
37
+ playhead: 7.5,
38
+ });
39
+ expect(result).toEqual({ before: "a", after: "b", version: "v2" });
40
+ const post = calls[1];
41
+ expect(post?.url).toContain("/file-mutations/freeze-frame");
42
+ expect(post?.body).toMatchObject({
43
+ path: "scene.html",
44
+ expectedVersion: "v1",
45
+ target: { id: "talk" },
46
+ playhead: 3.5,
47
+ });
48
+ });
49
+
50
+ it("surfaces the server's error", async () => {
51
+ stubFetch({ status: 400, body: { error: "Move the playhead inside a video clip to freeze" } });
52
+ await expect(
53
+ requestFreezeFrame({ projectId: "p", path: "index.html", element, playhead: 7 }),
54
+ ).rejects.toThrow("Move the playhead inside a video clip to freeze");
55
+ });
56
+ });