@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.
- package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
- package/dist/assets/{index-zoqPmBF6.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +11 -2
- package/dist/index.html +2 -2
- package/dist/index.js +12416 -11195
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +59 -0
- package/src/components/editor/mediaAudioEdits.ts +40 -11
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/index.ts +1 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- /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
|
|
71
|
-
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
72
|
-
return elements.some(
|
|
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(/"/g, '"')
|
|
27
27
|
.replace(/</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
|
-
|
|
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
|
|
40
|
+
return pendingEdits.size > 0 || focusedField() !== null;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function isStudioEditSaving(): boolean {
|
|
41
|
-
return
|
|
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([...
|
|
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
|
-
|
|
69
|
+
if (adopting) return promise;
|
|
70
|
+
pendingEdits.set(promise, { revert: null });
|
|
67
71
|
promise.then(
|
|
68
|
-
() =>
|
|
69
|
-
() =>
|
|
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:
|
|
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 ||
|
|
100
|
-
const promises = [...detail.promises, ...
|
|
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<
|
|
16
|
+
Pick<
|
|
17
|
+
TimelineElement,
|
|
18
|
+
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
|
|
19
|
+
>
|
|
16
20
|
>,
|
|
17
|
-
): Pick<
|
|
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 (!
|
|
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 (!
|
|
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
|
}
|