@hyperframes/studio 0.8.57 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +577 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +22537 -21600
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
  14. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  15. package/src/components/editor/audioInspectorInput.ts +55 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  17. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  18. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  19. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  20. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  21. package/src/components/editor/useColorGradingController.ts +2 -6
  22. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  23. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  24. package/src/components/nle/NLEContext.tsx +8 -2
  25. package/src/components/nle/NLEPreview.test.ts +1 -0
  26. package/src/components/nle/TimelinePane.tsx +29 -23
  27. package/src/components/nle/useCompositionStack.ts +1 -1
  28. package/src/contexts/TimelineEditContext.tsx +4 -0
  29. package/src/hooks/useCompositionDimensions.test.tsx +0 -1
  30. package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
  31. package/src/hooks/useSdkSession.ts +31 -4
  32. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  33. package/src/index.ts +25 -1
  34. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  35. package/src/player/components/Player.test.ts +34 -0
  36. package/src/player/components/Timeline.test.ts +22 -31
  37. package/src/player/components/Timeline.tsx +34 -7
  38. package/src/player/components/TimelineCanvas.tsx +10 -56
  39. package/src/player/components/TimelineClip.tsx +14 -1
  40. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  41. package/src/player/components/TimelineClipFades.tsx +302 -0
  42. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  43. package/src/player/components/TimelineOverlays.tsx +164 -165
  44. package/src/player/components/TimelineParts.test.tsx +101 -0
  45. package/src/player/components/TimelineParts.tsx +74 -0
  46. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  47. package/src/player/components/TimelineProvider.tsx +25 -0
  48. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  49. package/src/player/components/TimelineRulerPart.tsx +22 -0
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  52. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  53. package/src/player/components/useTimelineProviderState.tsx +19 -12
  54. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  55. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  56. package/src/player/hooks/usePlayerHandle.ts +94 -0
  57. package/src/player/index.ts +7 -0
  58. package/src/player/lib/timelineElementHelpers.ts +20 -0
  59. package/src/player/store/timelineElement.ts +5 -0
  60. package/src/styles/theme.css +4 -0
  61. package/src/utils/audioMeterMath.test.ts +44 -0
  62. package/src/utils/audioMeterMath.ts +79 -0
  63. package/src/utils/audioMeterVisibility.ts +14 -0
  64. package/src/utils/studioHelpers.ts +21 -0
  65. package/src/utils/studioUiPreferences.test.ts +10 -0
  66. package/src/utils/studioUiPreferences.ts +5 -0
  67. package/src/utils/timelineAssetDrop.test.ts +32 -0
  68. package/src/utils/timelineAssetDrop.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.57",
3
+ "version": "0.8.59",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.57",
55
- "@hyperframes/player": "0.8.57",
56
- "@hyperframes/parsers": "0.8.57",
57
- "@hyperframes/sdk": "0.8.57",
58
- "@hyperframes/studio-server": "0.8.57"
54
+ "@hyperframes/core": "0.8.59",
55
+ "@hyperframes/parsers": "0.8.59",
56
+ "@hyperframes/player": "0.8.59",
57
+ "@hyperframes/studio-server": "0.8.59",
58
+ "@hyperframes/sdk": "0.8.59"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.57"
74
+ "@hyperframes/producer": "0.8.59"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -4,6 +4,7 @@ import {
4
4
  Magnet,
5
5
  MagnifyingGlassMinus,
6
6
  MagnifyingGlassPlus,
7
+ SpeakerHigh,
7
8
  Waves,
8
9
  } from "@phosphor-icons/react";
