@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,92 @@
1
+ import { useEffect, useState } from "react";
2
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
3
+ import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
4
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
5
+ import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { useLivePreviewIframe } from "../store/previewIframeStore";
8
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
9
+ import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
10
+ import { committedClipPath } from "../../components/editor/cropPresetStore";
11
+ import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
12
+ import type { ClipToolState } from "./clipToolAttrs";
13
+
14
+ const LINK_ATTR = "data-link";
15
+
16
+ const WATCHED_ATTRS = [
17
+ HF_AUDIO_FX_ATTR,
18
+ HF_AUDIO_AUTOMATION_ATTR,
19
+ HF_AUDIO_CARVE_ATTR,
20
+ HF_COLOR_GRADING_ATTR,
21
+ LINK_ATTR,
22
+ "data-volume",
23
+ "data-has-audio",
24
+ "muted",
25
+ "style",
26
+ ];
27
+
28
+ function readVolume(node: Element): number | null {
29
+ const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
30
+ return Number.isFinite(volume) ? volume : null;
31
+ }
32
+
33
+ function toolStateFromNode(node: Element): ClipToolState {
34
+ const tag = node.tagName.toLowerCase();
35
+ return {
36
+ tag,
37
+ hasSound: tag === "audio" || isAudibleVideoNode(node),
38
+ volume: readVolume(node),
39
+ muted: node.hasAttribute("muted"),
40
+ fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
41
+ automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
42
+ colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
43
+ clipPath: committedClipPath(node),
44
+ carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
45
+ link: node.getAttribute(LINK_ATTR),
46
+ };
47
+ }
48
+
49
+ function toolStateFromElement(el: TimelineElement): ClipToolState {
50
+ const tag = el.tag.trim().toLowerCase();
51
+ return {
52
+ tag,
53
+ hasSound: tag === "audio" || el.hasAudio === true,
54
+ volume: el.volume ?? null,
55
+ muted: el.muted === true,
56
+ fxChain: el.fxChain ?? null,
57
+ automation: el.automation ?? null,
58
+ colorGrading: null,
59
+ clipPath: null,
60
+ carve: null,
61
+ link: null,
62
+ };
63
+ }
64
+
65
+ /** Inline style changes every frame under a tween; only a changed reading re-renders. */
66
+ function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
67
+ return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
68
+ }
69
+
70
+ /** A clip's tool attributes as the live preview node holds them, kept current as they change. */
71
+ export function useClipToolState(el: TimelineElement): ClipToolState {
72
+ const iframe = useLivePreviewIframe();
73
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
74
+ const [state, setState] = useState<ClipToolState>(() => toolStateFromElement(el));
75
+
76
+ useEffect(() => {
77
+ const node = findTimelineElementInIframe(iframe, el, activeCompPath);
78
+ if (!node) {
79
+ setState(toolStateFromElement(el));
80
+ return;
81
+ }
82
+ const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
83
+ refresh();
84
+ const view = node.ownerDocument.defaultView;
85
+ if (!view) return;
86
+ const observer = new view.MutationObserver(refresh);
87
+ observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
88
+ return () => observer.disconnect();
89
+ }, [iframe, el, activeCompPath]);
90
+
91
+ return state;
92
+ }
@@ -231,6 +231,15 @@ export function isVideoAudible(opts: {
231
231
  return opts.tag.toLowerCase() === "video" && !opts.muted;
232
232
  }
233
233
 
234
+ export function isAudibleVideoNode(el: Element): boolean {
235
+ if (el.tagName.toLowerCase() !== "video" || el.hasAttribute("muted")) return false;
236
+ return isVideoAudible({
237
+ tag: "video",
238
+ hasAudioAttr: el.getAttribute("data-has-audio"),
239
+ muted: false,
240
+ });
241
+ }
242
+
234
243
  /** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
235
244
  function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
236
245
  const media = resolveMediaElement(el) ?? el;
@@ -6,6 +6,7 @@ import {
6
6
  AudioMeterStrip,
7
7
  TimelineHistoryButtons,
8
8
  TimelineToolbar,
9
+ useAudioMetersVisible as exportedAudioMetersVisible,
9
10
  useClipboard,
10
11
  usePlayerStore,
11
12
  type UseClipboardOptions,
@@ -51,6 +52,12 @@ describe("timeline chrome package exports, outside Studio's shell", () => {
51
52
  await act(async () => root.unmount());
52
53
  });
53
54
 
55
+ it("exports the meter visibility store a host toggles", () => {
56
+ expect(exportedAudioMetersVisible).toBe(useAudioMetersVisible);
57
+ exportedAudioMetersVisible.getState().setVisible(true);
58
+ expect(useAudioMetersVisible.getState().visible).toBe(true);
59
+ });
60
+
54
61
  it("runs the clipboard with no DOM edit session", async () => {
55
62
  const toasts: string[] = [];
56
63
  const options: UseClipboardOptions = {
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../player";
2
3
  import {
4
+ hasProjectAudio,
3
5
  levelToFraction,
4
6
  markFraction,
5
7
  SILENT_CHANNEL,
@@ -42,3 +44,38 @@ describe("stepPair", () => {
42
44
  expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
43
45
  });
44
46
  });
47
+
48
+ describe("hasProjectAudio", () => {
49
+ const clip = (overrides: Partial<TimelineElement>): TimelineElement => ({
50
+ id: "c",
51
+ tag: "div",
52
+ start: 0,
53
+ duration: 1,
54
+ track: 0,
55
+ ...overrides,
56
+ });
57
+
58
+ it("ignores an audible video left on native output at unity gain", () => {
59
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true })])).toBe(false);
60
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, volume: 1 })])).toBe(false);
61
+ });
62
+
63
+ it.each<[string, Partial<TimelineElement>]>([
64
+ ["an above-unity gain", { volume: 2 }],
65
+ ["an fx chain", { fxChain: "[]" }],
66
+ ["automation", { automation: "{}" }],
67
+ ["a group", { audioGroup: "music" }],
68
+ ])("counts an audible video routed through Web Audio by %s", (_, fields) => {
69
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, ...fields })])).toBe(true);
70
+ });
71
+
72
+ it("ignores a muted video even when it carries processing", () => {
73
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, muted: true, volume: 2 })])).toBe(
74
+ false,
75
+ );
76
+ });
77
+
78
+ it("ignores a silent video", () => {
79
+ expect(hasProjectAudio([clip({ tag: "video" })])).toBe(false);
80
+ });
81
+ });
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
3
+ import { audibleVideoNeedsWebAudio } from "@hyperframes/core/audible-video";
3
4
  import { usePlayerStore, type TimelineElement } from "../player";
4
5
  import { isAudioTimelineElement } from "./timelineInspector";
5
6
 
@@ -67,9 +68,14 @@ export function stepPair(
67
68
  return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
68
69
  }
69
70
 
70
- /** The meters appear only for a project with at least one audio clip or group. */
71
- function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
72
- return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
71
+ /** The meters appear only for a project with an audio clip, a group, or a video Web Audio carries. */
72
+ export function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
73
+ return elements.some(
74
+ (el) =>
75
+ isAudioTimelineElement(el) ||
76
+ el.audioGroup !== undefined ||
77
+ (el.hasAudio === true && !el.muted && audibleVideoNeedsWebAudio(el)),
78
+ );
73
79
  }
