@hyperframes/studio 0.8.81 → 0.8.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -80,6 +80,8 @@ function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | unde
|
|
|
80
80
|
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
const SAMPLE_RATE = 48000;
|
|
84
|
+
|
|
83
85
|
function mount(dataAttributes: Record<string, string>, alone = false, voices = 2) {
|
|
84
86
|
// Every write is quiet: persisted without the preview reload that would
|
|
85
87
|
// restart every playing track, but with a selection resync so the panel sees
|
|
@@ -459,29 +461,8 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
459
461
|
|
|
460
462
|
afterEach(() => vi.unstubAllGlobals());
|
|
461
463
|
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
* hear, and measuring a long voiceover takes seconds — by which time the
|
|
465
|
-
* pointer has usually moved on. Applying then would put levelling on a track
|
|
466
|
-
* nobody asked to level, through a channel that does not persist: audible,
|
|
467
|
-
* absent from the document, and gone on the next reload.
|
|
468
|
-
*/
|
|
469
|
-
it("levels the part of the file the clip plays, not the file from its start", async () => {
|
|
470
|
-
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
471
|
-
// whole file — so a trimmed clip got an envelope offset by exactly
|
|
472
|
-
// `media-start`, and every correction landed early.
|
|
473
|
-
//
|
|
474
|
-
// The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
|
|
475
|
-
// first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
|
|
476
|
-
// passage and wants a real lift; measured from the file's zero it sits in
|
|
477
|
-
// the loud head and wants none. That gap is the bug.
|
|
478
|
-
const sampleRate = 48000;
|
|
479
|
-
const data = new Float32Array(sampleRate * 8);
|
|
480
|
-
for (let i = 0; i < data.length; i++) {
|
|
481
|
-
const t = i / sampleRate;
|
|
482
|
-
const amp = t < 2 ? 0.5 : t < 5 ? 0.05 : 0.5;
|
|
483
|
-
data[i] = amp * Math.sin(2 * Math.PI * 300 * t);
|
|
484
|
-
}
|
|
464
|
+
/** Runs Even Out Levels on a decode of `data` and returns the fx lane's nearest point to a clip time. */
|
|
465
|
+
async function levelledLane(data: Float32Array, dataAttributes: Record<string, string>) {
|
|
485
466
|
vi.stubGlobal(
|
|
486
467
|
"fetch",
|
|
487
468
|
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
|
|
@@ -489,15 +470,10 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
489
470
|
vi.stubGlobal(
|
|
490
471
|
"OfflineAudioContext",
|
|
491
472
|
class {
|
|
492
|
-
decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
|
|
473
|
+
decodeAudioData = async () => ({ sampleRate: SAMPLE_RATE, getChannelData: () => data });
|
|
493
474
|
},
|
|
494
475
|
);
|
|
495
|
-
|
|
496
|
-
const { host, onSetAttributeQuiet } = mount({
|
|
497
|
-
"fx-chain": CHAIN,
|
|
498
|
-
"media-start": "2",
|
|
499
|
-
duration: "6",
|
|
500
|
-
});
|
|
476
|
+
const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN, ...dataAttributes });
|
|
501
477
|
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
502
478
|
act(() => byTextButton(host, "Audio FX")?.click());
|
|
503
479
|
act(() => byTextButton(host, "+ effect")?.click());
|
|
@@ -513,12 +489,75 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
513
489
|
JSON.parse(String(write[1])).lanes as { target: string; points: { t: number; v: number }[] }[]
|
|
514
490
|
).find((l) => l.target.startsWith("fx."));
|
|
515
491
|
if (!lane) throw new Error("no fx lane");
|
|
516
|
-
|
|
492
|
+
return (t: number) =>
|
|
517
493
|
lane.points.reduce((best, p) => (Math.abs(p.t - t) < Math.abs(best.t - t) ? p : best));
|
|
518
|
-
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A 300 Hz tone, 16 s long: quiet between `quietFrom` and `quietTo`, loud elsewhere. */
|
|
497
|
+
function toneWithQuietPassage(quietFrom: number, quietTo: number): Float32Array {
|
|
498
|
+
const data = new Float32Array(SAMPLE_RATE * 16);
|
|
499
|
+
for (let i = 0; i < data.length; i++) {
|
|
500
|
+
const t = i / SAMPLE_RATE;
|
|
501
|
+
data[i] = (t >= quietFrom && t < quietTo ? 0.05 : 0.5) * Math.sin(2 * Math.PI * 300 * t);
|
|
502
|
+
}
|
|
503
|
+
return data;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
it("levels the part of the file the clip plays, not the file from its start", async () => {
|
|
507
|
+
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
508
|
+
// whole file. The file is quiet 2-5 s and the clip trims the first 2 s, so
|
|
509
|
+
// from the clip's zero t=0.5 wants a real lift; from the file's zero it wants none.
|
|
510
|
+
const near = await levelledLane(toneWithQuietPassage(2, 5), {
|
|
511
|
+
"playback-start": "2",
|
|
512
|
+
"media-start": "0",
|
|
513
|
+
duration: "6",
|
|
514
|
+
});
|
|
519
515
|
expect(near(0.5).v).toBeGreaterThan(4);
|
|
520
516
|
});
|
|
521
517
|
|
|
518
|
+
it.each<{
|
|
519
|
+
label: string;
|
|
520
|
+
attrs: Record<string, string>;
|
|
521
|
+
quiet: [number, number];
|
|
522
|
+
quietAt: number;
|
|
523
|
+
loudAt: number;
|
|
524
|
+
}>([
|
|
525
|
+
// 2x: 6 s of clip play source 0-12 s; source 2-6 s is quiet, so clip 1-3 s wants lift.
|
|
526
|
+
{
|
|
527
|
+
label: "2x",
|
|
528
|
+
attrs: { "playback-rate": "2", duration: "6" },
|
|
529
|
+
quiet: [2, 6],
|
|
530
|
+
quietAt: 2,
|
|
531
|
+
loudAt: 5,
|
|
532
|
+
},
|
|
533
|
+
// 0.5x: 8 s of clip play source 0-4 s; source 1-2 s is quiet, so clip 2-4 s wants lift and clip 1 s none.
|
|
534
|
+
{
|
|
535
|
+
label: "0.5x",
|
|
536
|
+
attrs: { "playback-rate": "0.5", duration: "8" },
|
|
537
|
+
quiet: [1, 2],
|
|
538
|
+
quietAt: 3.5,
|
|
539
|
+
loudAt: 1,
|
|
540
|
+
},
|
|
541
|
+
// A rate lane holding 2x: playback reads the lane, not the missing data-playback-rate.
|
|
542
|
+
{
|
|
543
|
+
label: "a 2x rate lane",
|
|
544
|
+
attrs: {
|
|
545
|
+
automation:
|
|
546
|
+
'{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":2},{"t":6,"v":2}]}]}',
|
|
547
|
+
duration: "6",
|
|
548
|
+
},
|
|
549
|
+
quiet: [2, 6],
|
|
550
|
+
quietAt: 2,
|
|
551
|
+
loudAt: 5,
|
|
552
|
+
},
|
|
553
|
+
])("levels on the clip's clock at $label", async (c) => {
|
|
554
|
+
const near = await levelledLane(toneWithQuietPassage(...c.quiet), c.attrs);
|
|
555
|
+
expect({ quiet: near(c.quietAt).v > 4, loud: near(c.loudAt).v < 2 }).toEqual({
|
|
556
|
+
quiet: true,
|
|
557
|
+
loud: true,
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
522
561
|
it("removes every lane a preset owned, not just the last node's", () => {
|
|
523
562
|
// Each write is computed from the same render-time snapshot and replaces the
|
|
524
563
|
// whole attribute, so removing lanes one node at a time kept only the final
|
|
@@ -950,6 +989,141 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
950
989
|
expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
|
|
951
990
|
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
|
|
952
991
|
});
|
|
992
|
+
|
|
993
|
+
describe("on each clip's own clock", () => {
|
|
994
|
+
type Point = { t: number; v: number };
|
|
995
|
+
/** `hz` at `amp` over [from, to) of a `seconds`-long file, silence elsewhere. */
|
|
996
|
+
const tone = (seconds: number, [from, to]: [number, number], hz = 1000, amp = 0.7) =>
|
|
997
|
+
Float32Array.from({ length: SAMPLE_RATE * seconds }, (_, i) => {
|
|
998
|
+
const t = i / SAMPLE_RATE;
|
|
999
|
+
return t >= from && t < to ? amp * Math.sin(2 * Math.PI * hz * t) : 0;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
/** Each file decodes to its own samples, found by the URL that was fetched. */
|
|
1003
|
+
function stubFiles(files: Record<string, Float32Array>): void {
|
|
1004
|
+
vi.stubGlobal(
|
|
1005
|
+
"fetch",
|
|
1006
|
+
vi.fn(async (url: string) => ({ arrayBuffer: async () => url })),
|
|
1007
|
+
);
|
|
1008
|
+
vi.stubGlobal(
|
|
1009
|
+
"OfflineAudioContext",
|
|
1010
|
+
class {
|
|
1011
|
+
decodeAudioData = async (url: string) => {
|
|
1012
|
+
const data = Object.entries(files).find(([name]) => url.endsWith(name))![1];
|
|
1013
|
+
return { sampleRate: SAMPLE_RATE, getChannelData: () => data };
|
|
1014
|
+
};
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const valueAt = (points: Point[], t: number): number => {
|
|
1020
|
+
const i = points.findIndex((p) => p.t >= t);
|
|
1021
|
+
if (i <= 0) return points.at(i)!.v;
|
|
1022
|
+
const [a, b] = [points[i - 1]!, points[i]!];
|
|
1023
|
+
return a.v + ((b.v - a.v) * (t - a.t)) / (b.t - a.t);
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
/** Carves the bed against `vo` and reads back the lane driving the first node of `type`. */
|
|
1027
|
+
async function carveLane(
|
|
1028
|
+
type: string,
|
|
1029
|
+
voice: Record<string, string>,
|
|
1030
|
+
bed: Record<string, string> = {},
|
|
1031
|
+
strength = 0,
|
|
1032
|
+
) {
|
|
1033
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
1034
|
+
"fx-chain": carvedChain,
|
|
1035
|
+
"fx-carve": settings(true, { strength, sources: [] }),
|
|
1036
|
+
start: "0",
|
|
1037
|
+
...bed,
|
|
1038
|
+
});
|
|
1039
|
+
const vo = document.getElementById("vo")!;
|
|
1040
|
+
vo.setAttribute("src", "voice.wav");
|
|
1041
|
+
for (const [name, value] of Object.entries(voice)) vo.setAttribute(`data-${name}`, value);
|
|
1042
|
+
document.getElementById("bed")!.setAttribute("src", "bed.wav");
|
|
1043
|
+
await act(async () => {
|
|
1044
|
+
pickSource(host, "vo");
|
|
1045
|
+
});
|
|
1046
|
+
const calls = onSetAttributeQuiet.mock.calls;
|
|
1047
|
+
const nodes = parseWrite(writeTo(calls, "data-fx-chain")!).nodes as {
|
|
1048
|
+
id: string;
|
|
1049
|
+
type: string;
|
|
1050
|
+
params: { gain: number; frequency: number };
|
|
1051
|
+
}[];
|
|
1052
|
+
const node = nodes.find((n) => n.type === type)!;
|
|
1053
|
+
const lanes = parseWrite(writeTo(calls, "data-automation")!).lanes as {
|
|
1054
|
+
target: string;
|
|
1055
|
+
points: Point[];
|
|
1056
|
+
}[];
|
|
1057
|
+
const points = lanes.find((l) => l.target === `fx.${node.id}.gain`)!.points;
|
|
1058
|
+
return {
|
|
1059
|
+
depth: node.params.gain,
|
|
1060
|
+
frequency: node.params.frequency,
|
|
1061
|
+
points,
|
|
1062
|
+
at: (t: number) => valueAt(points, t),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Quiet 1 kHz speech at 0-2 s, loud 4 kHz speech at 4-6 s, in an 8 s file. */
|
|
1067
|
+
const lateSpeech = () => {
|
|
1068
|
+
const after = tone(8, [4, 6], 4000, 0.9);
|
|
1069
|
+
return tone(8, [0, 2], 1000, 0.3).map((v, i) => v + after[i]!);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
it("ignores speech after the bed has stopped playing", async () => {
|
|
1073
|
+
// The bed file runs 8 s, but the clip plays only its first 3 s.
|
|
1074
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(8, [0, 8], 200) });
|
|
1075
|
+
const lane = await carveLane("peaking", { start: "0" }, { duration: "3" });
|
|
1076
|
+
expect(lane.frequency).toBe(1000);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("ignores speech after a bed with no duration runs out, when it ducks", async () => {
|
|
1080
|
+
// No data-duration: the bed plays its 3 s file. Strength 0.05 ducks and keeps one band.
|
|
1081
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
|
|
1082
|
+
const lane = await carveLane("peaking", { start: "0" }, {}, 0.05);
|
|
1083
|
+
expect(lane.frequency).toBe(1000);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it.each([
|
|
1087
|
+
["no duration", {}],
|
|
1088
|
+
["a duration longer than its file", { duration: "6" }],
|
|
1089
|
+
])("ignores speech after a 3 s bed file with %s, without ducking", async (_, bed) => {
|
|
1090
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
|
|
1091
|
+
const lane = await carveLane("peaking", { start: "0" }, bed);
|
|
1092
|
+
expect(lane.frequency).toBe(1000);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("cuts where a trimmed voice speaks, not where its file does", async () => {
|
|
1096
|
+
// File speech 2-3.5 s; the clip plays 2-4 s of it, so the bed hears it at 0-1.5 s.
|
|
1097
|
+
stubFiles({ "voice.wav": tone(6, [2, 3.5]), "bed.wav": tone(8, [0, 8], 200) });
|
|
1098
|
+
const lane = await carveLane("peaking", { start: "0", "media-start": "2", duration: "2" });
|
|
1099
|
+
expect({ speaking: lane.at(0.8) <= lane.depth * 0.5, after: lane.at(3) }).toEqual({
|
|
1100
|
+
speaking: true,
|
|
1101
|
+
after: 0,
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("cuts over half the source length for a voice at 2x", async () => {
|
|
1106
|
+
// File speech 1-3 s at 2x: the bed hears it at 0.5-1.5 s.
|
|
1107
|
+
stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [0, 8], 200) });
|
|
1108
|
+
const lane = await carveLane("peaking", { start: "0", "playback-rate": "2", duration: "2" });
|
|
1109
|
+
expect({
|
|
1110
|
+
speaking: lane.at(0.8) <= lane.depth * 0.5 && lane.at(1.3) <= lane.depth * 0.5,
|
|
1111
|
+
after: lane.at(2.5),
|
|
1112
|
+
}).toEqual({ speaking: true, after: 0 });
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("ducks against the part of the bed that plays", async () => {
|
|
1116
|
+
// The bed file is silent for 4 s and loud after; the clip starts 4 s in, so it is loud under the voice.
|
|
1117
|
+
stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [4, 8], 200, 0.9) });
|
|
1118
|
+
const lane = await carveLane(
|
|
1119
|
+
"gain",
|
|
1120
|
+
{ start: "0" },
|
|
1121
|
+
{ "media-start": "4", duration: "4" },
|
|
1122
|
+
1,
|
|
1123
|
+
);
|
|
1124
|
+
expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThan(0);
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
953
1127
|
});
|
|
954
1128
|
|
|
955
1129
|
describe("AudioFxGroup successive edits", () => {
|
|
@@ -4,7 +4,10 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
|
7
|
+
import { MediaSection } from "./propertyPanelMediaSection";
|
|
7
8
|
import type { DomEditSelection } from "./domEditing";
|
|
9
|
+
import { formatTimingValue } from "./propertyPanelHelpers";
|
|
10
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
8
11
|
|
|
9
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
13
|
|
|
@@ -608,6 +611,65 @@ describe("FlatMediaSection — audio clips", () => {
|
|
|
608
611
|
act(() => root.unmount());
|
|
609
612
|
});
|
|
610
613
|
|
|
614
|
+
it.each<Record<string, string>>([
|
|
615
|
+
{ "playback-start": "1", "media-start": "5" },
|
|
616
|
+
{ "playback-start": "-1", "media-start": "1" },
|
|
617
|
+
{ "media-start": "1" },
|
|
618
|
+
{},
|
|
619
|
+
])("shows and edits the in-point playback reads (%o)", (inPoint) => {
|
|
620
|
+
const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
|
|
621
|
+
const playbackReads = () => readMediaOffsetSeconds((name) => attrs[name.slice(5)]);
|
|
622
|
+
const onSetAttribute = vi.fn((name: string, value: string) => {
|
|
623
|
+
attrs[name] = value;
|
|
624
|
+
});
|
|
625
|
+
const { host, root } = renderWithRate(makeAudioElement(attrs), onSetAttribute);
|
|
626
|
+
const readout = host
|
|
627
|
+
.querySelector('[data-flat-slider-track="true"][aria-label="Media start"]')
|
|
628
|
+
?.parentElement?.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
|
|
629
|
+
if (!readout) throw new Error("expected Media start readout");
|
|
630
|
+
expect(readout.textContent).toBe(formatTimingValue(playbackReads()));
|
|
631
|
+
act(() => readout.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
632
|
+
const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
|
|
633
|
+
if (!input) throw new Error("expected Media start input");
|
|
634
|
+
act(() => {
|
|
635
|
+
typeInto(input, "3");
|
|
636
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
637
|
+
});
|
|
638
|
+
expect(playbackReads()).toBe(3);
|
|
639
|
+
act(() => root.unmount());
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it.each<Record<string, string>>([
|
|
643
|
+
{ "playback-start": "1", "media-start": "5" },
|
|
644
|
+
{ "media-start": "1" },
|
|
645
|
+
])("the Design panel's Media start slider writes where playback reads (%o)", (inPoint) => {
|
|
646
|
+
const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
|
|
647
|
+
const onSetAttribute = vi.fn((name: string, value: string) => {
|
|
648
|
+
attrs[name] = value;
|
|
649
|
+
});
|
|
650
|
+
const host = document.createElement("div");
|
|
651
|
+
document.body.append(host);
|
|
652
|
+
const root = createRoot(host);
|
|
653
|
+
act(() => {
|
|
654
|
+
root.render(
|
|
655
|
+
<MediaSection
|
|
656
|
+
projectDir={null}
|
|
657
|
+
element={makeAudioElement(attrs)}
|
|
658
|
+
styles={{}}
|
|
659
|
+
onSetStyle={vi.fn()}
|
|
660
|
+
onSetAttribute={onSetAttribute}
|
|
661
|
+
onSetHtmlAttribute={vi.fn()}
|
|
662
|
+
/>,
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Media start"]');
|
|
666
|
+
if (!slider) throw new Error("expected Media start slider");
|
|
667
|
+
act(() => typeInto(slider, "300"));
|
|
668
|
+
act(() => slider.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })));
|
|
669
|
+
expect(readMediaOffsetSeconds((name) => attrs[name.slice(5)])).toBe(3);
|
|
670
|
+
act(() => root.unmount());
|
|
671
|
+
});
|
|
672
|
+
|
|
611
673
|
it("commits a typed volume in dB and a typed fade in seconds", () => {
|
|
612
674
|
const { host, root, onSetAttribute } = renderWithRate(makeAudioElement());
|
|
613
675
|
const readouts = host.querySelectorAll<HTMLElement>('[data-flat-slider-value="true"]');
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatNumericValue,
|
|
9
9
|
formatTimingValue,
|
|
10
10
|
parseNumericValue,
|
|
11
|
+
readClipInPoint,
|
|
11
12
|
stripQueryAndHash,
|
|
12
13
|
} from "./propertyPanelHelpers";
|
|
13
14
|
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
|
@@ -88,10 +89,7 @@ export function FlatMediaSection({
|
|
|
88
89
|
? automatedVolumeValue
|
|
89
90
|
: (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
|
|
90
91
|
const volumeFaderPosition = audioGainToFaderPosition(volume);
|
|
91
|
-
const mediaStart =
|
|
92
|
-
Number.parseFloat(
|
|
93
|
-
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
|
|
94
|
-
) || 0;
|
|
92
|
+
const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
|
|
95
93
|
const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
|
96
94
|
const playbackRate =
|
|
97
95
|
rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
|
|
@@ -340,11 +338,11 @@ export function FlatMediaSection({
|
|
|
340
338
|
max={mediaStartMax * 100}
|
|
341
339
|
tier={mediaStart === 0 ? "default" : "explicitCustom"}
|
|
342
340
|
displayValue={formatTimingValue(mediaStart)}
|
|
343
|
-
onCommit={(next) => void onSetAttribute(
|
|
341
|
+
onCommit={(next) => void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2))}
|
|
344
342
|
onCommitText={(text) => {
|
|
345
343
|
const seconds = parseSecondsInput(text);
|
|
346
344
|
if (seconds === null) return false;
|
|
347
|
-
void onSetAttribute(
|
|
345
|
+
void onSetAttribute(mediaStartAttr, Math.min(seconds, mediaStartMax).toFixed(2));
|
|
348
346
|
return true;
|
|
349
347
|
}}
|
|
350
348
|
/>
|
|
@@ -5,6 +5,10 @@ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
|
5
5
|
import type { TimelineElement } from "../../player";
|
|
6
6
|
import { roundToCenti } from "../../utils/rounding";
|
|
7
7
|
import { findPreviewNode } from "./domEditingElement";
|
|
8
|
+
import {
|
|
9
|
+
playbackStartAttributeForElement,
|
|
10
|
+
readPlaybackStartAttributes,
|
|
11
|
+
} from "../../player/lib/timelineElementHelpers";
|
|
8
12
|
|
|
9
13
|
export type {
|
|
10
14
|
BackgroundRemovalProgress,
|
|
@@ -225,6 +229,17 @@ export function parseNumericValue(value: string | undefined): number | null {
|
|
|
225
229
|
return Number.isFinite(parsed) ? parsed : null;
|
|
226
230
|
}
|
|
227
231
|
|
|
232
|
+
export function readClipInPoint(dataAttributes: Record<string, string> | undefined): {
|
|
233
|
+
mediaStart: number;
|
|
234
|
+
mediaStartAttr: string;
|
|
235
|
+
} {
|
|
236
|
+
const inPoint = readPlaybackStartAttributes((name) => dataAttributes?.[name.slice(5)]);
|
|
237
|
+
return {
|
|
238
|
+
mediaStart: inPoint.playbackStart ?? 0,
|
|
239
|
+
mediaStartAttr: playbackStartAttributeForElement(inPoint).slice(5),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
228
243
|
export function formatTimingValue(seconds: number): string {
|
|
229
244
|
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
|
|
230
245
|
return `${seconds.toFixed(2)}s`;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatTimingValue,
|
|
9
9
|
LABEL,
|
|
10
10
|
parseNumericValue,
|
|
11
|
+
readClipInPoint,
|
|
11
12
|
RESPONSIVE_GRID,
|
|
12
13
|
stripQueryAndHash,
|
|
13
14
|
} from "./propertyPanelHelpers";
|
|
@@ -57,10 +58,7 @@ export function MediaSection({
|
|
|
57
58
|
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
|
|
58
59
|
const volumeFaderPosition = audioGainToFaderPosition(volume);
|
|
59
60
|
|
|
60
|
-
const mediaStart =
|
|
61
|
-
Number.parseFloat(
|
|
62
|
-
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
|
|
63
|
-
) || 0;
|
|
61
|
+
const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
|
|
64
62
|
|
|
65
63
|
const hasLoop = el.hasAttribute("loop");
|
|
66
64
|
const hasMuted = el.hasAttribute("muted");
|
|
@@ -297,7 +295,7 @@ export function MediaSection({
|
|
|
297
295
|
displayValue={formatTimingValue(mediaStart)}
|
|
298
296
|
formatDisplayValue={(next) => formatTimingValue(next / 100)}
|
|
299
297
|
onCommit={(next) => {
|
|
300
|
-
void onSetAttribute(
|
|
298
|
+
void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2));
|
|
301
299
|
}}
|
|
302
300
|
/>
|
|
303
301
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { reapplyPositionEditsAfterSeek } from "./
|
|
3
|
+
import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
|
|
4
4
|
import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
|
|
4
|
+
import {
|
|
5
|
+
STUDIO_PATH_OFFSET_ATTR,
|
|
6
|
+
STUDIO_ROTATION_ATTR,
|
|
7
|
+
STUDIO_ROTATION_PROP,
|
|
8
|
+
} from "./manualEditsTypes";
|
|
9
|
+
import { STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
|
|
10
|
+
|
|
11
|
+
describe("reapplyPositionEditsAfterSeek", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does no per-edit work on a film Studio never edited", () => {
|
|
18
|
+
document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
|
|
19
|
+
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
20
|
+
|
|
21
|
+
reapplyPositionEditsAfterSeek(document);
|
|
22
|
+
|
|
23
|
+
expect(queryAll).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("looks nothing up on later seeks of a film Studio never edited", () => {
|
|
27
|
+
document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
|
|
28
|
+
const query = vi.spyOn(document, "querySelector");
|
|
29
|
+
reapplyPositionEditsAfterSeek(document);
|
|
30
|
+
// A comma selector list walks the whole DOM in Chrome; lone attribute selectors do not.
|
|
31
|
+
for (const [selector] of query.mock.calls) expect(selector).not.toContain(",");
|
|
32
|
+
query.mockClear();
|
|
33
|
+
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
34
|
+
|
|
35
|
+
reapplyPositionEditsAfterSeek(document);
|
|
36
|
+
|
|
37
|
+
expect(query).not.toHaveBeenCalled();
|
|
38
|
+
expect(queryAll).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("looks up no more on a film with an edit than the reapply itself does", () => {
|
|
42
|
+
document.body.innerHTML = `<div id="a" ${STUDIO_ROTATION_ATTR}="true" style="${STUDIO_ROTATION_PROP}: 8deg"></div>`;
|
|
43
|
+
reapplyPositionEditsAfterSeek(document);
|
|
44
|
+
const query = vi.spyOn(document, "querySelector");
|
|
45
|
+
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
46
|
+
|
|
47
|
+
reapplyPositionEditsAfterSeek(document);
|
|
48
|
+
|
|
49
|
+
// Three edit kinds, each with a legacy mark, plus motion: seven, as before the skip.
|
|
50
|
+
expect(query.mock.calls.length + queryAll.mock.calls.length).toBeLessThanOrEqual(7);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reapplies an edit made after the last seek, in the same task", () => {
|
|
54
|
+
document.body.innerHTML = '<div id="a"></div>';
|
|
55
|
+
reapplyPositionEditsAfterSeek(document);
|
|
56
|
+
const el = document.getElementById("a") as HTMLElement;
|
|
57
|
+
el.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
|
58
|
+
el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
|
|
59
|
+
|
|
60
|
+
reapplyPositionEditsAfterSeek(document);
|
|
61
|
+
|
|
62
|
+
expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reapplies an edited element inserted after the last seek", async () => {
|
|
66
|
+
document.body.innerHTML = '<div id="a"></div>';
|
|
67
|
+
reapplyPositionEditsAfterSeek(document);
|
|
68
|
+
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
69
|
+
reapplyPositionEditsAfterSeek(document);
|
|
70
|
+
expect(queryAll).not.toHaveBeenCalled(); // the film reads as unedited
|
|
71
|
+
queryAll.mockRestore();
|
|
72
|
+
// A paste, scene swap or undo puts back a node that already carries its mark.
|
|
73
|
+
const el = document.createElement("div");
|
|
74
|
+
el.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
|
75
|
+
el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
|
|
76
|
+
document.body.append(el);
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
|
|
79
|
+
reapplyPositionEditsAfterSeek(document);
|
|
80
|
+
|
|
81
|
+
expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not rescan after a style write, the one GSAP makes every frame", async () => {
|
|
85
|
+
document.body.innerHTML = '<div id="a"></div>';
|
|
86
|
+
reapplyPositionEditsAfterSeek(document);
|
|
87
|
+
(document.getElementById("a") as HTMLElement).style.setProperty("opacity", "0.5");
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
const query = vi.spyOn(document, "querySelector");
|
|
90
|
+
|
|
91
|
+
reapplyPositionEditsAfterSeek(document);
|
|
92
|
+
|
|
93
|
+
expect(query).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("still clears a motion timeline whose marks an undo removed", () => {
|
|
97
|
+
document.body.innerHTML = '<div id="a"></div>';
|
|
98
|
+
const kill = vi.fn();
|
|
99
|
+
const win = window as unknown as { __timelines?: Record<string, unknown> };
|
|
100
|
+
win.__timelines = { [STUDIO_MOTION_TIMELINE_ID]: { kill } };
|
|
101
|
+
|
|
102
|
+
reapplyPositionEditsAfterSeek(document);
|
|
103
|
+
|
|
104
|
+
expect(kill).toHaveBeenCalledTimes(1);
|
|
105
|
+
delete win.__timelines;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("still migrates a legacy double-prefixed edit mark", () => {
|
|
109
|
+
document.body.innerHTML = `<div id="a" data-${STUDIO_PATH_OFFSET_ATTR}="true"></div>`;
|
|
110
|
+
|
|
111
|
+
reapplyPositionEditsAfterSeek(document);
|
|
112
|
+
|
|
113
|
+
const el = document.getElementById("a");
|
|
114
|
+
expect(el?.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
|
|
115
|
+
expect(el?.hasAttribute(`data-${STUDIO_PATH_OFFSET_ATTR}`)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -33,7 +33,8 @@ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAu
|
|
|
33
33
|
import { trackCarveChanged } from "./audioFxTelemetry.js";
|
|
34
34
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
35
35
|
import { usePlayerStore } from "../../player";
|
|
36
|
-
import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
|
|
36
|
+
import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
|
|
37
|
+
import { readClipClock } from "./clipAudioClock.js";
|
|
37
38
|
import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
|
|
38
39
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
39
40
|
|
|
@@ -93,11 +94,8 @@ function carveNeedsReanalysis(
|
|
|
93
94
|
* what lets the src and the start be non-null by construction downstream
|
|
94
95
|
* instead of by assertion.
|
|
95
96
|
*/
|
|
96
|
-
function resolveCarveVoices(
|
|
97
|
-
|
|
98
|
-
sources: readonly string[],
|
|
99
|
-
): { src: string; start: string | null }[] {
|
|
100
|
-
const voices: { src: string; start: string | null }[] = [];
|
|
97
|
+
function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
|
|
98
|
+
const voices: CarveClip[] = [];
|
|
101
99
|
for (const id of sources) {
|
|
102
100
|
const el = doc.getElementById(id);
|
|
103
101
|
// By tag name, not `instanceof HTMLAudioElement`: these elements belong to
|
|
@@ -106,7 +104,8 @@ function resolveCarveVoices(
|
|
|
106
104
|
if (el?.tagName !== "AUDIO") continue;
|
|
107
105
|
const src = el.getAttribute("src");
|
|
108
106
|
if (!src) continue;
|
|
109
|
-
|
|
107
|
+
const clock = readClipClock((name) => el.getAttribute(name));
|
|
108
|
+
voices.push({ src, start: el.getAttribute("data-start"), clock });
|
|
110
109
|
}
|
|
111
110
|
return voices;
|
|
112
111
|
}
|
|
@@ -363,14 +362,11 @@ export function useFxCarve(
|
|
|
363
362
|
if (voices.length === 0) return;
|
|
364
363
|
setAnalysing(true);
|
|
365
364
|
try {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
element.dataAttributes?.["start"],
|
|
372
|
-
bedSrc,
|
|
373
|
-
);
|
|
365
|
+
const measured = await measureCarve(doc, voices, active.strength, {
|
|
366
|
+
src: element.element?.getAttribute("src"),
|
|
367
|
+
start: element.dataAttributes?.["start"],
|
|
368
|
+
clock: readClipClock((name) => element.dataAttributes?.[name.slice(5)]),
|
|
369
|
+
});
|
|
374
370
|
if (!measured) return;
|
|
375
371
|
await persistMeasuredCarve(measured);
|
|
376
372
|
} catch {
|