@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
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import type { GestureState } from "./domEditOverlayGestures";
5
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+ import {
7
+ hasStudioPendingEdits,
8
+ revertNewestStudioPendingEdit,
9
+ } from "../../utils/studioPendingEdits";
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ const ref = <T>(current: T) => ({ current });
16
+ const pointer = (x: number, y: number) => ({
17
+ clientX: x,
18
+ clientY: y,
19
+ pointerId: 1,
20
+ button: 0,
21
+ preventDefault() {},
22
+ stopPropagation() {},
23
+ currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
24
+ });
25
+
26
+ /** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27
+ function dragWithSaveRunning(save: Promise<void>) {
28
+ const element = document.createElement("div");
29
+ element.style.setProperty("translate", "40px 30px");
30
+ document.body.append(element);
31
+ const selection = { element, capabilities: { canApplyManualOffset: true } };
32
+ const handlers = createDomEditOverlayGestureHandlers({
33
+ selectionRef: ref(selection as unknown as DomEditSelection),
34
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
35
+ boxRef: ref(document.createElement("div")),
36
+ overlayRef: ref(null),
37
+ iframeRef: ref(null),
38
+ gestureRef: ref<GestureState | null>(null),
39
+ rafPausedRef: ref(false),
40
+ onManualDragStartRef: ref(vi.fn()),
41
+ onBlockedMoveRef: ref(vi.fn()),
42
+ onPathOffsetCommitRef: ref(vi.fn(() => save)),
43
+ snapGuidesRef: ref(null),
44
+ groupGestureRef: ref(null),
45
+ blockedMoveRef: ref(null),
46
+ setOverlayRect: vi.fn(),
47
+ suppressNextBoxClickRef: ref(false),
48
+ hoverSelectionRef: ref(null),
49
+ onCanvasMouseDown: vi.fn(),
50
+ } as never);
51
+ expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
52
+ handlers.onPointerUp(pointer(110, 70) as never);
53
+ return element;
54
+ }
55
+
56
+ it("a drag whose save is still running can be painted back at once, and shown again", async () => {
57
+ let saved!: () => void;
58
+ const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
59
+ const moved = element.style.getPropertyValue("translate");
60
+ expect(moved).not.toBe("40px 30px");
61
+
62
+ const showAgain = revertNewestStudioPendingEdit();
63
+ expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
64
+ expect(revertNewestStudioPendingEdit()).toBeNull();
65
+ showAgain!();
66
+ expect(element.style.getPropertyValue("translate")).toBe(moved);
67
+
68
+ saved();
69
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
+ expect(revertNewestStudioPendingEdit()).toBeNull();
71
+ });
@@ -13,6 +13,7 @@ import {
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
14
  import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
+ import type { StudioEditRevert } from "../../utils/studioPendingEdits";
16
17
 
17
18
  interface OffsetDragGsap {
18
19
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -510,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
510
511
  endStudioManualEditGesture(member.element, member.gestureToken);
511
512
  }
512
513
 
514
+ /** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
515
+ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
516
+ if (!members.every((member) => member.plainTranslate)) return null;
517
+ return () => {
518
+ const shown = members.map((member) => captureStudioPathOffset(member.element));
519
+ for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
520
+ return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
521
+ };
522
+ }
523
+
513
524
  /** Roll back a FAILED drag to the exact gesture-start state. */