74
80
 
75
81
  /** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
@@ -0,0 +1 @@
1
+ export const EXPORT_PEAK_CEILING = 10 ** (-1 / 20);
@@ -0,0 +1,19 @@
1
+ import { buildProjectApiPath } from "./projectRouting";
2
+
3
+ /** Resolve a media src to its project-relative preview path, or null. */
4
+ export function resolvePreviewRelative(
5
+ src: string | undefined,
6
+ pid: string,
7
+ origin: string,
8
+ ): string | null {
9
+ if (!src) return null;
10
+ try {
11
+ const parsed = new URL(src, origin);
12
+ const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
13
+ return parsed.pathname.startsWith(base)
14
+ ? decodeURIComponent(parsed.pathname.slice(base.length))
15
+ : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
@@ -21,7 +21,7 @@ export function escapeHtmlAttribute(value: string): string {
21
21
  }
22
22
 
23
23
  /** Reverse escapeHtmlAttribute so callers get the original value. */
24
- function unescapeHtmlAttribute(value: string): string {
24
+ export function unescapeHtmlAttribute(value: string): string {
25
25
  return value
26
26
  .replace(/&quot;/g, '"')
27
27
  .replace(/&lt;/g, "<")
@@ -2,8 +2,10 @@
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  addStudioPendingEditFlushListener,
5
+ beginStudioPendingEdit,
5
6
  flushStudioPendingEdits,
6
7
  hasStudioPendingEdits,
8
+ revertNewestStudioPendingEdit,
7
9
  trackStudioPendingEdit,
8
10
  trackedStudioEdit,
9
11
  } from "./studioPendingEdits";
@@ -211,3 +213,67 @@ describe("trackedStudioEdit", () => {
211
213
  expect(hasStudioPendingEdits()).toBe(false);
212
214
  });
213
215
  });
216
+
217
+ describe("a pending edit undo can paint back", () => {
218
+ const shown: string[] = [];
219
+ const edit = (name: string) =>
220
+ beginStudioPendingEdit(() => {
221
+ shown.push(`${name} undone`);
222
+ return () => shown.push(`${name} again`);
223
+ });
224
+
225
+ it("paints back only the newest edit, once, and shows it again on request", () => {
226
+ shown.length = 0;
227
+ const first = edit("first");
228
+ const second = edit("second");
229
+ const again = revertNewestStudioPendingEdit();
230
+ expect(shown).toEqual(["second undone"]);
231
+ expect(second.reverted()).toBe(true);
232
+ expect(first.reverted()).toBe(false);
233
+ expect(revertNewestStudioPendingEdit()).toBeNull();
234
+ again!();
235
+ expect(shown).toEqual(["second undone", "second again"]);
236
+ first.settle();
237
+ second.settle();
238
+ });
239
+
240
+ it("paints nothing when the newest edit has no revert", async () => {
241
+ shown.length = 0;
242
+ const move = edit("move");
243
+ let saved!: () => void;
244
+ trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
245
+ expect(revertNewestStudioPendingEdit()).toBeNull();
246
+ expect(shown).toEqual([]);
247
+ saved();
248
+ move.settle();
249
+ await flushStudioPendingEdits();
250
+ });
251
+
252
+ it("counts what the edit starts inside adopt as that edit, not a newer one", async () => {
253
+ shown.length = 0;
254
+ const move = edit("move");
255
+ let saved!: () => void;
256
+ const save = move.adopt(() =>
257
+ trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
258
+ );
259
+ move.settle(save);
260
+ revertNewestStudioPendingEdit();
261
+ expect(shown).toEqual(["move undone"]);
262
+ saved();
263
+ await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
264
+ expect(hasStudioPendingEdits()).toBe(false);
265
+ });
266
+ });
267
+
268
+ describe("a pending edit whose start throws", () => {
269
+ it("ends, so undo and export never wait on it", async () => {
270
+ const edit = beginStudioPendingEdit(null);
271
+ expect(() =>
272
+ edit.adopt(() => {
273
+ throw new Error("The commit threw.");
274
+ }),
275
+ ).toThrow("The commit threw.");
276
+ await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
277
+ expect(hasStudioPendingEdits()).toBe(false);
278
+ });
279
+ });
@@ -9,7 +9,10 @@ interface StudioFlushPendingEditsDetail {
9
9
 
10
10
  export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
11
11
 
12
- const pendingEditPromises = new Set<Promise<unknown>>();
12
+ export type StudioEditRevert = () => () => void;
13
+
14
+ const pendingEdits = new Map<Promise<unknown>, { revert: StudioEditRevert | null }>();
15
+ let adopting = false;
13
16
 
14
17
  function waitForPostBlurEffects(): Promise<void> {
15
18
  return new Promise((resolve) => setTimeout(resolve, 0));
@@ -34,11 +37,11 @@ function focusedField(): HTMLElement | null {
34
37
  }
35
38
 
36
39
  export function hasStudioPendingEdits(): boolean {
37
- return pendingEditPromises.size > 0 || focusedField() !== null;
40
+ return pendingEdits.size > 0 || focusedField() !== null;
38
41
  }
39
42
 
40
43
  export function isStudioEditSaving(): boolean {
41
- return pendingEditPromises.size > 0;
44
+ return pendingEdits.size > 0;
42
45
  }
43
46
 
44
47
  export function afterStudioPendingEdits(run: () => void): () => void {
@@ -46,7 +49,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
46
49
  const check = () => {
47
50
  if (!waiting) return;
48
51
  if (isStudioEditSaving()) {
49
- void Promise.allSettled([...pendingEditPromises]).then(check);
52
+ void Promise.allSettled([...pendingEdits.keys()]).then(check);
50
53
  return;
51
54
  }
52
55
  waiting = false;
@@ -63,10 +66,11 @@ export function trackStudioPendingEdit(
63
66
  ): Promise<unknown> | undefined {
64
67
  if (!result) return undefined;
65
68
  const promise = Promise.resolve(result);
66
- pendingEditPromises.add(promise);
69
+ if (adopting) return promise;
70
+ pendingEdits.set(promise, { revert: null });
67
71
  promise.then(
68
- () => pendingEditPromises.delete(promise),
69
- () => pendingEditPromises.delete(promise),
72
+ () => pendingEdits.delete(promise),
73
+ () => pendingEdits.delete(promise),
70
74
  );
71
75
  return promise;
72
76
  }
@@ -81,12 +85,42 @@ export function trackedStudioEdit<Args extends unknown[], R>(
81
85
  };
82
86
  }
83
87
 
88
+ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
89
+ let settle!: (saved?: Promise<unknown>) => void;
90
+ const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
91
+ const entry = pendingEdits.get(promise)!;
92
+ entry.revert = revert;
93
+ return {
94
+ settle,
95
+ reverted: () => entry.revert === null && revert !== null,
96
+ // Only what `start` registers synchronously is adopted; a registration after an await is a newer edit.
97
+ adopt<T>(start: () => T): T {
98
+ adopting = true;
99
+ try {
100
+ return start();
101
+ } catch (error) {
102
+ settle();
103
+ throw error;
104
+ } finally {
105
+ adopting = false;
106
+ }
107
+ },
108
+ };
109
+ }
110
+
111
+ export function revertNewestStudioPendingEdit(): (() => void) | null {
112
+ const newest = [...pendingEdits.values()].at(-1);
113
+ const revert = newest?.revert;
114
+ if (!newest || !revert) return null;
115
+ newest.revert = null;
116
+ return revert();
117
+ }
118
+
84
119
  export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
85
120
  const active = focusedField();
86
121
  if (active) {
87
122
  active.blur();
88
- // ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary
89
- // so React effects triggered by the blur can register their flush listener.
123
+ // ponytail: keep blur commits, then cross one task so effects the blur runs can add their listener.
90
124
  await Promise.resolve();
91
125
  await waitForPostBlurEffects();
92
126
  }
@@ -96,8 +130,8 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
96
130
  );
97
131
  let conflict: StudioFileConflictError | undefined;
98
132
  let firstFailure: PromiseRejectedResult | undefined;
99
- while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
100
- const promises = [...detail.promises, ...pendingEditPromises];
133
+ while (detail.promises.length > 0 || pendingEdits.size > 0) {
134
+ const promises = [...detail.promises, ...pendingEdits.keys()];
101
135
  detail.promises = [];
102
136
  const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
103
137
  conflict ??= batchFailures.conflict;
@@ -4,6 +4,7 @@ import {
4
4
  canHideSelections,
5
5
  isAudioDomElement,
6
6
  isAudioTimelineElement,
7
+ isMusicSourceElement,
7
8
  isMusicTrack,
8
9
  resolveBeatSourceTrack,
9
10
  } from "./timelineInspector";
@@ -12,9 +13,15 @@ import type { TimelineElement } from "../player";
12
13
  // Minimal element factory for tests
13
14
  function el(
14
15
  overrides: Partial<
15
- Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
16
+ Pick<
17
+ TimelineElement,
18
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
19
+ >
16
20
  >,
17
- ): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
21
+ ): Pick<
22
+ TimelineElement,
23
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
24
+ > {
18
25
  return {
19
26
  tag: "audio",
20
27
  src: "assets/track.mp3",
@@ -153,3 +160,27 @@ describe("isAudioDomElement / canHideSelections", () => {
153
160
  expect(canHideSelections([{ element: el("hf-audio-group") }])).toBe(false);
154
161
  });
155
162
  });
163
+
164
+ describe("beat source from a video with sound", () => {
165
+ const talk = { tag: "video", src: "assets/clip.mp4", hasAudio: true };
166
+
167
+ it("counts an audible video as a music source but never a muted or silent one", () => {
168
+ expect(isMusicSourceElement(el(talk))).toBe(true);
169
+ expect(isMusicSourceElement(el({ ...talk, muted: true }))).toBe(false);
170
+ expect(isMusicSourceElement(el({ ...talk, hasAudio: false }))).toBe(false);
171
+ expect(isAudioTimelineElement(el(talk))).toBe(false);
172
+ });
173
+
174
+ it("takes a video tagged as music as the beat source", () => {
175
+ const video = el({ ...talk, timelineRole: "music" });
176
+ expect(isMusicTrack(video)).toBe(true);
177
+ expect(resolveBeatSourceTrack([video])).toEqual({ element: video, isFallback: false });
178
+ });
179
+
180
+ it("falls back to the longest audible video when there is no audio", () => {
181
+ const short = el({ ...talk, id: "a", duration: 3 });
182
+ const long = el({ ...talk, id: "b", duration: 9 });
183
+ const muted = el({ ...talk, id: "c", duration: 20, muted: true });
184
+ expect(resolveBeatSourceTrack([short, long, muted])?.element).toBe(long);
185
+ });
186
+ });
@@ -44,17 +44,29 @@ export function isAudioOrVideoTimelineElement(
44
44
  return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
45
45
  }
46
46
 
47
+ type MusicSourceFacts = Pick<TimelineElement, "tag" | "src" | "hasAudio" | "muted">;
48
+
49
+ /** Can carry the music: an audio clip, or a video whose own sound plays. Lane zoning is unaffected. */
50
+ export function isMusicSourceElement(element: MusicSourceFacts): boolean {
51
+ if (isAudioTimelineElement(element)) return true;
52
+ return (
53
+ element.tag.trim().toLowerCase() === "video" &&
54
+ element.hasAudio === true &&
55
+ element.muted !== true
56
+ );
57
+ }
58
+
47
59
  /** True for the music track: an audio element with data-timeline-role="music",
48
60
  * or — when no role is set — an id matching the music regex. Voiceover/other
49
61
  * audio (explicit non-music role) is excluded. */
50
62
  export function isMusicTrack(
51
63
  element:
52
- | Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole">
64
+ | Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "hasAudio" | "muted">
53
65
  | null
54
66
  | undefined,
55
67
  ): boolean {
56
68
  if (!element) return false;
57
- if (!isAudioTimelineElement(element)) return false;
69
+ if (!isMusicSourceElement(element)) return false;
58
70
  if (element.timelineRole === "music") return true;
59
71
  if (element.timelineRole && element.timelineRole !== "music") return false;
60
72
  const id = element.domId ?? element.id ?? "";
@@ -75,7 +87,7 @@ export function isMusicTrack(
75
87
  export function resolveBeatSourceTrack(
76
88
  elements: readonly Pick<
77
89
  TimelineElement,
78
- "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
90
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
79
91
  >[],
80
92
  ): { element: (typeof elements)[number]; isFallback: boolean } | null {
81
93
  const explicit = elements.find(isMusicTrack);
@@ -85,7 +97,7 @@ export function resolveBeatSourceTrack(
85
97
  // like "sfx" or "voiceover" to avoid triggering beat analysis on those).
86
98
  let best: (typeof elements)[number] | null = null;
87
99
  for (const el of elements) {
88
- if (!isAudioTimelineElement(el)) continue;
100
+ if (!isMusicSourceElement(el)) continue;
89
101
  if (el.timelineRole && el.timelineRole !== "music") continue;
90
102
  if (!best || el.duration > best.duration) best = el;
91
103
  }