@hyperframes/studio 0.8.81 → 0.8.83

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -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
@@ -950,6 +989,141 @@ describe("AudioFxGroup dynamic carve", () => {
950
989
  expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
951
990
  expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
952
991
  });
992
+
993
+ describe("on each clip's own clock", () => {
994
+ type Point = { t: number; v: number };
995
+ /** `hz` at `amp` over [from, to) of a `seconds`-long file, silence elsewhere. */
996
+ const tone = (seconds: number, [from, to]: [number, number], hz = 1000, amp = 0.7) =>
997
+ Float32Array.from({ length: SAMPLE_RATE * seconds }, (_, i) => {
998
+ const t = i / SAMPLE_RATE;
999
+ return t >= from && t < to ? amp * Math.sin(2 * Math.PI * hz * t) : 0;
1000
+ });
1001
+
1002
+ /** Each file decodes to its own samples, found by the URL that was fetched. */
1003
+ function stubFiles(files: Record<string, Float32Array>): void {
1004
+ vi.stubGlobal(
1005
+ "fetch",
1006
+ vi.fn(async (url: string) => ({ arrayBuffer: async () => url })),
1007
+ );
1008
+ vi.stubGlobal(
1009
+ "OfflineAudioContext",
1010
+ class {
1011
+ decodeAudioData = async (url: string) => {
1012
+ const data = Object.entries(files).find(([name]) => url.endsWith(name))![1];
1013
+ return { sampleRate: SAMPLE_RATE, getChannelData: () => data };
1014
+ };
1015
+ },
1016
+ );
1017
+ }
1018
+
1019
+ const valueAt = (points: Point[], t: number): number => {
1020
+ const i = points.findIndex((p) => p.t >= t);
1021
+ if (i <= 0) return points.at(i)!.v;
1022
+ const [a, b] = [points[i - 1]!, points[i]!];
1023
+ return a.v + ((b.v - a.v) * (t - a.t)) / (b.t - a.t);
1024
+ };
1025
+
1026
+ /** Carves the bed against `vo` and reads back the lane driving the first node of `type`. */
1027
+ async function carveLane(
1028
+ type: string,
1029
+ voice: Record<string, string>,
1030
+ bed: Record<string, string> = {},
1031
+ strength = 0,
1032
+ ) {
1033
+ const { host, onSetAttributeQuiet } = mount({
1034
+ "fx-chain": carvedChain,
1035
+ "fx-carve": settings(true, { strength, sources: [] }),
1036
+ start: "0",
1037
+ ...bed,
1038
+ });
1039
+ const vo = document.getElementById("vo")!;
1040
+ vo.setAttribute("src", "voice.wav");
1041
+ for (const [name, value] of Object.entries(voice)) vo.setAttribute(`data-${name}`, value);
1042
+ document.getElementById("bed")!.setAttribute("src", "bed.wav");
1043
+ await act(async () => {
1044
+ pickSource(host, "vo");
1045
+ });
1046
+ const calls = onSetAttributeQuiet.mock.calls;
1047
+ const nodes = parseWrite(writeTo(calls, "data-fx-chain")!).nodes as {
1048
+ id: string;
1049
+ type: string;
1050
+ params: { gain: number; frequency: number };
1051
+ }[];
1052
+ const node = nodes.find((n) => n.type === type)!;
1053
+ const lanes = parseWrite(writeTo(calls, "data-automation")!).lanes as {
1054
+ target: string;
1055
+ points: Point[];
1056
+ }[];
1057
+ const points = lanes.find((l) => l.target === `fx.${node.id}.gain`)!.points;
1058
+ return {
1059
+ depth: node.params.gain,
1060
+ frequency: node.params.frequency,
1061
+ points,
1062
+ at: (t: number) => valueAt(points, t),
1063
+ };
1064
+ }
1065
+
1066
+ /** Quiet 1 kHz speech at 0-2 s, loud 4 kHz speech at 4-6 s, in an 8 s file. */
1067
+ const lateSpeech = () => {
1068
+ const after = tone(8, [4, 6], 4000, 0.9);
1069
+ return tone(8, [0, 2], 1000, 0.3).map((v, i) => v + after[i]!);
1070
+ };
1071
+
1072
+ it("ignores speech after the bed has stopped playing", async () => {
1073
+ // The bed file runs 8 s, but the clip plays only its first 3 s.
1074
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(8, [0, 8], 200) });
1075
+ const lane = await carveLane("peaking", { start: "0" }, { duration: "3" });
1076
+ expect(lane.frequency).toBe(1000);
1077
+ });
1078
+
1079
+ it("ignores speech after a bed with no duration runs out, when it ducks", async () => {
1080
+ // No data-duration: the bed plays its 3 s file. Strength 0.05 ducks and keeps one band.
1081
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1082
+ const lane = await carveLane("peaking", { start: "0" }, {}, 0.05);
1083
+ expect(lane.frequency).toBe(1000);
1084
+ });
1085
+
1086
+ it.each([
1087
+ ["no duration", {}],
1088
+ ["a duration longer than its file", { duration: "6" }],
1089
+ ])("ignores speech after a 3 s bed file with %s, without ducking", async (_, bed) => {
1090
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1091
+ const lane = await carveLane("peaking", { start: "0" }, bed);
1092
+ expect(lane.frequency).toBe(1000);
1093
+ });
1094
+
1095
+ it("cuts where a trimmed voice speaks, not where its file does", async () => {
1096
+ // File speech 2-3.5 s; the clip plays 2-4 s of it, so the bed hears it at 0-1.5 s.
1097
+ stubFiles({ "voice.wav": tone(6, [2, 3.5]), "bed.wav": tone(8, [0, 8], 200) });
1098
+ const lane = await carveLane("peaking", { start: "0", "media-start": "2", duration: "2" });
1099
+ expect({ speaking: lane.at(0.8) <= lane.depth * 0.5, after: lane.at(3) }).toEqual({
1100
+ speaking: true,
1101
+ after: 0,
1102
+ });
1103
+ });
1104
+
1105
+ it("cuts over half the source length for a voice at 2x", async () => {
1106
+ // File speech 1-3 s at 2x: the bed hears it at 0.5-1.5 s.
1107
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [0, 8], 200) });
1108
+ const lane = await carveLane("peaking", { start: "0", "playback-rate": "2", duration: "2" });
1109
+ expect({
1110
+ speaking: lane.at(0.8) <= lane.depth * 0.5 && lane.at(1.3) <= lane.depth * 0.5,
1111
+ after: lane.at(2.5),
1112
+ }).toEqual({ speaking: true, after: 0 });
1113
+ });
1114
+
1115
+ it("ducks against the part of the bed that plays", async () => {
1116
+ // The bed file is silent for 4 s and loud after; the clip starts 4 s in, so it is loud under the voice.
1117
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [4, 8], 200, 0.9) });
1118
+ const lane = await carveLane(
1119
+ "gain",
1120
+ { start: "0" },
1121
+ { "media-start": "4", duration: "4" },
1122
+ 1,
1123
+ );
1124
+ expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThan(0);
1125
+ });
1126
+ });
953
1127
  });