514
525
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
515
526
  for (const member of members) {
@@ -209,4 +209,63 @@ describe("buildKeepSoundCutoutEdit", () => {
209
209
  const doc = parse(applyEdit(taken, edit));
210
210
  expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
211
211
  });
212
+
213
+ it("reads the authored src from the source, not the live element", () => {
214
+ const sub = source.replace('src="assets/talk.mp4"', 'src="../assets/a.mp4"');
215
+ const edit = buildKeepSoundCutoutEdit({
216
+ video: liveVideo(),
217
+ videoId: "clip",
218
+ target: { id: "clip" },
219
+ cutoutSrc: "assets/cut.webm",
220
+ });
221
+ const doc = parse(applyEdit(sub, edit));
222
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("../assets/a.mp4");
223
+ expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
224
+ });
225
+
226
+ it("takes the first <source> child when the video has no src attribute", () => {
227
+ const withSource = source.replace(
228
+ /<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
229
+ '<video id="clip"$1$2><source src="assets/s.mp4" type="video/mp4"></video>',
230
+ );
231
+ const edit = buildKeepSoundCutoutEdit({
232
+ video: liveVideo(),
233
+ videoId: "clip",
234
+ target: { id: "clip" },
235
+ cutoutSrc: "assets/cut.webm",
236
+ });
237
+ const doc = parse(applyEdit(withSource, edit));
238
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/s.mp4");
239
+ });
240
+
241
+ it("reports when the audio insert did not land", () => {
242
+ const edit = buildKeepSoundCutoutEdit({
243
+ video: liveVideo(),
244
+ videoId: "clip",
245
+ target: { id: "missing" },
246
+ cutoutSrc: "assets/cut.webm",
247
+ });
248
+ edit.prepareContent(source);
249
+ expect(edit.audioInserted()).toBe(false);
250
+ const ok = buildKeepSoundCutoutEdit({
251
+ video: liveVideo(),
252
+ videoId: "clip",
253
+ target: { id: "clip" },
254
+ cutoutSrc: "assets/cut.webm",
255
+ });
256
+ ok.prepareContent(source);
257
+ expect(ok.audioInserted()).toBe(true);
258
+ });
259
+
260
+ it("copies loop onto the linked audio", () => {
261
+ const video = makeVideo({ id: "clip", src: "assets/talk.mp4", loop: "" });
262
+ const edit = buildKeepSoundCutoutEdit({
263
+ video,
264
+ videoId: "clip",
265
+ target: { id: "clip" },
266
+ cutoutSrc: "assets/cut.webm",
267
+ });
268
+ const doc = parse(applyEdit(source, edit));
269
+ expect(doc.getElementById("clip-audio")?.hasAttribute("loop")).toBe(true);
270
+ });
212
271
  });
@@ -12,7 +12,9 @@ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
12
12
  import {
13
13
  escapeHtmlAttribute,
14
14
  findTagByTarget,
15
+ applyPatchByTarget,
15
16
  readAttributeByTarget,
17
+ unescapeHtmlAttribute,
16
18
  type PatchOperation,
17
19
  type PatchTarget,
18
20
  } from "../../utils/sourcePatcher";
@@ -24,6 +26,9 @@ import { generateId } from "../../utils/generateId";
24
26
  const MEDIA_LINK_ATTR = "data-link";
25
27
  const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
26
28
 
