@hyperframes/studio 0.8.81 → 0.8.82

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 (98) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
  4. package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2414 -2172
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  11. package/src/components/TimelineHistoryButtons.tsx +26 -14
  12. package/src/components/TimelineToolbar.test.tsx +9 -1
  13. package/src/components/TimelineToolbar.tsx +13 -5
  14. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  15. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  16. package/src/components/editor/manualEdits.test.ts +24 -0
  17. package/src/components/editor/manualEdits.ts +6 -2
  18. package/src/components/editor/manualEditsDom.ts +0 -65
  19. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  20. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  21. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  23. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  24. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  25. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  26. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  27. package/src/components/editor/useFxLevelling.ts +35 -10
  28. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  29. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  30. package/src/components/pointerTargetSize.test.tsx +1 -1
  31. package/src/components/studioColorGradingScope.ts +12 -36
  32. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  33. package/src/contexts/TimelineEditContext.tsx +27 -23
  34. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  35. package/src/hooks/gsapMutationClient.ts +116 -0
  36. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  37. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  38. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  39. package/src/hooks/timelineEditingHelpers.ts +66 -61
  40. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  41. package/src/hooks/timelineTimingSync.test.ts +12 -0
  42. package/src/hooks/timelineTimingSync.ts +31 -123
  43. package/src/hooks/timelineTrackVisibility.ts +15 -29
  44. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  45. package/src/hooks/useAskAgentModal.ts +3 -1
  46. package/src/hooks/useClipboard.source.test.ts +32 -0
  47. package/src/hooks/useClipboard.ts +21 -20
  48. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  49. package/src/hooks/useDomEditCommits.ts +88 -97
  50. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  51. package/src/hooks/useEditorSave.ts +1 -1
  52. package/src/hooks/useElementLifecycleOps.ts +36 -37
  53. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  54. package/src/hooks/useFileManager.ts +21 -12
  55. package/src/hooks/useGroupCommits.ts +23 -24
  56. package/src/hooks/useGsapAnimationOps.ts +26 -8
  57. package/src/hooks/useGsapScriptCommits.ts +1 -0
  58. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  59. package/src/hooks/useThumbnailStripSize.ts +31 -0
  60. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  61. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  62. package/src/hooks/useTimelineEditing.ts +3 -1
  63. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  64. package/src/index.ts +8 -0
  65. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  66. package/src/player/components/CompositionThumbnail.tsx +6 -21
  67. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  68. package/src/player/components/ImageThumbnail.tsx +4 -18
  69. package/src/player/components/PlayerControls.test.tsx +52 -0
  70. package/src/player/components/PlayerControls.tsx +2 -1
  71. package/src/player/components/Timeline.test.ts +28 -3
  72. package/src/player/components/TimelineCanvas.tsx +5 -3
  73. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  74. package/src/player/components/VideoThumbnail.tsx +7 -29
  75. package/src/player/components/thumbnailUtils.test.ts +16 -20
  76. package/src/player/components/thumbnailUtils.ts +6 -7
  77. package/src/player/components/timelineEditing.test.ts +55 -0
  78. package/src/player/components/timelineGroupEditing.ts +13 -6
  79. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  80. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  81. package/src/player/components/useTimelinePlayhead.ts +26 -8
  82. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  83. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  84. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  85. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  86. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  87. package/src/player/lib/playbackShortcuts.ts +18 -0
  88. package/src/player/lib/playbackTypes.ts +1 -0
  89. package/src/player/lib/timelineDOM.test.ts +92 -0
  90. package/src/player/lib/timelineDOM.ts +0 -2
  91. package/src/player/lib/timelineElementHelpers.ts +47 -10
  92. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  93. package/src/player/store/timelineElement.ts +2 -1
  94. package/src/timelineChromeExports.test.tsx +79 -0
  95. package/src/utils/authoredSource.ts +15 -0
  96. package/src/utils/blockInstaller.ts +32 -27
  97. package/src/utils/studioFileHistory.test.ts +14 -14
  98. package/src/utils/studioFileHistory.ts +31 -2
@@ -80,6 +80,8 @@ function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | unde
80
80
  return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
81
81
  }
82
82
 
