@hyperframes/studio 0.8.81 → 0.8.82
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-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
- package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2414 -2172
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- 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/anchoredResizeReleaseShift.test.ts +2 -6
- 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/propertyPanelAudioFxGroup.test.tsx +71 -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/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- 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 +36 -37
- 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/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 +8 -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/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- 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/useTimelinePlayhead.ts +26 -8
- 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/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
|
@@ -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
|
|
@@ -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
|
+
});
|
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
type HfAudioFxChain,
|
|
15
15
|
} from "@hyperframes/core/audio-fx";
|
|
16
16
|
import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
|
|
17
|
-
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
17
|
+
import type { HfAutomation, HfAutomationPoint } from "@hyperframes/core/audio-automation";
|
|
18
|
+
import {
|
|
19
|
+
resolveRateSpec,
|
|
20
|
+
sourceTimeAt,
|
|
21
|
+
timeAtSourceTime,
|
|
22
|
+
type RateSpec,
|
|
23
|
+
} from "@hyperframes/core/speed-ramp";
|
|
24
|
+
import { readPlaybackRate } from "@hyperframes/parsers/media-duration";
|
|
18
25
|
import {
|
|
19
26
|
automationAttrValue,
|
|
20
27
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -23,6 +30,7 @@ import {
|
|
|
23
30
|
} from "./propertyPanelAutomation";
|
|
24
31
|
import { trackLeveller } from "./audioFxTelemetry.js";
|
|
25
32
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
33
|
+
import { readClipInPoint } from "./propertyPanelHelpers";
|
|
26
34
|
import { useAuditionTransport } from "./useAuditionTransport.js";
|
|
27
35
|
|
|
28
36
|
/**
|
|
@@ -80,37 +88,54 @@ export function useFxLevelling(
|
|
|
80
88
|
*
|
|
81
89
|
* A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
82
90
|
* whole file from its first sample — so measuring a trimmed clip produced an
|
|
83
|
-
* envelope offset by the trim, and every correction landed early by exactly
|
|
84
|
-
*
|
|
91
|
+
* envelope offset by the trim, and every correction landed early by exactly the
|
|
92
|
+
* in-point. Slicing here is what puts the two clocks back on the same
|
|
85
93
|
* zero.
|
|
86
94
|
*/
|
|
87
|
-
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
|
|
88
|
-
const mediaStart =
|
|
95
|
+
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }, rate: RateSpec) => {
|
|
96
|
+
const { mediaStart } = readClipInPoint(element.dataAttributes);
|
|
89
97
|
const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
|
|
90
98
|
const from = mediaStart
|
|
91
99
|
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
|
|
92
100
|
: 0;
|
|
93
101
|
const to = duration
|
|
94
|
-
? Math.min(
|
|
102
|
+
? Math.min(
|
|
103
|
+
audio.samples.length,
|
|
104
|
+
from + Math.ceil(sourceTimeAt(rate, duration) * audio.sampleRate),
|
|
105
|
+
)
|
|
95
106
|
: audio.samples.length;
|
|
96
107
|
return from === 0 && to === audio.samples.length
|
|
97
108
|
? audio.samples
|
|
98
109
|
: audio.samples.subarray(from, to);
|
|
99
110
|
};
|
|
100
111
|
|
|
112
|
+
/** The levelled chain and its lane, with the lane moved from the source's clock onto the clip's. */
|
|
113
|
+
const measureLevelling = (audio: { samples: Float32Array; sampleRate: number }) => {
|
|
114
|
+
const attr = (name: string) => element.dataAttributes?.[name.slice(5)];
|
|
115
|
+
const rate = resolveRateSpec(attr("data-automation"), readPlaybackRate(attr));
|
|
116
|
+
const result = levellingResult(chain, clipWindow(audio, rate), audio.sampleRate);
|
|
117
|
+
if (!result) return null;
|
|
118
|
+
const lane = result.automation.lanes[0];
|
|
119
|
+
const toClipTime = (point: HfAutomationPoint) => ({
|
|
120
|
+
...point,
|
|
121
|
+
t: Number(timeAtSourceTime(rate, point.t).toFixed(3)),
|
|
122
|
+
});
|
|
123
|
+
return { chain: result.chain, lane: lane && { ...lane, points: lane.points.map(toClipTime) } };
|
|
124
|
+
};
|
|
125
|
+
|
|
101
126
|
const runLeveller = async (): Promise<void> => {
|
|
102
127
|
setAnalysing(true);
|
|
103
128
|
try {
|
|
104
129
|
const audio = await decodeTrack();
|
|
105
130
|
if (!audio) return;
|
|
106
|
-
const result =
|
|
131
|
+
const result = measureLevelling(audio);
|
|
107
132
|
if (!result) return;
|
|
108
133
|
trackLeveller("run");
|
|
109
134
|
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
110
135
|
// Merged by target, never written wholesale: the script describes its own
|
|
111
136
|
// lane only, and replacing the attribute would take the carve's lanes and
|
|
112
137
|
// the volume lane with it.
|
|
113
|
-
const lane = result.
|
|
138
|
+
const lane = result.lane;
|
|
114
139
|
if (lane) {
|
|
115
140
|
void onSetAttributeQuiet(
|
|
116
141
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -160,10 +185,10 @@ export function useFxLevelling(
|
|
|
160
185
|
const audio = await decodeTrack();
|
|
161
186
|
// Gone, or superseded by a later hover. Either way this result is stale.
|
|
162
187
|
if (!audio || run !== auditionRun.current) return;
|
|
163
|
-
const result =
|
|
188
|
+
const result = measureLevelling(audio);
|
|
164
189
|
if (!result || run !== auditionRun.current) return;
|
|
165
190
|
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
166
|
-
const lane = result.
|
|
191
|
+
const lane = result.lane;
|
|
167
192
|
if (lane) {
|
|
168
193
|
void onSetAttributeLive(
|
|
169
194
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
|
11
11
|
import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
12
12
|
import {
|
|
13
13
|
AudioMeterStrip,
|
|
14
|
+
type AudioMeterStripProps,
|
|
14
15
|
evictGoneMeterState,
|
|
15
16
|
followMeterHook,
|
|
16
17
|
stepAndPaintStrips,
|
|
@@ -20,8 +21,9 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const iframe = { contentWindow: null as unknown };
|
|
22
23
|
const previewIframeRef = { current: iframe };
|
|
24
|
+
let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
|
|
23
25
|
vi.mock("../../contexts/StudioContext", () => ({
|
|
24
|
-
|
|
26
|
+
useStudioShellContextOptional: () => shell,
|
|
25
27
|
}));
|
|
26
28
|
|
|
27
29
|
const onSetAudioGroupAttributeLive = vi.fn();
|
|
@@ -88,6 +90,7 @@ const tick = () => {
|
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
beforeEach(() => {
|
|
93
|
+
shell = { previewIframeRef };
|
|
91
94
|
frames = [];
|
|
92
95
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
|
93
96
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
@@ -103,11 +106,11 @@ afterEach(() => {
|
|
|
103
106
|
vi.unstubAllGlobals();
|
|
104
107
|
});
|
|
105
108
|
|
|
106
|
-
function mount() {
|
|
109
|
+
function mount(props: AudioMeterStripProps = {}) {
|
|
107
110
|
const host = document.createElement("div");
|
|
108
111
|
document.body.append(host);
|
|
109
112
|
const root = createRoot(host);
|
|
110
|
-
act(() => root.render(<AudioMeterStrip />));
|
|
113
|
+
act(() => root.render(<AudioMeterStrip {...props} />));
|
|
111
114
|
roots.push(root);
|
|
112
115
|
return { host, root };
|
|
113
116
|
}
|
|
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
|
|
|
177
180
|
expect(second.read).toHaveBeenCalled();
|
|
178
181
|
});
|
|
179
182
|
|
|
183
|
+
it("reads a host's preview iframe over the shell's", () => {
|
|
184
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
185
|
+
const shellHook = makeHook();
|
|
186
|
+
setHook(shellHook);
|
|
187
|
+
const hostHook = makeHook();
|
|
188
|
+
const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
|
|
189
|
+
mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
|
|
190
|
+
tick();
|
|
191
|
+
expect(hostHook.start).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(shellHook.start).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("mounts with no shell and no preview iframe", () => {
|
|
196
|
+
shell = null;
|
|
197
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
198
|
+
const { host } = mount();
|
|
199
|
+
tick();
|
|
200
|
+
expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
180
203
|
it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
|
|
181
204
|
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
|
|
182
205
|
const { host } = mount();
|