29
+ const SOUND_NOT_KEPT_MESSAGE =
30
+ "Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
31
+
27
32
  const MOVED_SOUND_ATTRS = [
28
33
  "data-volume",
29
34
  HF_AUDIO_FADE_IN_ATTR,
@@ -131,6 +136,15 @@ function formatAttrs(attrs: Array<[string, string]>): string {
131
136
  return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
132
137
  }
133
138
 
139
+ function readAuthoredSrc(source: string, target: PatchTarget): string {
140
+ const tag = findTagByTarget(source, target);
141
+ if (!tag) return "";
142
+ const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
143
+ if (own?.[2]) return unescapeHtmlAttribute(own[2]);
144
+ const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
145
+ return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
146
+ }
147
+
134
148
  function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
135
149
  const match = findTagByTarget(source, target);
136
150
  if (!match) return source;
@@ -145,6 +159,7 @@ function insertBeforeTarget(source: string, target: PatchTarget, markup: string)
145
159
  export interface KeepSoundCutoutEdit {
146
160
  ops: PatchOperation[];
147
161
  prepareContent: (source: string) => string;
162
+ audioInserted: () => boolean;
148
163
  }
149
164
 
150
165
  export function buildKeepSoundCutoutEdit(input: {
@@ -155,10 +170,11 @@ export function buildKeepSoundCutoutEdit(input: {
155
170
  }): KeepSoundCutoutEdit {
156
171
  const { video, target } = input;
157
172
  const linkId = mintLinkId(video.ownerDocument);
158
- const originalSrc = video.getAttribute("src") ?? "";
173
+ let audioInserted = false;
159
174
 
160
175
  const movedSound: Array<[string, string]> = [];
161
- const ops: PatchOperation[] = [...cutoutOps({ isVideo: true, cutoutSrc: input.cutoutSrc })];
176
+ const cutoutSrcOp = htmlAttr("src", input.cutoutSrc);
177
+ const ops: PatchOperation[] = [htmlAttr("muted", "true"), dataAttr("has-audio", null)];
162
178
  for (const name of MOVED_SOUND_ATTRS) {
163
179
  const value = video.getAttribute(name);
164
180
  if (value === null) continue;
@@ -174,6 +190,7 @@ export function buildKeepSoundCutoutEdit(input: {
174
190
  ops.push(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
175
191
 
176
192
  const prepareContent = (source: string): string => {
193
+ const originalSrc = readAuthoredSrc(source, target);
177
194
  const audioId = firstFreeName(
178
195
  `${input.videoId || "video"}-audio`,
179
196
  new Set(collectHtmlIds(source)),
@@ -193,10 +210,14 @@ export function buildKeepSoundCutoutEdit(input: {
193
210
  [MEDIA_LINK_ATTR, linkId],
194
211
  ...movedSound,
195
212
  ];
196
- return insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
213
+ if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
214
+ const cut = applyPatchByTarget(source, target, cutoutSrcOp);
215
+ const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
216
+ audioInserted = inserted !== cut;
217
+ return inserted;
197
218
  };
198
219
 
199
- return { ops, prepareContent };
220
+ return { ops, prepareContent, audioInserted: () => audioInserted };
200
221
  }
201
222
 
202
223
  export interface MediaEditContext {
@@ -231,23 +252,31 @@ export async function commitCutout(
231
252
  const isVideo = isVideoSelection(ctx.element);
232
253
  const label = "Remove background";
233
254
  const keep = isVideo && keepSound;
234
- const landed = keep
235
- ? await commitKeepSoundCutout(ctx, cutoutSrc, label)
236
- : await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
255
+ if (!keep) {
256
+ const applied = await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
257
+ if (!applied) throw new Error("Couldn't apply the cutout");
258
+ return "Applied cutout";
259
+ }
260
+ const { landed, soundKept } = await commitKeepSoundCutout(ctx, cutoutSrc, label);
237
261
  if (!landed) throw new Error("Couldn't apply the cutout");
238
- return keep ? SOUND_KEPT_ON_LINKED_AUDIO_STAGE : "Applied cutout";
262
+ if (!soundKept) throw new Error(SOUND_NOT_KEPT_MESSAGE);
263
+ return SOUND_KEPT_ON_LINKED_AUDIO_STAGE;
239
264
  }
240
265
 
241
- function commitKeepSoundCutout(
266
+ async function commitKeepSoundCutout(
242
267
  ctx: MediaEditContext,
243
268
  cutoutSrc: string,
244
269
  label: string,
245
- ): Promise<boolean> {
270
+ ): Promise<{ landed: boolean; soundKept: boolean }> {
246
271
  const edit = buildKeepSoundCutoutEdit({
247
272
  video: ctx.element.element,
248
273
  videoId: ctx.element.id ?? null,
249
274
  target: buildDomEditPatchTarget(ctx.element),
250
275
  cutoutSrc,
251
276
  });
252
- return ctx.commit(ctx.element, edit.ops, { label, prepareContent: edit.prepareContent });
277
+ const landed = await ctx.commit(ctx.element, edit.ops, {
278
+ label,
279
+ prepareContent: edit.prepareContent,
280
+ });
281
+ return { landed, soundKept: landed && edit.audioInserted() };
253
282
  }
@@ -103,11 +103,11 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
103
103
  return { host, onSetAttributeQuiet, onSetAttributeLive };
104
104
  }
105
105
 
106
- function mountGroup(memberStart: number) {
106
+ function mountGroup(memberStart: number, memberTag: "audio" | "video" = "audio") {
107
107
  const bus = document.createElement("hf-audio-group");
108
108
  bus.id = "voiceover";
109
109
  document.body.append(bus);
110
- const member = document.createElement("audio");
110
+ const member = document.createElement(memberTag);
111
111
  member.id = "vo-1";
112
112
  member.setAttribute("data-audio-group", "voiceover");
113
113
  member.setAttribute("data-start", String(memberStart));
@@ -649,6 +649,16 @@ describe("AudioFxGroup dynamic carve", () => {
649
649
  leaveShelf(host);
650
650
  });
651
651
 
652
+ it("seeks a group audition to an audible video member's span", () => {
653
+ act(() =>
654
+ usePlayerStore.setState({ isPlaying: false, currentTime: 2, requestedSeekTime: null }),
655
+ );
656
+ const host = mountGroup(7, "video");
657
+ hoverPreset(host);
658
+ expect(store().requestedSeekTime).toBe(7);
659
+ leaveShelf(host);
660
+ });
661
+
652
662
  it("leaves a transport the author started alone", () => {
653
663
  // Stopping their playback because they passed over a preset would be the
654
664
  // panel taking a decision nobody offered it.
@@ -1621,7 +1631,14 @@ describe("AudioFxGroup carve by default", () => {
1621
1631
  describe("AudioFxGroup carve source list", () => {
1622
1632
  /** Mount a bed alongside tracks named however the test needs. */
1623
1633
  const mountWith = (
1624
- tracks: { id: string; src?: string; start?: string; duration?: string }[],
1634
+ tracks: {
1635
+ id: string;
1636
+ src?: string;
1637
+ start?: string;
1638
+ duration?: string;
1639
+ tag?: "audio" | "video";
1640
+ muted?: boolean;
1641
+ }[],
1625
1642
  bedAttrs: Record<string, string> = {},
1626
1643
  ) => {
1627
1644
  const bed = document.createElement("audio");
@@ -1629,8 +1646,9 @@ describe("AudioFxGroup carve source list", () => {
1629
1646
  for (const [k, v] of Object.entries(bedAttrs)) bed.setAttribute(`data-${k}`, v);
1630
1647
  document.body.append(bed);
1631
1648
  for (const t of tracks) {
1632
- const el = document.createElement("audio");
1649
+ const el = document.createElement(t.tag ?? "audio");
1633
1650
  el.id = t.id;
1651
+ if (t.muted) el.setAttribute("muted", "");
1634
1652
  if (t.src) el.setAttribute("src", t.src);
1635
1653
  if (t.start !== undefined) el.setAttribute("data-start", t.start);
1636
1654
  if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
@@ -1660,6 +1678,16 @@ describe("AudioFxGroup carve source list", () => {
1660
1678
  return { host, options, boxes, onSetAttributeQuiet };
1661
1679
  };
1662
1680
 
1681
+ it("offers an audible video as a voice and never a muted one", () => {
1682
+ const { options } = mountWith([
1683
+ { id: "a-roll", tag: "video" },
1684
+ { id: "b-roll", tag: "video", muted: true },
1685
+ { id: "narration" },
1686
+ ]);
1687
+ expect(options).toContain("a-roll");
1688
+ expect(options).not.toContain("b-roll");
1689
+ });
1690
+
1663
1691
  it("reads the one voice out instead of offering a picker with one entry", () => {
1664
1692
  // A question with one answer is not a question. It is also the common case: a
1665
1693
  // narration and a bed, with a couple of stings that cannot be the voice.
@@ -54,6 +54,7 @@ import {
54
54
  resolveAudioGroups,
55
55
  } from "@hyperframes/core/audio-groups";
56
56
  import { useFxLevelling } from "./useFxLevelling.js";
57
+ import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
57
58
 
58
59
  function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
59
60
  const start = Number.parseFloat(startRaw ?? "");
@@ -70,8 +71,9 @@ function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
70
71
  if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
71
72
  const doc = element.element?.ownerDocument;
72
73
  if (!doc) return [];
73
- return [...doc.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)]
74
+ return [...doc.querySelectorAll(`[${HF_AUDIO_GROUP_ATTR}]`)]
74
75
  .filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
76
+ .filter(isCarveVoiceElement)
75
77
  .flatMap((member) => {
76
78
  const span = auditionSpan(
77
79
  member.getAttribute("data-start") ?? undefined,
@@ -652,7 +652,12 @@ function makeHandlers() {
652
652
  ops: PatchOperation[],
653
653
  options: BatchOptions,
654
654
  ) => Promise<boolean>
655
- >(async () => true),
655
+ >(async (_selection, _ops, options) => {
656
+ options.prepareContent?.(
657
+ '<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" data-start="2"></video></div>',
658
+ );
659
+ return true;
660
+ }),
656
661
  };
657
662
  }
658
663
 
@@ -871,14 +876,13 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
871
876
  const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
872
877
  expect(selection).toBe(element);
873
878
  expect(ops).toEqual([
874
- htmlOp("src", "assets/cut.webm"),
875
879
  htmlOp("muted", "true"),
876
880
  dataOp("has-audio", null),
877
881
  dataOp("volume", null),
878
882
  dataOp("link", "link"),
879
883
  ]);
880
884
  const source =
881
- '<div data-composition-id="main"><video id="s1-bg" src="assets/cut.webm" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
885
+ '<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
882
886
  const prepared = options?.prepareContent?.(source) ?? "";
883
887
  const audio = new DOMParser()
884
888
  .parseFromString(prepared, "text/html")
@@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
12
  import {
13
13
  addStudioPendingEditFlushListener,
14
- trackStudioPendingEdit,
14
+ beginStudioPendingEdit,
15
15
  } from "../../utils/studioPendingEdits";
16
16
  import type { DomEditSelection } from "./domEditing";
17
17
  import {
@@ -31,6 +31,7 @@ import {
31
31
  applyManualOffsetNudgeDraft,
32
32
  createManualOffsetDragMember,
33
33
  endManualOffsetDragMembers,
34
+ manualOffsetMoveRevert,
34
35
  restoreManualOffsetDragMembers,
35
36
  type ManualOffsetDragMember,
36
37
  } from "./manualOffsetDrag";
@@ -47,7 +48,7 @@ interface NudgeSession {
47
48
  /** Accumulated delta of the burst, in composition px. */
48
49
  accum: { x: number; y: number };
49
50
  timer: ReturnType<typeof setTimeout> | null;
50
- endPendingEdit: (saved?: Promise<unknown>) => void;
51
+ edit: ReturnType<typeof beginStudioPendingEdit>;
51
52
  }
52
53
 
53
54
  export interface UseDomEditNudgeParams {
@@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
161
162
  next: applyManualOffsetNudgeCommit(member, session.accum),
162
163
  plainTranslate: member.plainTranslate,
163
164
  }));
164
- const commit = session.isGroup
165
- ? p.onGroupPathOffsetCommitRef.current(updates)
166
- : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
167
- plainTranslate: updates[0].plainTranslate,
168
- });
165
+ const commit = session.edit.adopt(() =>
166
+ session.isGroup
167
+ ? p.onGroupPathOffsetCommitRef.current(updates)
168
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
169
+ plainTranslate: updates[0].plainTranslate,
170
+ }),
171
+ );
172
+ if (session.edit.reverted())
173
+ for (const member of session.members)
174
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
169
175
  saved = Promise.resolve(commit)
170
176
  .catch(() => {
171
177
  for (const member of session.members) {
@@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
177
183
  .finally(() => endManualOffsetDragMembers(session.members));
178
184
  return saved;
179
185
  } finally {
180
- session.endPendingEdit(saved);
186
+ session.edit.settle(saved);
181
187
  }
182
188
  };
183
189
  const commitSessionRef = useRef(commitSession);
@@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
190
196
  if (session.timer) clearTimeout(session.timer);
191
197
  restoreManualOffsetDragMembers(session.members);
192
198
  endManualOffsetDragMembers(session.members);
193
- session.endPendingEdit();
199
+ session.edit.settle();
194
200
  }, [params.allowCanvasMovement]);
195
201
 
196
202
  // Build drag members for the current target set — the same member snapshot a
@@ -217,9 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
217
223
  if (members.length === 0) return null;
218
224
  // Same side effect a drag start has (pauses preview playback).
219
225
  p.onManualDragStartRef.current?.();
220
- let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
221
- trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
222
- return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
226
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
227
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
223
228
  };
224
229
 
225
230
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -11,6 +11,7 @@ import {
11
11
  applyManualOffsetDragDraft,
12
12
  endManualOffsetDragMembers,
13
13
  restoreManualOffsetDragMembers,
14
+ manualOffsetMoveRevert,
14
15
  } from "./manualOffsetDrag";
15
16
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
16
17
  import {
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
57
58
  import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
58
59
  import { createGroupDragMover } from "./groupDragMove";
59
60
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
61
+ import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
60
62
 
61
63
  function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
62
64
  return (
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
344
346
  ),
345
347
  at: readDragPositions(groupG.members),
346
348
  });
347
- void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
349
+ const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
350
+ const groupSaved = Promise.resolve(
351
+ groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
352
+ )
348
353
  .catch(() => {
349
354
  for (const member of groupG.members) {
350
355
  if (
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
363
368
  // so this is where a snap-back would show.
364
369
  logDragSettle("settle", groupG.members);
365
370
  });
371
+ groupEdit.settle(groupSaved);
366
372
  return;
367
373
  }
368
374
 
@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
469
475
  box.style.left = `${nextBoxLeft}px`;
470
476
  box.style.top = `${nextBoxTop}px`;
471
477
  }
472
- void Promise.resolve(
473
- opts.onPathOffsetCommitRef.current(sel, finalOffset, {
474
- altKey: e.altKey,
475
- plainTranslate: g.pathOffsetMember.plainTranslate,
476
- }),
478
+ const member = g.pathOffsetMember;
479
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
480
+ const saved = Promise.resolve(
481
+ edit.adopt(() =>
482
+ opts.onPathOffsetCommitRef.current(sel, finalOffset, {
483
+ altKey: e.altKey,
484
+ plainTranslate: member.plainTranslate,
485
+ }),
486
+ ),
477
487
  )
478
488
  .catch(() => {
479
489
  if (
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
485
495
  .finally(() => {
486
496
  if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
487
497
  });
498
+ edit.settle(saved);
488
499
  } else {
489
500
  opts.suppressNextBoxClickRef.current = true;
490
501
  const finalSize = readStudioBoxSize(sel.element);
@@ -29,11 +29,11 @@ import {
29
29
  resolveNextCarveSettings,
30
30
  } from "./useFxCarveGrouping.js";
31
31
  import { type HfAutomation } from "@hyperframes/core/audio-automation";
32
- import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
33
32
  import { trackCarveChanged } from "./audioFxTelemetry.js";
34
33
  import type { DomEditSelection } from "./domEditingTypes";
35
34
  import { usePlayerStore } from "../../player";
36
- import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
35
+ import { dropCarveOutput, resolveCarveVoices, withoutCarveLanes } from "./carveOutput.js";
36
+ import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
37
37
  import { readClipClock } from "./clipAudioClock.js";
38
38
  import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
39
39
  import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -55,16 +55,6 @@ function carveAction(
55
55
  return "strength";
56
56
  }
57
57
 
58
- /** Lanes belonging to nodes the carve generated, which a re-run replaces. */
59
- function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
60
- const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
61
- if (prefixes.length === 0) return automation;
62
- return {
63
- version: automation.version,
64
- lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
65
- };
66
- }
67
-
68
58
  /**
69
59
  * Every setting here describes the filters, so changing one rebuilds them.
70
60
  * There is no apply button: a carve naming a voice with no filters behind it
@@ -86,54 +76,6 @@ function carveNeedsReanalysis(
86
76
  );
87
77
  }
88
78
 
89
- /**
90
- * Every named voice that is actually there with something to decode. A source
91
- * naming a deleted track is skipped rather than failing the whole analysis.
92
- *
93
- * Read out to plain values here rather than carrying elements around: it is
94
- * what lets the src and the start be non-null by construction downstream
95
- * instead of by assertion.
96
- */
97
- function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
98
- const voices: CarveClip[] = [];
99
- for (const id of sources) {
100
- const el = doc.getElementById(id);
101
- // By tag name, not `instanceof HTMLAudioElement`: these elements belong to
102
- // the composition's iframe document, so the constructor they were made
103
- // from is not this realm's and the instanceof is false for every one.
104
- if (el?.tagName !== "AUDIO") continue;
105
- const src = el.getAttribute("src");
106
- if (!src) continue;
107
- const clock = readClipClock((name) => el.getAttribute(name));
108
- voices.push({ src, start: el.getAttribute("data-start"), clock });
109
- }
110
- return voices;
111
- }
112
-
113
- /**
114
- * What the carve generated is only justified by the voices it was measured
115
- * from: switched off, or left naming none — every source deleted, say — there
116
- * is nothing those filters are making room for. Left behind they keep dipping
117
- * the bed with nothing in the panel to explain them.
118
- */
119
- async function dropCarveOutput(
120
- chain: HfAudioFxChain,
121
- automation: HfAutomation,
122
- onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
123
- ): Promise<void> {
124
- const carriedOver = withoutCarveLanes(automation, chain);
125
- if (carriedOver.lanes.length !== automation.lanes.length) {
126
- await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
127
- }
128
- const kept = chain.nodes.filter((n) => !n.fromCarve);
129
- if (kept.length !== chain.nodes.length) {
130
- await onSetAttributeQuiet(
131
- HF_AUDIO_FX_ATTR,
132
- kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
133
- );
134
- }
135
- }
136
-
137
79
  export function useFxCarve(
138
80
  element: DomEditSelection,
139
81
  chain: HfAudioFxChain,
@@ -179,7 +121,7 @@ export function useFxCarve(
179
121
  } => {
180
122
  const doc = element.element?.ownerDocument;
181
123
  if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
182
- const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
124
+ const others = Array.from(doc.querySelectorAll("audio[id], video[id]")).filter(
183
125
  (a) => a.id !== element.id,
184
126
  );
185
127
  // Only tracks that are actually playing while this bed is. A voice somewhere
@@ -54,6 +54,19 @@ describe("collectCarveCandidates", () => {
54
54
  });
55
55
  });
56
56
 
57
+ describe("collectCarveCandidates with video", () => {
58
+ it("offers an ungrouped audible video, not a muted one", () => {
59
+ const doc = previewDoc(`
60
+ <video id="a-roll"></video>
61
+ <video id="b-roll" muted></video>
62
+ <audio id="bed"></audio>
63
+ `);
64
+ const others = Array.from(doc.querySelectorAll("video[id]"));
65
+ const ids = collectCarveCandidates(doc, others, () => true, "bed").map((c) => c.id);
66
+ expect(ids).toEqual(["a-roll"]);
67
+ });
68
+ });
69
+
57
70
  describe("carverAgainst", () => {
58
71
  // The far-end guard has to see through a GROUP source. A plural carve names a
59
72
  // group — that is what the lint rule pushes authors toward — so matching raw