83
+ const SAMPLE_RATE = 48000;
84
+
83
85
  function mount(dataAttributes: Record<string, string>, alone = false, voices = 2) {
84
86
  // Every write is quiet: persisted without the preview reload that would
85
87
  // restart every playing track, but with a selection resync so the panel sees
@@ -459,29 +461,8 @@ describe("AudioFxGroup dynamic carve", () => {
459
461
 
460
462
  afterEach(() => vi.unstubAllGlobals());
461
463
 
462
- /**
463
- * Hover-auditioning the leveller has to measure before there is anything to
464
- * hear, and measuring a long voiceover takes seconds — by which time the
465
- * pointer has usually moved on. Applying then would put levelling on a track
466
- * nobody asked to level, through a channel that does not persist: audible,
467
- * absent from the document, and gone on the next reload.
468
- */
469
- it("levels the part of the file the clip plays, not the file from its start", async () => {
470
- // A lane's `t` is seconds from the start of the CLIP, but the decode is the
471
- // whole file — so a trimmed clip got an envelope offset by exactly
472
- // `media-start`, and every correction landed early.
473
- //
474
- // The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
475
- // first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
476
- // passage and wants a real lift; measured from the file's zero it sits in
477
- // the loud head and wants none. That gap is the bug.
478
- const sampleRate = 48000;
479
- const data = new Float32Array(sampleRate * 8);
480
- for (let i = 0; i < data.length; i++) {
481
- const t = i / sampleRate;
482
- const amp = t < 2 ? 0.5 : t < 5 ? 0.05 : 0.5;
483
- data[i] = amp * Math.sin(2 * Math.PI * 300 * t);
484
- }
464
+ /** Runs Even Out Levels on a decode of `data` and returns the fx lane's nearest point to a clip time. */
465
+ async function levelledLane(data: Float32Array, dataAttributes: Record<string, string>) {
485
466
  vi.stubGlobal(
486
467
  "fetch",
487
468
  vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
@@ -489,15 +470,10 @@ describe("AudioFxGroup dynamic carve", () => {
489
470
  vi.stubGlobal(
490
471
  "OfflineAudioContext",
491
472
  class {
492
- decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
473
+ decodeAudioData = async () => ({ sampleRate: SAMPLE_RATE, getChannelData: () => data });
493
474
  },
494
475
  );
495
-
496
- const { host, onSetAttributeQuiet } = mount({
497
- "fx-chain": CHAIN,
498
- "media-start": "2",
499
- duration: "6",
500
- });
476
+ const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN, ...dataAttributes });
501
477
  document.getElementById("bed")?.setAttribute("src", "bed.wav");
502
478
  act(() => byTextButton(host, "Audio FX")?.click());
503
479
  act(() => byTextButton(host, "+ effect")?.click());
@@ -513,12 +489,75 @@ describe("AudioFxGroup dynamic carve", () => {
513
489
  JSON.parse(String(write[1])).lanes as { target: string; points: { t: number; v: number }[] }[]
514
490
  ).find((l) => l.target.startsWith("fx."));
515
491
  if (!lane) throw new Error("no fx lane");
516
- const near = (t: number) =>
492
+ return (t: number) =>
517
493
  lane.points.reduce((best, p) => (Math.abs(p.t - t) < Math.abs(best.t - t) ? p : best));
518
- // The quiet passage, from the clip's zero, gets its lift.
494
+ }
495
+
496
+ /** A 300 Hz tone, 16 s long: quiet between `quietFrom` and `quietTo`, loud elsewhere. */
497
+ function toneWithQuietPassage(quietFrom: number, quietTo: number): Float32Array {
498
+ const data = new Float32Array(SAMPLE_RATE * 16);
499
+ for (let i = 0; i < data.length; i++) {
500
+ const t = i / SAMPLE_RATE;
501
+ data[i] = (t >= quietFrom && t < quietTo ? 0.05 : 0.5) * Math.sin(2 * Math.PI * 300 * t);
502
+ }
503
+ return data;
504
+ }
505
+
506
+ it("levels the part of the file the clip plays, not the file from its start", async () => {
507
+ // A lane's `t` is seconds from the start of the CLIP, but the decode is the
508
+ // whole file. The file is quiet 2-5 s and the clip trims the first 2 s, so
509
+ // from the clip's zero t=0.5 wants a real lift; from the file's zero it wants none.
510
+ const near = await levelledLane(toneWithQuietPassage(2, 5), {
511
+ "playback-start": "2",
512
+ "media-start": "0",
513
+ duration: "6",
514
+ });
519
515
  expect(near(0.5).v).toBeGreaterThan(4);
520
516
  });
521
517
 
518
+ it.each<{
519
+ label: string;
520
+ attrs: Record<string, string>;
521
+ quiet: [number, number];
522
+ quietAt: number;
523
+ loudAt: number;
524
+ }>([
525
+ // 2x: 6 s of clip play source 0-12 s; source 2-6 s is quiet, so clip 1-3 s wants lift.
526
+ {
527
+ label: "2x",
528
+ attrs: { "playback-rate": "2", duration: "6" },
529
+ quiet: [2, 6],
530
+ quietAt: 2,
531
+ loudAt: 5,
532
+ },
533
+ // 0.5x: 8 s of clip play source 0-4 s; source 1-2 s is quiet, so clip 2-4 s wants lift and clip 1 s none.
534
+ {
535
+ label: "0.5x",
536
+ attrs: { "playback-rate": "0.5", duration: "8" },
537
+ quiet: [1, 2],
538
+ quietAt: 3.5,
539
+ loudAt: 1,
540
+ },
541
+ // A rate lane holding 2x: playback reads the lane, not the missing data-playback-rate.
542
+ {
543
+ label: "a 2x rate lane",
544
+ attrs: {
545
+ automation:
546
+ '{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":2},{"t":6,"v":2}]}]}',
547
+ duration: "6",
548
+ },
549
+ quiet: [2, 6],
550
+ quietAt: 2,
551
+ loudAt: 5,
552
+ },
553
+ ])("levels on the clip's clock at $label", async (c) => {
554
+ const near = await levelledLane(toneWithQuietPassage(...c.quiet), c.attrs);
555
+ expect({ quiet: near(c.quietAt).v > 4, loud: near(c.loudAt).v < 2 }).toEqual({
556
+ quiet: true,
557
+ loud: true,
558
+ });
559
+ });
560
+
522
561
  it("removes every lane a preset owned, not just the last node's", () => {
523
562
  // Each write is computed from the same render-time snapshot and replaces the
524
563
  // whole attribute, so removing lanes one node at a time kept only the final
@@ -4,7 +4,10 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
7
+ import { MediaSection } from "./propertyPanelMediaSection";
7
8
  import type { DomEditSelection } from "./domEditing";
9
+ import { formatTimingValue } from "./propertyPanelHelpers";
10
+ import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
8
11
 
9
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
13
 
@@ -608,6 +611,65 @@ describe("FlatMediaSection — audio clips", () => {
608
611
  act(() => root.unmount());
609
612
  });
610
613
 
614
+ it.each<Record<string, string>>([
615
+ { "playback-start": "1", "media-start": "5" },
616
+ { "playback-start": "-1", "media-start": "1" },
617
+ { "media-start": "1" },
618
+ {},
619
+ ])("shows and edits the in-point playback reads (%o)", (inPoint) => {
620
+ const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
621
+ const playbackReads = () => readMediaOffsetSeconds((name) => attrs[name.slice(5)]);
622
+ const onSetAttribute = vi.fn((name: string, value: string) => {
623
+ attrs[name] = value;
624
+ });
625
+ const { host, root } = renderWithRate(makeAudioElement(attrs), onSetAttribute);
626
+ const readout = host
627
+ .querySelector('[data-flat-slider-track="true"][aria-label="Media start"]')
628
+ ?.parentElement?.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
629
+ if (!readout) throw new Error("expected Media start readout");
630
+ expect(readout.textContent).toBe(formatTimingValue(playbackReads()));
631
+ act(() => readout.dispatchEvent(new MouseEvent("click", { bubbles: true })));
632
+ const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
633
+ if (!input) throw new Error("expected Media start input");
634
+ act(() => {
635
+ typeInto(input, "3");
636
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
637
+ });
638
+ expect(playbackReads()).toBe(3);
639
+ act(() => root.unmount());
640
+ });
641
+
642
+ it.each<Record<string, string>>([
643
+ { "playback-start": "1", "media-start": "5" },
644
+ { "media-start": "1" },
645
+ ])("the Design panel's Media start slider writes where playback reads (%o)", (inPoint) => {
646
+ const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
647
+ const onSetAttribute = vi.fn((name: string, value: string) => {
648
+ attrs[name] = value;
649
+ });
650
+ const host = document.createElement("div");
651
+ document.body.append(host);
652
+ const root = createRoot(host);
653
+ act(() => {
654
+ root.render(
655
+ <MediaSection
656
+ projectDir={null}
657
+ element={makeAudioElement(attrs)}
658
+ styles={{}}
659
+ onSetStyle={vi.fn()}
660
+ onSetAttribute={onSetAttribute}
661
+ onSetHtmlAttribute={vi.fn()}
662
+ />,
663
+ );
664
+ });
665
+ const slider = host.querySelector<HTMLInputElement>('input[aria-label="Media start"]');
666
+ if (!slider) throw new Error("expected Media start slider");
667
+ act(() => typeInto(slider, "300"));
668
+ act(() => slider.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })));
669
+ expect(readMediaOffsetSeconds((name) => attrs[name.slice(5)])).toBe(3);
670
+ act(() => root.unmount());
671
+ });
672
+
611
673
  it("commits a typed volume in dB and a typed fade in seconds", () => {
612
674
  const { host, root, onSetAttribute } = renderWithRate(makeAudioElement());
613
675
  const readouts = host.querySelectorAll<HTMLElement>('[data-flat-slider-value="true"]');
@@ -8,6 +8,7 @@ import {
8
8
  formatNumericValue,
9
9
  formatTimingValue,
10
10
  parseNumericValue,
11
+ readClipInPoint,
11
12
  stripQueryAndHash,
12
13
  } from "./propertyPanelHelpers";
13
14
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
@@ -88,10 +89,7 @@ export function FlatMediaSection({
88
89
  ? automatedVolumeValue
89
90
  : (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
90
91
  const volumeFaderPosition = audioGainToFaderPosition(volume);
91
- const mediaStart =
92
- Number.parseFloat(
93
- element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
94
- ) || 0;
92
+ const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
95
93
  const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
96
94
  const playbackRate =
97
95
  rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
@@ -340,11 +338,11 @@ export function FlatMediaSection({
340
338
  max={mediaStartMax * 100}
341
339
  tier={mediaStart === 0 ? "default" : "explicitCustom"}
342
340
  displayValue={formatTimingValue(mediaStart)}
343
- onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
341
+ onCommit={(next) => void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2))}
344
342
  onCommitText={(text) => {
345
343
  const seconds = parseSecondsInput(text);
346
344
  if (seconds === null) return false;
347
- void onSetAttribute("media-start", Math.min(seconds, mediaStartMax).toFixed(2));
345
+ void onSetAttribute(mediaStartAttr, Math.min(seconds, mediaStartMax).toFixed(2));
348
346
  return true;
349
347
  }}
350
348
  />
@@ -5,6 +5,10 @@ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
5
5
  import type { TimelineElement } from "../../player";
6
6
  import { roundToCenti } from "../../utils/rounding";
7
7
  import { findPreviewNode } from "./domEditingElement";
8
+ import {
9
+ playbackStartAttributeForElement,
10
+ readPlaybackStartAttributes,
11
+ } from "../../player/lib/timelineElementHelpers";
8
12
 
9
13
  export type {
10
14
  BackgroundRemovalProgress,
@@ -225,6 +229,17 @@ export function parseNumericValue(value: string | undefined): number | null {
225
229
  return Number.isFinite(parsed) ? parsed : null;
226
230
  }
227
231
 
232
+ export function readClipInPoint(dataAttributes: Record<string, string> | undefined): {
233
+ mediaStart: number;
234
+ mediaStartAttr: string;
235
+ } {
236
+ const inPoint = readPlaybackStartAttributes((name) => dataAttributes?.[name.slice(5)]);
237
+ return {
238
+ mediaStart: inPoint.playbackStart ?? 0,
239
+ mediaStartAttr: playbackStartAttributeForElement(inPoint).slice(5),
240
+ };
241
+ }
242
+
228
243
  export function formatTimingValue(seconds: number): string {
229
244
  if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
230
245
  return `${seconds.toFixed(2)}s`;
@@ -8,6 +8,7 @@ import {
8
8
  formatTimingValue,
9
9
  LABEL,
10
10
  parseNumericValue,
11
+ readClipInPoint,
11
12
  RESPONSIVE_GRID,
12
13
  stripQueryAndHash,
13
14
  } from "./propertyPanelHelpers";
@@ -57,10 +58,7 @@ export function MediaSection({
57
58
  const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
58
59
  const volumeFaderPosition = audioGainToFaderPosition(volume);
59
60
 
60
- const mediaStart =
61
- Number.parseFloat(
62
- element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
63
- ) || 0;
61
+ const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
64
62
 
65
63
  const hasLoop = el.hasAttribute("loop");
66
64
  const hasMuted = el.hasAttribute("muted");
@@ -297,7 +295,7 @@ export function MediaSection({
297
295
  displayValue={formatTimingValue(mediaStart)}
298
296
  formatDisplayValue={(next) => formatTimingValue(next / 100)}
299
297
  onCommit={(next) => {
300
- void onSetAttribute("media-start", (next / 100).toFixed(2));
298
+ void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2));
301
299
  }}
302
300
  />
303
301
  </div>
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import { reapplyPositionEditsAfterSeek } from "./manualEditsDom";
3
+ import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
4
4
  import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes";
5
5
 
6
6
  /**
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
4
+ import {
5
+ STUDIO_PATH_OFFSET_ATTR,
6
+ STUDIO_ROTATION_ATTR,
7
+ STUDIO_ROTATION_PROP,
8
+ } from "./manualEditsTypes";
9
+ import { STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
10
+
11
+ describe("reapplyPositionEditsAfterSeek", () => {
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("does no per-edit work on a film Studio never edited", () => {
18
+ document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
19
+ const queryAll = vi.spyOn(document, "querySelectorAll");
20
+
21
+ reapplyPositionEditsAfterSeek(document);
22
+
23
+ expect(queryAll).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("looks nothing up on later seeks of a film Studio never edited", () => {
27
+ document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
28
+ const query = vi.spyOn(document, "querySelector");
29
+ reapplyPositionEditsAfterSeek(document);
30
+ // A comma selector list walks the whole DOM in Chrome; lone attribute selectors do not.
31
+ for (const [selector] of query.mock.calls) expect(selector).not.toContain(",");
32
+ query.mockClear();
33
+ const queryAll = vi.spyOn(document, "querySelectorAll");
34
+
35
+ reapplyPositionEditsAfterSeek(document);
36
+
37
+ expect(query).not.toHaveBeenCalled();
38
+ expect(queryAll).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it("looks up no more on a film with an edit than the reapply itself does", () => {
42
+ document.body.innerHTML = `<div id="a" ${STUDIO_ROTATION_ATTR}="true" style="${STUDIO_ROTATION_PROP}: 8deg"></div>`;
43
+ reapplyPositionEditsAfterSeek(document);
44
+ const query = vi.spyOn(document, "querySelector");
45
+ const queryAll = vi.spyOn(document, "querySelectorAll");
46
+
47
+ reapplyPositionEditsAfterSeek(document);
48
+
49
+ // Three edit kinds, each with a legacy mark, plus motion: seven, as before the skip.
50
+ expect(query.mock.calls.length + queryAll.mock.calls.length).toBeLessThanOrEqual(7);
51
+ });
52
+
53
+ it("reapplies an edit made after the last seek, in the same task", () => {
54
+ document.body.innerHTML = '<div id="a"></div>';
55
+ reapplyPositionEditsAfterSeek(document);
56
+ const el = document.getElementById("a") as HTMLElement;
57
+ el.setAttribute(STUDIO_ROTATION_ATTR, "true");
58
+ el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
59
+
60
+ reapplyPositionEditsAfterSeek(document);
61
+
62
+ expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
63
+ });
64
+
65
+ it("reapplies an edited element inserted after the last seek", async () => {
66
+ document.body.innerHTML = '<div id="a"></div>';
67
+ reapplyPositionEditsAfterSeek(document);
68
+ const queryAll = vi.spyOn(document, "querySelectorAll");
69
+ reapplyPositionEditsAfterSeek(document);
70
+ expect(queryAll).not.toHaveBeenCalled(); // the film reads as unedited
71
+ queryAll.mockRestore();
72
+ // A paste, scene swap or undo puts back a node that already carries its mark.
73
+ const el = document.createElement("div");
74
+ el.setAttribute(STUDIO_ROTATION_ATTR, "true");
75
+ el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
76
+ document.body.append(el);
77
+ await Promise.resolve();
78
+
79
+ reapplyPositionEditsAfterSeek(document);
80
+
81
+ expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
82
+ });
83
+
84
+ it("does not rescan after a style write, the one GSAP makes every frame", async () => {
85
+ document.body.innerHTML = '<div id="a"></div>';
86
+ reapplyPositionEditsAfterSeek(document);
87
+ (document.getElementById("a") as HTMLElement).style.setProperty("opacity", "0.5");
88
+ await Promise.resolve();
89
+ const query = vi.spyOn(document, "querySelector");
90
+
91
+ reapplyPositionEditsAfterSeek(document);
92
+
93
+ expect(query).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("still clears a motion timeline whose marks an undo removed", () => {
97
+ document.body.innerHTML = '<div id="a"></div>';
98
+ const kill = vi.fn();
99
+ const win = window as unknown as { __timelines?: Record<string, unknown> };
100
+ win.__timelines = { [STUDIO_MOTION_TIMELINE_ID]: { kill } };
101
+
102
+ reapplyPositionEditsAfterSeek(document);
103
+
104
+ expect(kill).toHaveBeenCalledTimes(1);
105
+ delete win.__timelines;
106
+ });
107
+
108
+ it("still migrates a legacy double-prefixed edit mark", () => {
109
+ document.body.innerHTML = `<div id="a" data-${STUDIO_PATH_OFFSET_ATTR}="true"></div>`;
110
+
111
+ reapplyPositionEditsAfterSeek(document);
112
+
113
+ const el = document.getElementById("a");
114
+ expect(el?.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
115
+ expect(el?.hasAttribute(`data-${STUDIO_PATH_OFFSET_ATTR}`)).toBe(false);
116
+ });
117
+ });
@@ -14,7 +14,14 @@ import {
14
14
  type HfAudioFxChain,
15
15
  } from "@hyperframes/core/audio-fx";
16
16
  import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
17
- import type { HfAutomation } from "@hyperframes/core/audio-automation";
17
+ import type { HfAutomation, HfAutomationPoint } from "@hyperframes/core/audio-automation";
18
+ import {
19
+ resolveRateSpec,
20
+ sourceTimeAt,
21
+ timeAtSourceTime,
22
+ type RateSpec,
23
+ } from "@hyperframes/core/speed-ramp";
24
+ import { readPlaybackRate } from "@hyperframes/parsers/media-duration";
18
25
  import {
19
26
  automationAttrValue,
20
27
  HF_AUDIO_AUTOMATION_ATTR,
@@ -23,6 +30,7 @@ import {
23
30
  } from "./propertyPanelAutomation";
24
31
  import { trackLeveller } from "./audioFxTelemetry.js";
25
32
  import type { DomEditSelection } from "./domEditingTypes";
33
+ import { readClipInPoint } from "./propertyPanelHelpers";
26
34
  import { useAuditionTransport } from "./useAuditionTransport.js";
27
35
 
28
36
  /**
@@ -80,37 +88,54 @@ export function useFxLevelling(
80
88
  *
81
89
  * A lane's `t` is seconds from the start of the CLIP, but the decode is the
82
90
  * whole file from its first sample — so measuring a trimmed clip produced an
83
- * envelope offset by the trim, and every correction landed early by exactly
84
- * `media-start`. Slicing here is what puts the two clocks back on the same
91
+ * envelope offset by the trim, and every correction landed early by exactly the
92
+ * in-point. Slicing here is what puts the two clocks back on the same
85
93
  * zero.
86
94
  */
87
- const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
88
- const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
95
+ const clipWindow = (audio: { samples: Float32Array; sampleRate: number }, rate: RateSpec) => {
96
+ const { mediaStart } = readClipInPoint(element.dataAttributes);
89
97
  const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
90
98
  const from = mediaStart
91
99
  ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
92
100
  : 0;
93
101
  const to = duration
94
- ? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
102
+ ? Math.min(
103
+ audio.samples.length,
104
+ from + Math.ceil(sourceTimeAt(rate, duration) * audio.sampleRate),
105
+ )
95
106
  : audio.samples.length;
96
107
  return from === 0 && to === audio.samples.length
97
108
  ? audio.samples
98
109
  : audio.samples.subarray(from, to);
99
110
  };
100
111
 
112
+ /** The levelled chain and its lane, with the lane moved from the source's clock onto the clip's. */
113
+ const measureLevelling = (audio: { samples: Float32Array; sampleRate: number }) => {
114
+ const attr = (name: string) => element.dataAttributes?.[name.slice(5)];
115
+ const rate = resolveRateSpec(attr("data-automation"), readPlaybackRate(attr));
116
+ const result = levellingResult(chain, clipWindow(audio, rate), audio.sampleRate);
117
+ if (!result) return null;
118
+ const lane = result.automation.lanes[0];
119
+ const toClipTime = (point: HfAutomationPoint) => ({
120
+ ...point,
121
+ t: Number(timeAtSourceTime(rate, point.t).toFixed(3)),
122
+ });
123
+ return { chain: result.chain, lane: lane && { ...lane, points: lane.points.map(toClipTime) } };
124
+ };
125
+
101
126
  const runLeveller = async (): Promise<void> => {
102
127
  setAnalysing(true);
103
128
  try {
104
129
  const audio = await decodeTrack();
105
130
  if (!audio) return;
106
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
131
+ const result = measureLevelling(audio);
107
132
  if (!result) return;
108
133
  trackLeveller("run");
109
134
  await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
110
135
  // Merged by target, never written wholesale: the script describes its own
111
136
  // lane only, and replacing the attribute would take the carve's lanes and
112
137
  // the volume lane with it.
113
- const lane = result.automation.lanes[0];
138
+ const lane = result.lane;
114
139
  if (lane) {
115
140
  void onSetAttributeQuiet(
116
141
  HF_AUDIO_AUTOMATION_ATTR,
@@ -160,10 +185,10 @@ export function useFxLevelling(
160
185
  const audio = await decodeTrack();
161
186
  // Gone, or superseded by a later hover. Either way this result is stale.
162
187
  if (!audio || run !== auditionRun.current) return;
163
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
188
+ const result = measureLevelling(audio);
164
189
  if (!result || run !== auditionRun.current) return;
165
190
  void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
166
- const lane = result.automation.lanes[0];
191
+ const lane = result.lane;
167
192
  if (lane) {
168
193
  void onSetAttributeLive(
169
194
  HF_AUDIO_AUTOMATION_ATTR,
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
11
11
  import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
+ type AudioMeterStripProps,
14
15
  evictGoneMeterState,
15
16
  followMeterHook,
16
17
  stepAndPaintStrips,
@@ -20,8 +21,9 @@ import {
20
21
 
21
22
  const iframe = { contentWindow: null as unknown };
22
23
  const previewIframeRef = { current: iframe };
24
+ let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
23
25
  vi.mock("../../contexts/StudioContext", () => ({
24
- useStudioShellContext: () => ({ previewIframeRef }),
26
+ useStudioShellContextOptional: () => shell,
25
27
  }));
26
28
 
27
29
  const onSetAudioGroupAttributeLive = vi.fn();
@@ -88,6 +90,7 @@ const tick = () => {
88
90
  };
89
91
 
90
92
  beforeEach(() => {
93
+ shell = { previewIframeRef };
91
94
  frames = [];
92
95
  vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
93
96
  vi.stubGlobal("cancelAnimationFrame", () => {});
@@ -103,11 +106,11 @@ afterEach(() => {
103
106
  vi.unstubAllGlobals();
104
107
  });
105
108
 
106
- function mount() {
109
+ function mount(props: AudioMeterStripProps = {}) {
107
110
  const host = document.createElement("div");
108
111
  document.body.append(host);
109
112
  const root = createRoot(host);
110
- act(() => root.render(<AudioMeterStrip />));
113
+ act(() => root.render(<AudioMeterStrip {...props} />));
111
114
  roots.push(root);
112
115
  return { host, root };
113
116
  }
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
177
180
  expect(second.read).toHaveBeenCalled();
178
181
  });
179
182
 
183
+ it("reads a host's preview iframe over the shell's", () => {
184
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
185
+ const shellHook = makeHook();
186
+ setHook(shellHook);
187
+ const hostHook = makeHook();
188
+ const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
189
+ mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
190
+ tick();
191
+ expect(hostHook.start).toHaveBeenCalledTimes(1);
192
+ expect(shellHook.start).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("mounts with no shell and no preview iframe", () => {
196
+ shell = null;
197
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
198
+ const { host } = mount();
199
+ tick();
200
+ expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
201
+ });
202
+
180
203
  it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
181
204
  usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
182
205
  const { host } = mount();