9
10
  import {
@@ -28,6 +29,8 @@ import { Scissors } from "../icons/SystemIcons";
28
29
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
29
30
  import type { DomEditSelection } from "./editor/domEditingTypes";
30
31
  import { canSplitElement } from "../utils/timelineElementSplit";
32
+ import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
33
+ import { useProjectHasAudio } from "../utils/audioMeterMath";
31
34
  import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
32
35
 
33
36
  interface DomEditSessionSlice extends EnableKeyframesSession {
@@ -141,6 +144,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
141
144
  const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
142
145
  const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
143
146
  const thumbnailsVisible = thumbnailMode === "adaptive";
147
+ const audioMetersVisible = useAudioMetersVisible((s) => s.visible);
148
+ const setAudioMetersVisible = useAudioMetersVisible((s) => s.setVisible);
149
+ const projectHasAudio = useProjectHasAudio();
144
150
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
145
151
  const currentTime = usePlayerStore((s) => s.currentTime);
146
152
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -249,6 +255,19 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
249
255
  <Waves size={16} weight="bold" aria-hidden="true" />
250
256
  </button>
251
257
  </Tooltip>
258
+ {projectHasAudio && (
259
+ <Tooltip label={audioMetersVisible ? "Hide audio meters" : "Show audio meters"}>
260
+ <button
261
+ type="button"
262
+ onClick={() => setAudioMetersVisible(!audioMetersVisible)}
263
+ aria-label="Toggle audio meters"
264
+ aria-pressed={audioMetersVisible}
265
+ className={audioMetersVisible ? flatActive : flatIdle}
266
+ >
267
+ <SpeakerHigh size={16} weight="bold" aria-hidden="true" />
268
+ </button>
269
+ </Tooltip>
270
+ )}
252
271
  {/* Always rendered (CapCut-style): with no keyframeable selection the
253
272
  button fades to a disabled state instead of unmounting, so the
254
273
  toolbar layout never shifts. */}
@@ -52,7 +52,6 @@ it("re-evaluates set-destination availability when a reload swaps the live ifram
52
52
  expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
53
53
 
54
54
  act(() => {
55
- ref.current = after;
56
55
  usePreviewIframeStore.getState().setIframe(after);
57
56
  });
58
57
  expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
3
+ import { RATE_RANGE } from "@hyperframes/core/audio-automation";
4
+ import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
5
+
6
+ describe("parseGainInput", () => {
7
+ it("reads decibels as the readout shows them", () => {
8
+ expect(parseGainInput("0")).toBeCloseTo(1, 6);
9
+ expect(parseGainInput("+0.5 dB")).toBeCloseTo(10 ** (0.5 / 20), 6);
10
+ expect(parseGainInput("-6")).toBeCloseTo(10 ** (-6 / 20), 6);
11
+ expect(parseGainInput("-6dB")).toBeCloseTo(10 ** (-6 / 20), 6);
12
+ expect(parseGainInput("-inf")).toBe(0);
13
+ });
14
+ it("also takes a linear multiplier or a percentage", () => {
15
+ expect(parseGainInput("x0.8")).toBeCloseTo(0.8, 6);
16
+ expect(parseGainInput("0.8x")).toBeCloseTo(0.8, 6);
17
+ expect(parseGainInput("80%")).toBeCloseTo(0.8, 6);
18
+ });
19
+ it("clamps to the fader's ceiling and refuses nonsense", () => {
20
+ expect(parseGainInput("+40 dB")).toBeCloseTo(MAX_AUDIO_GAIN, 6);
21
+ expect(parseGainInput("loud")).toBeNull();
22
+ expect(parseGainInput("")).toBeNull();
23
+ expect(parseGainInput("x-1")).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe("parseRateInput", () => {
28
+ it("reads a multiplier with or without the x, and a percentage", () => {
29
+ expect(parseRateInput("1.5x")).toBe(1.5);
30
+ expect(parseRateInput("1.5")).toBe(1.5);
31
+ expect(parseRateInput("x2")).toBe(2);
32
+ expect(parseRateInput("150%")).toBe(1.5);
33
+ });
34
+ it("clamps to the rate range and refuses zero or text", () => {
35
+ expect(parseRateInput("100x")).toBe(RATE_RANGE.max);
36
+ expect(parseRateInput("0.001")).toBe(RATE_RANGE.min);
37
+ expect(parseRateInput("0")).toBeNull();
38
+ expect(parseRateInput("fast")).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("parseSecondsInput", () => {
43
+ it("reads seconds, milliseconds and minutes:seconds", () => {
44
+ expect(parseSecondsInput("2.5")).toBe(2.5);
45
+ expect(parseSecondsInput("2.5s")).toBe(2.5);
46
+ expect(parseSecondsInput("500ms")).toBe(0.5);
47
+ expect(parseSecondsInput("1:02.5")).toBe(62.5);
48
+ expect(parseSecondsInput("0")).toBe(0);
49
+ });
50
+ it("refuses negatives and text", () => {
51
+ expect(parseSecondsInput("-1")).toBeNull();
52
+ expect(parseSecondsInput("soon")).toBeNull();
53
+ expect(parseSecondsInput("")).toBeNull();
54
+ });
55
+ });
@@ -0,0 +1,55 @@
1
+ import { audioDbToGain, clampAudioGain } from "@hyperframes/core/audio-gain";
2
+ import { RATE_RANGE } from "@hyperframes/core/audio-automation";
3
+
4
+ const NUMBER = String.raw`[-+]?(?:\d+\.?\d*|\.\d+)`;
5
+
6
+ /** Gain as the readout shows it (dB, "x0.8", "80%"). Returns linear gain, or null to refuse. */
7
+ export function parseGainInput(text: string): number | null {
8
+ const t = text.trim().toLowerCase().replace(/\s+/g, "");
9
+ if (!t) return null;
10
+ if (t === "-inf" || t === "-infinity" || t === "-∞" || t === "silence" || t === "mute") return 0;
11
+ let m = new RegExp(`^(${NUMBER})%$`).exec(t);
12
+ if (m) return finiteGain(Number(m[1]) / 100);
13
+ m = new RegExp(`^(?:x(${NUMBER})|(${NUMBER})x)$`).exec(t);
14
+ if (m) return finiteGain(Number(m[1] ?? m[2]));
15
+ m = new RegExp(`^(${NUMBER})(?:db)?$`).exec(t);
16
+ if (m) return finiteGain(audioDbToGain(Number(m[1])));
17
+ return null;
18
+ }
19
+
20
+ function finiteGain(gain: number): number | null {
21
+ return Number.isFinite(gain) && gain >= 0 ? clampAudioGain(gain) : null;
22
+ }
23
+
24
+ /** Playback rate: "1.5x", "1.5", "150%". Clamped to the rate lane's range. */
25
+ export function parseRateInput(text: string): number | null {
26
+ const t = text.trim().toLowerCase().replace(/\s+/g, "");
27
+ if (!t) return null;
28
+ let value: number;
29
+ let m = new RegExp(`^(${NUMBER})%$`).exec(t);
30
+ if (m) value = Number(m[1]) / 100;
31
+ else {
32
+ m = new RegExp(`^(?:x(${NUMBER})|(${NUMBER})x?)$`).exec(t);
33
+ if (!m) return null;
34
+ value = Number(m[1] ?? m[2]);
35
+ }
36
+ if (!Number.isFinite(value) || value <= 0) return null;
37
+ return Math.min(RATE_RANGE.max, Math.max(RATE_RANGE.min, value));
38
+ }
39
+
40
+ /** Non-negative duration or offset: "2.5", "2.5s", "500ms", "1:02.5". Returns seconds, or null to refuse. */
41
+ export function parseSecondsInput(text: string): number | null {
42
+ const t = text.trim().toLowerCase().replace(/\s+/g, "");
43
+ if (!t) return null;
44
+ let m = new RegExp(`^(\\d+):(\\d{1,2}(?:\\.\\d*)?)$`).exec(t);
45
+ if (m) return finiteSeconds(Number(m[1]) * 60 + Number(m[2]));
46
+ m = new RegExp(`^(${NUMBER})ms$`).exec(t);
47
+ if (m) return finiteSeconds(Number(m[1]) / 1000);
48
+ m = new RegExp(`^(${NUMBER})(?:s|sec|secs)?$`).exec(t);
49
+ if (m) return finiteSeconds(Number(m[1]));
50
+ return null;
51
+ }
52
+
53
+ function finiteSeconds(seconds: number): number | null {
54
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
55
+ }
@@ -474,3 +474,175 @@ describe("FlatMediaSection — fit/position", () => {
474
474
  act(() => root.unmount());
475
475
  });
476
476
  });
477
+
478
+ function makeAudioElement(dataAttributes: Record<string, string> = {}): DomEditSelection {
479
+ const el = document.createElement("audio");
480
+ el.setAttribute("src", "assets/music.wav");
481
+ return makeVideoElement({
482
+ element: el,
483
+ id: "music",
484
+ selector: "#music",
485
+ label: "Music",
486
+ tagName: "audio",
487
+ dataAttributes: { duration: "10", ...dataAttributes },
488
+ });
489
+ }
490
+
491
+ function renderWithRate(element: DomEditSelection, onSetAttribute = vi.fn()) {
492
+ const host = document.createElement("div");
493
+ document.body.append(host);
494
+ const root = createRoot(host);
495
+ act(() => {
496
+ root.render(
497
+ <FlatMediaSection
498
+ projectDir={null}
499
+ element={element}
500
+ styles={{}}
501
+ onSetStyle={vi.fn()}
502
+ onSetAttribute={onSetAttribute}
503
+ onSetHtmlAttribute={vi.fn()}
504
+ rate={{
505
+ automated: false,
506
+ automatedValue: undefined,
507
+ onAutomate: vi.fn(),
508
+ onRemoveAutomation: vi.fn(),
509
+ onCommitAt: vi.fn(),
510
+ canApplyPreset: true,
511
+ onApplyPreset: vi.fn(),
512
+ }}
513
+ />,
514
+ );
515
+ });
516
+ return { host, root, onSetAttribute };
517
+ }
518
+
519
+ function labelsOf(host: HTMLElement): string[] {
520
+ return [...host.querySelectorAll('[data-flat-slider-track="true"]')].map(
521
+ (track) => track.getAttribute("aria-label") ?? "",
522
+ );
523
+ }
524
+
525
+ describe("FlatMediaSection — audio clips", () => {
526
+ it("offers speed presets on video but not on audio", () => {
527
+ const video = renderWithRate(makeVideoElement({ dataAttributes: { duration: "10" } }));
528
+ expect(video.host.textContent).toContain("Speed preset");
529
+ act(() => video.root.unmount());
530
+
531
+ const audio = renderWithRate(makeAudioElement());
532
+ expect(audio.host.textContent).not.toContain("Speed preset");
533
+ act(() => audio.root.unmount());
534
+ });
535
+
536
+ it("shows Fade in / Fade out rows for audio, and for video only when it carries audio", () => {
537
+ const audio = renderWithRate(makeAudioElement());
538
+ expect(labelsOf(audio.host)).toEqual(
539
+ expect.arrayContaining(["Volume", "Speed", "Media start", "Fade in", "Fade out"]),
540
+ );
541
+ act(() => audio.root.unmount());
542
+
543
+ const silentVideo = renderWithRate(makeVideoElement({ dataAttributes: { duration: "10" } }));
544
+ expect(labelsOf(silentVideo.host)).not.toContain("Fade in");
545
+ act(() => silentVideo.root.unmount());
546
+
547
+ const audibleVideo = renderWithRate(
548
+ makeVideoElement({ dataAttributes: { duration: "10", "has-audio": "true" } }),
549
+ );
550
+ expect(labelsOf(audibleVideo.host)).toContain("Fade out");
551
+ act(() => audibleVideo.root.unmount());
552
+ });
553
+
554
+ it("reads the authored fades and writes data-fade-in from the slider, clearing it at zero", () => {
555
+ const { host, root, onSetAttribute } = renderWithRate(
556
+ makeAudioElement({ "fade-in": "0.5", "fade-out": "2" }),
557
+ );
558
+ const readouts = [...host.querySelectorAll('[data-flat-slider-value="true"]')].map(
559
+ (node) => node.textContent,
560
+ );
561
+ expect(readouts).toEqual(expect.arrayContaining(["0.50s", "2.00s"]));
562
+
563
+ const fadeInTrack = host.querySelector<HTMLElement>(
564
+ '[data-flat-slider-track="true"][aria-label="Fade in"]',
565
+ );
566
+ if (!fadeInTrack) throw new Error("expected a Fade in slider");
567
+ Object.defineProperty(fadeInTrack, "getBoundingClientRect", {
568
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
569
+ });
570
+ // The other fade reserves 2 s: a quarter of the remaining 8 s is 2 s.
571
+ act(() => {
572
+ fadeInTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 25 }));
573
+ fadeInTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 25 }));
574
+ });
575
+ expect(onSetAttribute).toHaveBeenCalledWith("fade-in", "2");
576
+ act(() => {
577
+ fadeInTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 0 }));
578
+ fadeInTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 0 }));
579
+ });
580
+ // An empty write removes the attribute rather than leaving data-fade-in="0" behind.
581
+ expect(onSetAttribute).toHaveBeenCalledWith("fade-in", "");
582
+ act(() => root.unmount());
583
+ });
584
+
585
+ it.each([
586
+ ["Media start", "media-start", "45.00", {}, "9999"],
587
+ ["Fade in", "fade-in", "10", {}, "9999"],
588
+ ["Fade out", "fade-out", "10", {}, "9999"],
589
+ ["Fade in", "fade-in", "5", { "fade-out": "5" }, "8"],
590
+ ["Fade out", "fade-out", "5", { "fade-in": "5" }, "8"],
591
+ ] as const)("bounds typed %s to the slider limit", (label, attribute, expected, fades, typed) => {
592
+ const { host, root, onSetAttribute } = renderWithRate(
593
+ makeAudioElement({ "source-duration": "45", ...fades }),
594
+ );
595
+ const row = host.querySelector<HTMLElement>(
596
+ `[data-flat-slider-track="true"][aria-label="${label}"]`,
597
+ )?.parentElement;
598
+ const readout = row?.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
599
+ if (!readout) throw new Error(`expected ${label} readout`);
600
+ act(() => readout.dispatchEvent(new MouseEvent("click", { bubbles: true })));
601
+ const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
602
+ if (!input) throw new Error(`expected ${label} input`);
603
+ act(() => {
604
+ typeInto(input, typed);
605
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
606
+ });
607
+ expect(onSetAttribute.mock.calls).toEqual([[attribute, expected]]);
608
+ act(() => root.unmount());
609
+ });
610
+
611
+ it("commits a typed volume in dB and a typed fade in seconds", () => {
612
+ const { host, root, onSetAttribute } = renderWithRate(makeAudioElement());
613
+ const readouts = host.querySelectorAll<HTMLElement>('[data-flat-slider-value="true"]');
614
+ // Volume is the first slider row.
615
+ act(() => readouts[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
616
+ const volumeInput = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
617
+ if (!volumeInput) throw new Error("expected the volume readout to open for typing");
618
+ act(() => {
619
+ typeInto(volumeInput, "-6 dB");
620
+ volumeInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
621
+ });
622
+ const [attr, value] = onSetAttribute.mock.calls.at(-1) ?? [];
623
+ expect(attr).toBe("volume");
624
+ expect(Number(value)).toBeCloseTo(10 ** (-6 / 20), 2);
625
+
626
+ const fadeOutRow = host.querySelector<HTMLElement>(
627
+ '[data-flat-slider-track="true"][aria-label="Fade out"]',
628
+ )?.parentElement;
629
+ const fadeOutReadout = fadeOutRow?.querySelector<HTMLElement>(
630
+ '[data-flat-slider-value="true"]',
631
+ );
632
+ act(() => fadeOutReadout?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
633
+ const fadeInput = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
634
+ if (!fadeInput) throw new Error("expected the fade readout to open for typing");
635
+ act(() => {
636
+ typeInto(fadeInput, "1.25s");
637
+ fadeInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
638
+ });
639
+ expect(onSetAttribute).toHaveBeenLastCalledWith("fade-out", "1.25");
640
+ act(() => root.unmount());
641
+ });
642
+ });
643
+
644
+ function typeInto(input: HTMLInputElement, value: string) {
645
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
646
+ setter?.call(input, value);
647
+ input.dispatchEvent(new Event("input", { bubbles: true }));
648
+ }
@@ -25,6 +25,14 @@ import {
25
25
  audioGainToFaderPosition,
26
26
  audioGainToText,
27
27
  } from "@hyperframes/core/audio-gain";
28
+ import {
29
+ HF_AUDIO_FADE_IN_DATA_KEY,
30
+ HF_AUDIO_FADE_OUT_DATA_KEY,
31
+ clampFadesToDuration,
32
+ formatFadeSeconds,
33
+ readFadeSeconds,
34
+ } from "@hyperframes/core/audio-fade";
35
+ import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
28
36
 
29
37
  // fallow-ignore-next-line complexity
30
38
  export function FlatMediaSection({
@@ -92,6 +100,10 @@ export function FlatMediaSection({
92
100
  (el as HTMLMediaElement).duration ||
93
101
  0;
94
102
  const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
103
+ const fadeIn = readFadeSeconds(element.dataAttributes[HF_AUDIO_FADE_IN_DATA_KEY]);
104
+ const fadeOut = readFadeSeconds(element.dataAttributes[HF_AUDIO_FADE_OUT_DATA_KEY]);
105
+ const clipDuration = Number.parseFloat(element.dataAttributes.duration ?? "") || 0;
106
+ const fadeMax = clipDuration > 0 ? clipDuration : 10;
95
107
  const hasLoop = el.hasAttribute("loop");
96
108
  const hasMuted = el.hasAttribute("muted");
97
109
  const hasAudio = element.dataAttributes["has-audio"] === "true";
@@ -256,6 +268,13 @@ export function FlatMediaSection({
256
268
  void onSetAttribute("volume", formatAudioGain(gain));
257
269
  }
258
270
  }}
271
+ onCommitText={(text) => {
272
+ const gain = parseGainInput(text);
273
+ if (gain === null) return false;
274
+ if (volumeAutomated) onCommitVolumeAt?.(gain);
275
+ else void onSetAttribute("volume", formatAudioGain(gain));
276
+ return true;
277
+ }}
259
278
  />
260
279
  </div>
261
280
  <AutomationToggle
@@ -285,6 +304,13 @@ export function FlatMediaSection({
285
304
  void onSetAttribute("playback-rate", formatNumericValue(speed));
286
305
  }
287
306
  }}
307
+ onCommitText={(text) => {
308
+ const speed = parseRateInput(text);
309
+ if (speed === null) return false;
310
+ if (rate?.automated) rate.onCommitAt(speed);
311
+ else void onSetAttribute("playback-rate", formatNumericValue(speed));
312
+ return true;
313
+ }}
288
314
  />
289
315
  </div>
290
316
  <AutomationToggle
@@ -295,7 +321,10 @@ export function FlatMediaSection({
295
321
  onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
296
322
  />
297
323
  </div>
298
- {rate?.canApplyPreset && (
324
+ {/* Speed presets are picture-driven ramps (slow-mo reveals, whip
325
+ speed-ups); on a bare audio clip they only warp pitch, so the
326
+ row is video-only. */}
327
+ {rate?.canApplyPreset && !isAudio && (
299
328
  <FlatSelectRow
300
329
  label="Speed preset"
301
330
  value=""
@@ -312,7 +341,21 @@ export function FlatMediaSection({
312
341
  tier={mediaStart === 0 ? "default" : "explicitCustom"}
313
342
  displayValue={formatTimingValue(mediaStart)}
314
343
  onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
344
+ onCommitText={(text) => {
345
+ const seconds = parseSecondsInput(text);
346
+ if (seconds === null) return false;
347
+ void onSetAttribute("media-start", Math.min(seconds, mediaStartMax).toFixed(2));
348
+ return true;
349
+ }}
315
350
  />
351
+ {(isAudio || hasAudio) && (
352
+ <MediaFadeSliders
353
+ fadeIn={fadeIn}
354
+ fadeOut={fadeOut}
355
+ fadeMax={fadeMax}
356
+ onSetAttribute={onSetAttribute}
357
+ />
358
+ )}
316
359
  <FlatToggle
317
360
  label="Loop"
318
361
  checked={hasLoop}
@@ -371,3 +414,46 @@ export function FlatMediaSection({
371
414
  </div>
372
415
  );
373
416
  }
417
+
418
+ function MediaFadeSliders({
419
+ fadeIn,
420
+ fadeOut,
421
+ fadeMax,
422
+ onSetAttribute,
423
+ }: {
424
+ fadeIn: number;
425
+ fadeOut: number;
426
+ fadeMax: number;
427
+ onSetAttribute: (attr: string, value: string) => void | Promise<void>;
428
+ }) {
429
+ const fadeText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : "");
430
+ return (["in", "out"] as const).map((edge) => {
431
+ const seconds = edge === "in" ? fadeIn : fadeOut;
432
+ const dataKey = edge === "in" ? HF_AUDIO_FADE_IN_DATA_KEY : HF_AUDIO_FADE_OUT_DATA_KEY;
433
+ const edgeMax = Math.max(0, fadeMax - (edge === "in" ? fadeOut : fadeIn));
434
+ return (
435
+ <FlatSlider
436
+ key={edge}
437
+ label={`Fade ${edge}`}
438
+ value={Math.round(seconds * 100)}
439
+ min={0}
440
+ max={Math.round(edgeMax * 100)}
441
+ tier={seconds === 0 ? "default" : "explicitCustom"}
442
+ displayValue={formatTimingValue(seconds)}
443
+ onCommit={(next) => void onSetAttribute(dataKey, fadeText(next / 100))}
444
+ onCommitText={(text) => {
445
+ const parsed = parseSecondsInput(text);
446
+ if (parsed === null) return false;
447
+ const next = Math.min(parsed, edgeMax);
448
+ const bounded = clampFadesToDuration(
449
+ { fadeIn: edge === "in" ? next : fadeIn, fadeOut: edge === "out" ? next : fadeOut },
450
+ fadeMax,
451
+ );
452
+ void onSetAttribute(dataKey, fadeText(edge === "in" ? bounded.fadeIn : bounded.fadeOut));
453
+ return true;
454
+ }}
455
+ onReset={seconds > 0 ? () => void onSetAttribute(dataKey, "") : undefined}
456
+ />
457
+ );
458
+ });
459
+ }
@@ -1285,3 +1285,95 @@ describe("FlatSelectRow — label/value options", () => {
1285
1285
  act(() => root.unmount());
1286
1286
  });
1287
1287
  });
1288
+
1289
+ describe("FlatSlider — typed value", () => {
1290
+ it("keeps the readout inert without onCommitText", () => {
1291
+ const { host, root } = renderInto(
1292
+ <FlatSlider
1293
+ label="Opacity"
1294
+ value={10}
1295
+ min={0}
1296
+ max={100}
1297
+ tier="default"
1298
+ displayValue="10%"
1299
+ onCommit={vi.fn()}
1300
+ />,
1301
+ );
1302
+ const readout = host.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
1303
+ expect(readout?.getAttribute("role")).toBeNull();
1304
+ act(() => readout?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
1305
+ expect(host.querySelector('[data-flat-slider-input="true"]')).toBeNull();
1306
+ act(() => root.unmount());
1307
+ });
1308
+
1309
+ it("turns the readout into an input on click and commits the typed text on Enter", () => {
1310
+ const onCommitText = vi.fn().mockReturnValue(true);
1311
+ const { host, root } = renderInto(
1312
+ <FlatSlider
1313
+ label="Volume"
1314
+ value={0}
1315
+ min={-100}
1316
+ max={100}
1317
+ tier="default"
1318
+ displayValue="0 dB"
1319
+ onCommit={vi.fn()}
1320
+ onCommitText={onCommitText}
1321
+ />,
1322
+ );
1323
+ const readout = host.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
1324
+ expect(readout?.getAttribute("role")).toBe("button");
1325
+ act(() => readout?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
1326
+ const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
1327
+ if (!input) throw new Error("expected the readout to become an input");
1328
+ expect(input.value).toBe("0 dB");
1329
+ act(() => {
1330
+ setReactInputValue(input, "-6 dB");
1331
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
1332
+ });
1333
+ expect(onCommitText).toHaveBeenCalledWith("-6 dB");
1334
+ expect(host.querySelector('[data-flat-slider-input="true"]')).toBeNull();
1335
+ act(() => root.unmount());
1336
+ });
1337
+
1338
+ it("stays open and marks itself invalid when the text is refused, and closes on Escape", () => {
1339
+ const onCommitText = vi.fn().mockReturnValue(false);
1340
+ const { host, root } = renderInto(
1341
+ <FlatSlider
1342
+ label="Speed"
1343
+ value={500}
1344
+ min={0}
1345
+ max={1000}
1346
+ tier="default"
1347
+ displayValue="1x"
1348
+ onCommit={vi.fn()}
1349
+ onCommitText={onCommitText}
1350
+ />,
1351
+ );
1352
+ act(() =>
1353
+ host
1354
+ .querySelector<HTMLElement>('[data-flat-slider-value="true"]')
1355
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
1356
+ );
1357
+ const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
1358
+ if (!input) throw new Error("expected an input");
1359
+ act(() => {
1360
+ setReactInputValue(input, "fast");
1361
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
1362
+ });
1363
+ expect(onCommitText).toHaveBeenCalledWith("fast");
1364
+ const still = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
1365
+ expect(still).not.toBeNull();
1366
+ expect(still?.getAttribute("aria-invalid")).toBe("true");
1367
+ act(() => still?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
1368
+ expect(host.querySelector('[data-flat-slider-input="true"]')).toBeNull();
1369
+ expect(host.querySelector('[data-flat-slider-value="true"]')?.textContent).toBe("1x");
1370
+ act(() => root.unmount());
1371
+ });
1372
+ });
1373
+
1374
+ /** Drives React's controlled input the way a keystroke would. */
1375
+ function setReactInputValue(input: HTMLInputElement, value: string) {
1376
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
1377
+ setter?.call(input, value);
1378
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1379
+ }
@@ -1,7 +1,8 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
1
+ import { type ReactNode, useEffect, useRef, useState } from "react";
2
2
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
3
  import { RotateCcw } from "../../icons/SystemIcons";
4
4
  import { CommitField } from "./propertyPanelPrimitives";
5
+ import { FlatSliderReadout } from "./propertyPanelFlatSliderReadout";
5
6
  import {
6
7
  VALUE_TIER_LABEL_CLASS,
7
8
  VALUE_TIER_VALUE_CLASS,
@@ -278,6 +279,7 @@ export function FlatSlider({
278
279
  centerTick,
279
280
  onReset,
280
281
  onCommit,
282
+ onCommitText,
281
283
  }: {
282
284
  label: string;
283
285
  value: number;
@@ -290,6 +292,8 @@ export function FlatSlider({
290
292
  centerTick?: boolean;
291
293
  onReset?: () => void;
292
294
  onCommit: (nextValue: number) => void;
295
+ /** Typed readout: return false to refuse the text, keep the field open, and mark it invalid. */
296
+ onCommitText?: (text: string) => boolean | void;
293
297
  }) {
294
298
  const track = useTrackDesignInput();
295
299
  // `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
@@ -538,14 +542,14 @@ export function FlatSlider({
538
542
  style={{ left: `${clampedPct}%` }}
539
543
  />
540
544
  </div>
541
- <span
542
- data-flat-slider-value="true"
543
- className={`w-11 shrink-0 text-right font-mono text-[10px] ${
544
- tier === "explicitCustom" ? "text-panel-text-0" : "text-panel-text-3"
545
- }`}
546
- >
547
- {displayValue}
548
- </span>
545
+ <FlatSliderReadout
546
+ label={label}
547
+ displayValue={displayValue}
548
+ tier={tier}
549
+ disabled={disabled}
550
+ onCommitText={onCommitText}
551
+ onCommitted={() => track("input", label)}
552
+ />
549
553
  {(centerTick || onReset) && (
550
554
  <span data-flat-slider-reset-slot="true" className="w-3.5 shrink-0">
551
555
  {tier === "explicitCustom" && onReset && (