954
1128
 
955
1129
  describe("AudioFxGroup successive edits", () => {
@@ -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
+ });
@@ -33,7 +33,8 @@ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAu
33
33
  import { trackCarveChanged } from "./audioFxTelemetry.js";
34
34
  import type { DomEditSelection } from "./domEditingTypes";
35
35
  import { usePlayerStore } from "../../player";
36
- import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
36
+ import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
37
+ import { readClipClock } from "./clipAudioClock.js";
37
38
  import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
38
39
  import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
39
40
 
@@ -93,11 +94,8 @@ function carveNeedsReanalysis(
93
94
  * what lets the src and the start be non-null by construction downstream
94
95
  * instead of by assertion.
95
96
  */
96
- function resolveCarveVoices(
97
- doc: Document,
98
- sources: readonly string[],
99
- ): { src: string; start: string | null }[] {
100
- const voices: { src: string; start: string | null }[] = [];
97
+ function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
98
+ const voices: CarveClip[] = [];
101
99
  for (const id of sources) {
102
100
  const el = doc.getElementById(id);
103
101
  // By tag name, not `instanceof HTMLAudioElement`: these elements belong to
@@ -106,7 +104,8 @@ function resolveCarveVoices(
106
104
  if (el?.tagName !== "AUDIO") continue;
107
105
  const src = el.getAttribute("src");
108
106
  if (!src) continue;
109
- voices.push({ src, start: el.getAttribute("data-start") });
107
+ const clock = readClipClock((name) => el.getAttribute(name));
108
+ voices.push({ src, start: el.getAttribute("data-start"), clock });
110
109
  }
111
110
  return voices;
112
111
  }
@@ -363,14 +362,11 @@ export function useFxCarve(
363
362
  if (voices.length === 0) return;
364
363
  setAnalysing(true);
365
364
  try {
366
- const bedSrc = element.element?.getAttribute("src");
367
- const measured = await measureCarve(
368
- doc,
369
- voices,
370
- active.strength,
371
- element.dataAttributes?.["start"],
372
- bedSrc,
373
- );
365
+ const measured = await measureCarve(doc, voices, active.strength, {
366
+ src: element.element?.getAttribute("src"),
367
+ start: element.dataAttributes?.["start"],
368
+ clock: readClipClock((name) => element.dataAttributes?.[name.slice(5)]),
369
+ });
374
370
  if (!measured) return;
375
371
  await persistMeasuredCarve(measured);
376
372
  } catch {