@hyperframes/studio 0.8.6 → 0.8.7
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-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10336 -7195
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +2 -0
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +7 -7
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +5 -0
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -469,7 +469,7 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
469
469
|
});
|
|
470
470
|
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
471
471
|
act(() => byTextButton(host, "Audio FX")?.click());
|
|
472
|
-
act(() => byTextButton(host, "
|
|
472
|
+
act(() => byTextButton(host, "+ effect")?.click());
|
|
473
473
|
await act(async () => {
|
|
474
474
|
byTextButton(host, "Even Out Levels")?.click();
|
|
475
475
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -581,7 +581,7 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
581
581
|
const { release, decoded } = stubGatedDecode();
|
|
582
582
|
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
|
583
583
|
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
584
|
-
act(() => byTextButton(host, "
|
|
584
|
+
act(() => byTextButton(host, "+ effect")?.click());
|
|
585
585
|
const level = byTextButton(host, "Even Out Levels");
|
|
586
586
|
expect(level, "the levelling button was not offered").toBeTruthy();
|
|
587
587
|
act(() => level?.focus());
|
|
@@ -611,7 +611,7 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
611
611
|
const { release, decoded } = stubGatedDecode();
|
|
612
612
|
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
|
613
613
|
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
|
614
|
-
act(() => byTextButton(host, "
|
|
614
|
+
act(() => byTextButton(host, "+ effect")?.click());
|
|
615
615
|
act(() => byTextButton(host, "Even Out Levels")?.focus());
|
|
616
616
|
// Straight to a neighbour, without ever leaving the shelf.
|
|
617
617
|
act(() =>
|
|
@@ -1507,6 +1507,142 @@ describe("AudioFxGroup carve source list", () => {
|
|
|
1507
1507
|
});
|
|
1508
1508
|
});
|
|
1509
1509
|
|
|
1510
|
+
describe("AudioFxGroup carve targets groups (B6)", () => {
|
|
1511
|
+
// These tests are the only ones in this file whose auto-carve effect makes a
|
|
1512
|
+
// real cross-element write call (`onAutoGroupCarveSources`), which can be a
|
|
1513
|
+
// genuine Promise. None of this file's other `mount*` helpers ever unmount
|
|
1514
|
+
// their React root — harmless everywhere else because their effects only
|
|
1515
|
+
// ever touch a plain `onSetAttributeQuiet` mock, so an orphaned root left
|
|
1516
|
+
// over from an earlier test does nothing observable if it ever re-renders.
|
|
1517
|
+
// Here it can re-fire the auto-group effect against WHATEVER a later test's
|
|
1518
|
+
// fixture put in the (file-global) `document`, calling a long-dead test's
|
|
1519
|
+
// mock — unmounting is what prevents that.
|
|
1520
|
+
const roots: ReturnType<typeof createRoot>[] = [];
|
|
1521
|
+
afterEach(() => {
|
|
1522
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
/** A bed, plus tracks that may carry `data-audio-group`, plus an optional auto-group handler. */
|
|
1526
|
+
const mountWith = (
|
|
1527
|
+
tracks: { id: string; group?: string; start?: string; duration?: string }[],
|
|
1528
|
+
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>,
|
|
1529
|
+
) => {
|
|
1530
|
+
const bed = document.createElement("audio");
|
|
1531
|
+
bed.id = "bed";
|
|
1532
|
+
document.body.append(bed);
|
|
1533
|
+
for (const t of tracks) {
|
|
1534
|
+
const el = document.createElement("audio");
|
|
1535
|
+
el.id = t.id;
|
|
1536
|
+
if (t.group) el.setAttribute("data-audio-group", t.group);
|
|
1537
|
+
if (t.start !== undefined) el.setAttribute("data-start", t.start);
|
|
1538
|
+
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
|
|
1539
|
+
document.body.append(el);
|
|
1540
|
+
}
|
|
1541
|
+
const onSetAttributeQuiet = vi.fn();
|
|
1542
|
+
const host = document.createElement("div");
|
|
1543
|
+
document.body.append(host);
|
|
1544
|
+
const root = createRoot(host);
|
|
1545
|
+
roots.push(root);
|
|
1546
|
+
act(() => {
|
|
1547
|
+
root.render(
|
|
1548
|
+
<AudioFxGroup
|
|
1549
|
+
element={{ dataAttributes: {}, id: "bed", element: bed } as unknown as DomEditSelection}
|
|
1550
|
+
onSetAttributeQuiet={onSetAttributeQuiet}
|
|
1551
|
+
onSetAttributeLive={vi.fn()}
|
|
1552
|
+
onAutoGroupCarveSources={onAutoGroupCarveSources}
|
|
1553
|
+
/>,
|
|
1554
|
+
);
|
|
1555
|
+
});
|
|
1556
|
+
const offered = Array.from(host.querySelectorAll<HTMLElement>("[data-carve-source]"));
|
|
1557
|
+
const options = offered.map((el) => el.dataset["carveSource"] ?? "");
|
|
1558
|
+
const boxes = offered.filter((el): el is HTMLInputElement => el instanceof HTMLInputElement);
|
|
1559
|
+
return { host, options, boxes, onSetAttributeQuiet };
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
it("offers one entry for a group and hides its members individually", () => {
|
|
1563
|
+
const { options } = mountWith([
|
|
1564
|
+
{ id: "vo-1", group: "voiceover" },
|
|
1565
|
+
{ id: "vo-2", group: "voiceover" },
|
|
1566
|
+
]);
|
|
1567
|
+
expect(options).toEqual(["voiceover"]);
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("offers the group when only ONE member overlaps the bed (union, not per-clip)", () => {
|
|
1571
|
+
// The bed spans the whole default window (no start/duration on the
|
|
1572
|
+
// selection itself in this harness resolves to [0, Infinity)), so give the
|
|
1573
|
+
// members explicit, non-overlapping-with-each-other spans and confirm the
|
|
1574
|
+
// group still appears as long as at least one of them is in range.
|
|
1575
|
+
const { options } = mountWith([
|
|
1576
|
+
{ id: "vo-1", group: "voiceover", start: "0", duration: "5" },
|
|
1577
|
+
{ id: "vo-2", group: "voiceover", start: "1000", duration: "5" },
|
|
1578
|
+
]);
|
|
1579
|
+
expect(options).toEqual(["voiceover"]);
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
it("auto-selects the group over its individual members", () => {
|
|
1583
|
+
const { onSetAttributeQuiet } = mountWith([
|
|
1584
|
+
{ id: "vo-1", group: "voiceover" },
|
|
1585
|
+
{ id: "vo-2", group: "voiceover" },
|
|
1586
|
+
]);
|
|
1587
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1588
|
+
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
it("auto-groups a multi-voice carve into one named group, atomically", async () => {
|
|
1592
|
+
// Two ungrouped, both voice-classified: the existing "every candidate
|
|
1593
|
+
// carves itself" mount effect names both by id, which is exactly the
|
|
1594
|
+
// plural-ungrouped case B6 intercepts — the carve should land on the
|
|
1595
|
+
// minted group, not on the two ids directly.
|
|
1596
|
+
const onAutoGroupCarveSources = vi.fn().mockResolvedValue(undefined);
|
|
1597
|
+
const { onSetAttributeQuiet } = mountWith(
|
|
1598
|
+
[
|
|
1599
|
+
{ id: "narration", start: "0", duration: "5" },
|
|
1600
|
+
{ id: "interview-guest", start: "10", duration: "5" },
|
|
1601
|
+
],
|
|
1602
|
+
onAutoGroupCarveSources,
|
|
1603
|
+
);
|
|
1604
|
+
expect(onAutoGroupCarveSources).toHaveBeenCalledWith(
|
|
1605
|
+
["narration", "interview-guest"],
|
|
1606
|
+
"voiceover",
|
|
1607
|
+
);
|
|
1608
|
+
// Explicitly await the exact promise `assignGroup` returned — a bare
|
|
1609
|
+
// `await Promise.resolve()`/`setTimeout` flush is guessing how deep the
|
|
1610
|
+
// chain behind it goes (its `.then(...)`, the write, and the re-analysis
|
|
1611
|
+
// `setCarve` awaits afterward). Left genuinely unresolved when this test
|
|
1612
|
+
// returns, that chain settles during a LATER test instead, after this
|
|
1613
|
+
// one's mocks and DOM are gone.
|
|
1614
|
+
await act(async () => {
|
|
1615
|
+
await onAutoGroupCarveSources.mock.results[0]?.value;
|
|
1616
|
+
// Two more turns of the microtask queue: one for the `.then(...)` that
|
|
1617
|
+
// builds the grouped settings, one for `setCarve`'s own trailing
|
|
1618
|
+
// `await analyse(next)` (a no-op here — the fixture's tracks have no
|
|
1619
|
+
// `src`, so `resolveCarveVoices` returns empty and `analyse` exits
|
|
1620
|
+
// immediately, but it still has to actually run to completion).
|
|
1621
|
+
await Promise.resolve();
|
|
1622
|
+
await Promise.resolve();
|
|
1623
|
+
});
|
|
1624
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1625
|
+
expect(write).toBeTruthy();
|
|
1626
|
+
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it("leaves sources alone when one of them already names a group", () => {
|
|
1630
|
+
const onAutoGroupCarveSources = vi.fn();
|
|
1631
|
+
const { onSetAttributeQuiet } = mountWith(
|
|
1632
|
+
[
|
|
1633
|
+
{ id: "vo-1", group: "voiceover" },
|
|
1634
|
+
{ id: "vo-2", group: "voiceover" },
|
|
1635
|
+
],
|
|
1636
|
+
onAutoGroupCarveSources,
|
|
1637
|
+
);
|
|
1638
|
+
// The default-carve effect already named the group (previous test above),
|
|
1639
|
+
// so no auto-group call should ever fire for an all-group source list.
|
|
1640
|
+
expect(onAutoGroupCarveSources).not.toHaveBeenCalled();
|
|
1641
|
+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
|
|
1642
|
+
expect(JSON.parse(String(write![1])).sources).toEqual(["voiceover"]);
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1510
1646
|
describe("AudioFxGroup carve source range", () => {
|
|
1511
1647
|
const spanned = (tracks: { id: string; start?: string; duration?: string }[]): string[] => {
|
|
1512
1648
|
const bed = document.createElement("audio");
|
|
@@ -55,6 +55,7 @@ export function AudioFxGroup({
|
|
|
55
55
|
element,
|
|
56
56
|
onSetAttributeQuiet: onSetAttributeQuietRaw,
|
|
57
57
|
onSetAttributeLive,
|
|
58
|
+
onAutoGroupCarveSources,
|
|
58
59
|
}: {
|
|
59
60
|
element: DomEditSelection;
|
|
60
61
|
/**
|
|
@@ -70,6 +71,8 @@ export function AudioFxGroup({
|
|
|
70
71
|
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>;
|
|
71
72
|
/** Continuous, non-persisting write for a dial being dragged. */
|
|
72
73
|
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
|
|
74
|
+
/** Write `data-audio-group` on every named clip, atomically, one undo entry. */
|
|
75
|
+
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
73
76
|
}) {
|
|
74
77
|
const chain = ((): HfAudioFxChain => {
|
|
75
78
|
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
|
|
@@ -231,6 +234,7 @@ export function AudioFxGroup({
|
|
|
231
234
|
onSetAttributeQuiet,
|
|
232
235
|
writeAutomation,
|
|
233
236
|
setAnalysing,
|
|
237
|
+
onAutoGroupCarveSources,
|
|
234
238
|
);
|
|
235
239
|
|
|
236
240
|
const { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller } =
|
|
@@ -268,6 +268,10 @@ export function ColorField({
|
|
|
268
268
|
|
|
269
269
|
useEffect(() => {
|
|
270
270
|
if (!open) return;
|
|
271
|
+
// Move focus into the picker on open and restore it on close so Escape
|
|
272
|
+
// and keyboard editing work without a pointer round-trip.
|
|
273
|
+
panelRef.current?.focus();
|
|
274
|
+
const restoreTarget = buttonRef.current;
|
|
271
275
|
const handlePointerDown = (event: PointerEvent) => {
|
|
272
276
|
const target = event.target as Node | null;
|
|
273
277
|
if (!target) return;
|
|
@@ -279,6 +283,7 @@ export function ColorField({
|
|
|
279
283
|
if (event.key === "Escape") {
|
|
280
284
|
cancelColorGesture();
|
|
281
285
|
setOpen(false);
|
|
286
|
+
restoreTarget?.focus();
|
|
282
287
|
}
|
|
283
288
|
};
|
|
284
289
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
@@ -320,7 +325,10 @@ export function ColorField({
|
|
|
320
325
|
? createPortal(
|
|
321
326
|
<div
|
|
322
327
|
ref={panelRef}
|
|
323
|
-
|
|
328
|
+
role="dialog"
|
|
329
|
+
aria-label={`${label} color picker`}
|
|
330
|
+
tabIndex={-1}
|
|
331
|
+
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50 outline-none"
|
|
324
332
|
style={{
|
|
325
333
|
left: panelPosition?.left ?? -9999,
|
|
326
334
|
top: panelPosition?.top ?? -9999,
|
|
@@ -263,6 +263,8 @@ export function ColorGradingControls({
|
|
|
263
263
|
const lutInputRef = useRef<HTMLInputElement>(null);
|
|
264
264
|
const [lutOpen, setLutOpen] = useState(false);
|
|
265
265
|
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
|
|
266
|
+
const [lutImporting, setLutImporting] = useState(false);
|
|
267
|
+
const [lutImportError, setLutImportError] = useState<string | null>(null);
|
|
266
268
|
const lutAssets = useMemo(
|
|
267
269
|
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
|
268
270
|
[assets],
|
|
@@ -282,6 +284,9 @@ export function ColorGradingControls({
|
|
|
282
284
|
const actions = createColorGradingActions(grading, onCommitColorGrading);
|
|
283
285
|
|
|
284
286
|
const applyPreset = (preset: string) => {
|
|
287
|
+
// Pass the LUT through normalize; the preset's own adjust values are its
|
|
288
|
+
// look (main's normalize already avoids carrying over grading.adjust,
|
|
289
|
+
// which was the bug this PR originally fixed).
|
|
285
290
|
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
|
286
291
|
if (next) {
|
|
287
292
|
track("select", "Preset");
|
|
@@ -391,16 +396,21 @@ export function ColorGradingControls({
|
|
|
391
396
|
</select>
|
|
392
397
|
<button
|
|
393
398
|
type="button"
|
|
394
|
-
disabled={!onImportAssets}
|
|
399
|
+
disabled={!onImportAssets || lutImporting}
|
|
395
400
|
onClick={(event) => {
|
|
396
401
|
event.stopPropagation();
|
|
397
402
|
lutInputRef.current?.click();
|
|
398
403
|
}}
|
|
399
|
-
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
|
400
|
-
title="Import .cube LUT"
|
|
404
|
+
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
|
|
405
|
+
title={lutImporting ? "Importing…" : "Import .cube LUT"}
|
|
401
406
|
aria-label="Import .cube LUT"
|
|
407
|
+
aria-busy={lutImporting}
|
|
402
408
|
>
|
|
403
|
-
|
|
409
|
+
{lutImporting ? (
|
|
410
|
+
<span className="h-3 w-3 animate-spin rounded-full border border-panel-text-4 border-t-transparent motion-reduce:animate-none" />
|
|
411
|
+
) : (
|
|
412
|
+
<Plus size={13} />
|
|
413
|
+
)}
|
|
404
414
|
</button>
|
|
405
415
|
<input
|
|
406
416
|
ref={lutInputRef}
|
|
@@ -409,13 +419,24 @@ export function ColorGradingControls({
|
|
|
409
419
|
multiple
|
|
410
420
|
className="hidden"
|
|
411
421
|
onChange={(event) => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
);
|
|
422
|
+
const files = event.currentTarget.files;
|
|
423
|
+
setLutImporting(true);
|
|
424
|
+
setLutImportError(null);
|
|
425
|
+
void actions
|
|
426
|
+
.importLut(files, onImportAssets, () => track("button", "Import LUT"))
|
|
427
|
+
.catch(() =>
|
|
428
|
+
setLutImportError("LUT import failed — check the .cube file and try again."),
|
|
429
|
+
)
|
|
430
|
+
.finally(() => setLutImporting(false));
|
|
415
431
|
event.currentTarget.value = "";
|
|
416
432
|
}}
|
|
417
433
|
/>
|
|
418
434
|
</div>
|
|
435
|
+
{lutImportError && (
|
|
436
|
+
<div className="text-[10px] text-red-400" role="alert">
|
|
437
|
+
{lutImportError}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
419
440
|
{grading.lut && (
|
|
420
441
|
<div className="grid gap-2">
|
|
421
442
|
{selectedProjectLut && (
|
|
@@ -134,7 +134,15 @@ export function CommitField({
|
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
if (event.key === "Escape") {
|
|
137
|
-
cancelGestureFromKeyEvent(event);
|
|
137
|
+
if (cancelGestureFromKeyEvent(event)) return;
|
|
138
|
+
// No gesture to cancel means the draft was typed. Escape abandons it
|
|
139
|
+
// rather than leaving it for the blur-commit to write.
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
dirtyRef.current = false;
|
|
143
|
+
setDraft(valueRef.current);
|
|
144
|
+
if (liveCommit) onPreview?.(valueRef.current);
|
|
145
|
+
event.currentTarget.blur();
|
|
138
146
|
return;
|
|
139
147
|
}
|
|
140
148
|
if (event.key === "Enter") {
|
|
@@ -91,6 +91,7 @@ export function ImageFillField({
|
|
|
91
91
|
const track = useTrackDesignInput();
|
|
92
92
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
93
93
|
const [uploading, setUploading] = useState(false);
|
|
94
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
94
95
|
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
|
95
96
|
const selectedAsset = useMemo(
|
|
96
97
|
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
|
@@ -101,6 +102,7 @@ export function ImageFillField({
|
|
|
101
102
|
const handleUpload = async (files: FileList | null) => {
|
|
102
103
|
if (!files?.length || !onImportAssets) return;
|
|
103
104
|
setUploading(true);
|
|
105
|
+
setUploadError(null);
|
|
104
106
|
try {
|
|
105
107
|
const uploaded = await onImportAssets(files);
|
|
106
108
|
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
|
@@ -108,6 +110,8 @@ export function ImageFillField({
|
|
|
108
110
|
track("button", "Upload image");
|
|
109
111
|
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
|
110
112
|
}
|
|
113
|
+
} catch {
|
|
114
|
+
setUploadError("Upload failed — check the file and try again.");
|
|
111
115
|
} finally {
|
|
112
116
|
setUploading(false);
|
|
113
117
|
}
|
|
@@ -144,6 +148,11 @@ export function ImageFillField({
|
|
|
144
148
|
}}
|
|
145
149
|
/>
|
|
146
150
|
</div>
|
|
151
|
+
{uploadError && (
|
|
152
|
+
<div className="text-[10px] text-red-400" role="alert">
|
|
153
|
+
{uploadError}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
147
156
|
{imageAssets.length > 0 ? (
|
|
148
157
|
<div className="space-y-3">
|
|
149
158
|
{selectedAsset && (
|
|
@@ -262,11 +271,49 @@ export function GradientField({
|
|
|
262
271
|
{parsed.stops.map((stop, index) => (
|
|
263
272
|
<div
|
|
264
273
|
key={`stop-preview-${index}`}
|
|
265
|
-
|
|
274
|
+
role="slider"
|
|
275
|
+
tabIndex={disabled ? -1 : 0}
|
|
276
|
+
aria-label={`Stop ${index + 1} position`}
|
|
277
|
+
aria-valuenow={Math.round(stop.position)}
|
|
278
|
+
aria-valuemin={0}
|
|
279
|
+
aria-valuemax={100}
|
|
280
|
+
onKeyDown={(event) => {
|
|
281
|
+
if (disabled) return;
|
|
282
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
const step = event.shiftKey ? 10 : 1;
|
|
285
|
+
const delta = event.key === "ArrowRight" ? step : -step;
|
|
286
|
+
updateStop(index, {
|
|
287
|
+
position: Math.max(0, Math.min(100, Math.round(stop.position + delta))),
|
|
288
|
+
});
|
|
289
|
+
}}
|
|
290
|
+
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-studio-accent"
|
|
266
291
|
style={{
|
|
267
292
|
left: `calc(${stop.position}% - 8px)`,
|
|
268
293
|
backgroundColor: stop.color,
|
|
269
294
|
}}
|
|
295
|
+
onClick={(event) => event.stopPropagation()}
|
|
296
|
+
onPointerDown={(event) => {
|
|
297
|
+
if (disabled) return;
|
|
298
|
+
event.stopPropagation();
|
|
299
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
300
|
+
}}
|
|
301
|
+
onPointerMove={(event) => {
|
|
302
|
+
if (disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
|
303
|
+
const rect = previewRef.current?.getBoundingClientRect();
|
|
304
|
+
if (!rect || rect.width <= 0) return;
|
|
305
|
+
const next = Math.max(
|
|
306
|
+
0,
|
|
307
|
+
Math.min(100, ((event.clientX - rect.left) / rect.width) * 100),
|
|
308
|
+
);
|
|
309
|
+
updateStop(index, { position: Math.round(next * 10) / 10 });
|
|
310
|
+
}}
|
|
311
|
+
onPointerUp={(event) => {
|
|
312
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
313
|
+
}}
|
|
314
|
+
onPointerCancel={(event) => {
|
|
315
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
316
|
+
}}
|
|
270
317
|
/>
|
|
271
318
|
))}
|
|
272
319
|
</div>
|
|
@@ -392,7 +439,8 @@ export function GradientField({
|
|
|
392
439
|
type="button"
|
|
393
440
|
disabled={disabled || parsed.stops.length >= 6}
|
|
394
441
|
onClick={() => addStop()}
|
|
395
|
-
|
|
442
|
+
title={parsed.stops.length >= 6 ? "Maximum 6 stops" : "Add a gradient stop"}
|
|
443
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
396
444
|
>
|
|
397
445
|
<Plus size={12} />
|
|
398
446
|
Add stop
|
|
@@ -143,6 +143,7 @@ export function FontFamilyField({
|
|
|
143
143
|
const fontInputRef = useRef<HTMLInputElement | null>(null);
|
|
144
144
|
const [open, setOpen] = useState(false);
|
|
145
145
|
const [query, setQuery] = useState("");
|
|
146
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
146
147
|
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
|
147
148
|
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
|
148
149
|
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
|
@@ -259,6 +260,8 @@ export function FontFamilyField({
|
|
|
259
260
|
} else {
|
|
260
261
|
setFontNotice("No supported font files were imported.");
|
|
261
262
|
}
|
|
263
|
+
} catch {
|
|
264
|
+
setFontNotice("Font import failed — the files were not added. Try again.");
|
|
262
265
|
} finally {
|
|
263
266
|
setImportingFonts(false);
|
|
264
267
|
}
|
|
@@ -358,11 +361,17 @@ export function FontFamilyField({
|
|
|
358
361
|
commitFontFamily(buildFontFamilyValue(imported.family));
|
|
359
362
|
setQuery("");
|
|
360
363
|
setOpen(false);
|
|
361
|
-
|
|
364
|
+
} else {
|
|
365
|
+
// Committing an un-imported family would render a silent fallback,
|
|
366
|
+
// so surface the failure and keep the current font instead.
|
|
367
|
+
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
|
|
362
368
|
}
|
|
369
|
+
} catch {
|
|
370
|
+
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
|
|
363
371
|
} finally {
|
|
364
372
|
setImportingFonts(false);
|
|
365
373
|
}
|
|
374
|
+
return;
|
|
366
375
|
}
|
|
367
376
|
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
|
|
368
377
|
const imported = importedFonts.find(
|
|
@@ -383,17 +392,39 @@ export function FontFamilyField({
|
|
|
383
392
|
value={query}
|
|
384
393
|
disabled={disabled}
|
|
385
394
|
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
|
386
|
-
onChange={(e) =>
|
|
395
|
+
onChange={(e) => {
|
|
396
|
+
setQuery(e.target.value);
|
|
397
|
+
setActiveIndex(-1);
|
|
398
|
+
}}
|
|
387
399
|
onKeyDown={(e) => {
|
|
388
400
|
if (e.key === "Escape") {
|
|
389
401
|
e.preventDefault();
|
|
390
402
|
setOpen(false);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
if (filteredOptions.length === 0) return;
|
|
408
|
+
const delta = e.key === "ArrowDown" ? 1 : -1;
|
|
409
|
+
const next =
|
|
410
|
+
activeIndex < 0 && delta === 1
|
|
411
|
+
? 0
|
|
412
|
+
: (activeIndex + delta + filteredOptions.length) % filteredOptions.length;
|
|
413
|
+
setActiveIndex(next);
|
|
414
|
+
document
|
|
415
|
+
.querySelector(`[data-font-option-index="${next}"]`)
|
|
416
|
+
?.scrollIntoView({ block: "nearest" });
|
|
417
|
+
return;
|
|
391
418
|
}
|
|
392
|
-
|
|
419
|
+
const target = filteredOptions[activeIndex] ?? filteredOptions[0];
|
|
420
|
+
if (e.key === "Enter" && target) {
|
|
393
421
|
e.preventDefault();
|
|
394
|
-
commitFamily(
|
|
422
|
+
commitFamily(target);
|
|
395
423
|
}
|
|
396
424
|
}}
|
|
425
|
+
role="combobox"
|
|
426
|
+
aria-expanded={open}
|
|
427
|
+
aria-autocomplete="list"
|
|
397
428
|
className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
|
|
398
429
|
/>
|
|
399
430
|
{canQueryLocalFonts && (
|
|
@@ -437,19 +468,24 @@ export function FontFamilyField({
|
|
|
437
468
|
{filteredOptions.length === 0 ? (
|
|
438
469
|
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
|
|
439
470
|
) : (
|
|
440
|
-
filteredOptions.map((option) => (
|
|
471
|
+
filteredOptions.map((option, index) => (
|
|
441
472
|
<button
|
|
442
473
|
key={`${option.source}-${option.family}`}
|
|
443
474
|
type="button"
|
|
475
|
+
data-font-option-index={index}
|
|
444
476
|
onClick={() => commitFamily(option)}
|
|
445
477
|
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
|
|
446
|
-
|
|
447
|
-
? "bg-
|
|
448
|
-
:
|
|
478
|
+
index === activeIndex
|
|
479
|
+
? "bg-neutral-800 text-neutral-50"
|
|
480
|
+
: option.family === currentFamily
|
|
481
|
+
? "bg-studio-accent/15 text-neutral-50"
|
|
482
|
+
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
|
449
483
|
}`}
|
|
450
484
|
>
|
|
451
485
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
452
|
-
<span className="truncate font-medium"
|
|
486
|
+
<span className="truncate font-medium" style={{ fontFamily: `"${option.family}"` }}>
|
|
487
|
+
{option.family}
|
|
488
|
+
</span>
|
|
453
489
|
{renderAliasFor(option.family) && (
|
|
454
490
|
<span className="flex-shrink-0 text-[9px] text-neutral-500">
|
|
455
491
|
→ {renderAliasFor(option.family)}
|
|
@@ -150,6 +150,24 @@ export const FX_PRESET_STYLE: Record<string, FxPresetStyle> = {
|
|
|
150
150
|
color: "hsl(288, 85%, 72%)",
|
|
151
151
|
family: FACE.theatrical,
|
|
152
152
|
},
|
|
153
|
+
// Small, fast and squeaky — tight and bright, nothing about it sits still.
|
|
154
|
+
chipmunk: {
|
|
155
|
+
type: "text-[12px] font-black uppercase tracking-tighter",
|
|
156
|
+
color: "hsl(54, 88%, 66%)",
|
|
157
|
+
family: FACE.geometric,
|
|
158
|
+
},
|
|
159
|
+
// Huge and slow — the biggest size here, set on a face bolted to a monument.
|
|
160
|
+
giant: {
|
|
161
|
+
type: "text-[18px] font-black tracking-[0.02em]",
|
|
162
|
+
color: "hsl(250, 72%, 68%)",
|
|
163
|
+
family: FACE.engraved,
|
|
164
|
+
},
|
|
165
|
+
// Rough and too close — heavy industrial caps, growled rather than shouted.
|
|
166
|
+
monster: {
|
|
167
|
+
type: "text-[15px] font-black uppercase tracking-[0.05em]",
|
|
168
|
+
color: "hsl(350, 78%, 64%)",
|
|
169
|
+
family: FACE.condensed,
|
|
170
|
+
},
|
|
153
171
|
|
|
154
172
|
// --- space: rooms. Light and wide, because that is what space looks like ---
|
|
155
173
|
"room-tight": {
|
|
@@ -875,7 +875,7 @@ describe("FxSection chain", () => {
|
|
|
875
875
|
|
|
876
876
|
click(host.querySelector(".hf-fx-add"));
|
|
877
877
|
expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
|
|
878
|
-
expect(byText(host, "button", "
|
|
878
|
+
expect(byText(host, "button", "+ effect")).toBeTruthy();
|
|
879
879
|
});
|
|
880
880
|
|
|
881
881
|
it("opens one menu in place of the other", () => {
|
|
@@ -1046,7 +1046,7 @@ describe("FxSection chain", () => {
|
|
|
1046
1046
|
|
|
1047
1047
|
it("auditions an effect the add menu is offering", () => {
|
|
1048
1048
|
const { host, onChainPreview, onChainChange } = mount({ chain: chainOf("peaking") });
|
|
1049
|
-
click(byText(host, "button", "
|
|
1049
|
+
click(byText(host, "button", "+ effect"));
|
|
1050
1050
|
enter(byText(host, "button", EFFECT_COPY.reverb?.title ?? ""));
|
|
1051
1051
|
|
|
1052
1052
|
const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
|
@@ -1063,7 +1063,7 @@ describe("FxSection chain", () => {
|
|
|
1063
1063
|
onLevel: vi.fn(),
|
|
1064
1064
|
onAuditionLevel,
|
|
1065
1065
|
});
|
|
1066
|
-
click(byText(host, "button", "
|
|
1066
|
+
click(byText(host, "button", "+ effect"));
|
|
1067
1067
|
enter(byText(host, "button", "Even Out Levels"));
|
|
1068
1068
|
expect(onAuditionLevel).toHaveBeenLastCalledWith(true);
|
|
1069
1069
|
leave(host, ".hf-fx-add-menu");
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type HfAudioFxParamValues,
|
|
15
15
|
} from "@hyperframes/core/audio-fx";
|
|
16
16
|
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
17
|
+
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
|
|
17
18
|
import {
|
|
18
19
|
addAudioEq,
|
|
19
20
|
audioEqIds,
|
|
@@ -31,7 +32,6 @@ import {
|
|
|
31
32
|
trackNodeAdded,
|
|
32
33
|
trackNodeMoved,
|
|
33
34
|
trackNodeRemoved,
|
|
34
|
-
trackPresetApplied,
|
|
35
35
|
trackPresetAuditioned,
|
|
36
36
|
trackPresetAutomated,
|
|
37
37
|
trackPresetRemoved,
|
|
@@ -138,23 +138,8 @@ export function FxSection({
|
|
|
138
138
|
|
|
139
139
|
const applyPreset = useCallback(
|
|
140
140
|
(id: string) => {
|
|
141
|
-
const
|
|
142
|
-
if (!
|
|
143
|
-
// Appends. Stacking a character preset onto an already-cleaned voice is a
|
|
144
|
-
// real thing to want, and replacing silently would throw work away — so
|
|
145
|
-
// the destructive option is a separate gesture, not the default one.
|
|
146
|
-
const next = applyAudioFxPreset(chain, preset);
|
|
147
|
-
// Re-applying replaces this preset's own nodes in place rather than
|
|
148
|
-
// appending a second copy, and the two are different decisions — worth
|
|
149
|
-
// telling apart in the numbers.
|
|
150
|
-
const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
|
|
151
|
-
trackPresetApplied(
|
|
152
|
-
preset.id,
|
|
153
|
-
preset.family,
|
|
154
|
-
preset.nodes.length,
|
|
155
|
-
reapply ? "reapply" : "append",
|
|
156
|
-
{ trackKind },
|
|
157
|
-
);
|
|
141
|
+
const next = applyPresetToChain(chain, id, trackKind);
|
|
142
|
+
if (!next) return;
|
|
158
143
|
// The audition WAS this, so there is nothing to put back — and putting the
|
|
159
144
|
// old chain back over the write that just landed is a race the author
|
|
160
145
|
// hears as the preset arriving and then leaving again.
|
|
@@ -162,7 +147,7 @@ export function FxSection({
|
|
|
162
147
|
mutate(next.nodes);
|
|
163
148
|
// Land on the first node the preset wrote, so the author can hear what
|
|
164
149
|
// arrived and immediately see what it is made of.
|
|
165
|
-
setOpenNode(next.nodes.findIndex((n) => n.fromPreset ===
|
|
150
|
+
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
|
|
166
151
|
setPicking(false);
|
|
167
152
|
},
|
|
168
153
|
[chain, mutate, clearAudition, trackKind],
|
|
@@ -511,10 +496,10 @@ export function FxSection({
|
|
|
511
496
|
opened one and changed their mind had no way back: picking something
|
|
512
497
|
was the only thing that set these false, so the only exits were adding
|
|
513
498
|
an effect they did not want or deselecting the clip. */}
|
|
514
|
-
<div className="flex gap-1">
|
|
499
|
+
<div className="flex flex-col gap-1">
|
|
515
500
|
<button
|
|
516
501
|
type="button"
|
|
517
|
-
className="hf-fx-preset w-full rounded-[4px] border border-
|
|
502
|
+
className="hf-fx-preset w-full rounded-[4px] border border-panel-text-0 py-1.5 text-[11px] font-semibold text-panel-text-0 disabled:opacity-40"
|
|
518
503
|
aria-expanded={picking}
|
|
519
504
|
disabled={disabled}
|
|
520
505
|
onClick={() => {
|
|
@@ -530,7 +515,7 @@ export function FxSection({
|
|
|
530
515
|
</button>
|
|
531
516
|
<button
|
|
532
517
|
type="button"
|
|
533
|
-
className="hf-fx-add
|
|
518
|
+
className="hf-fx-add self-end px-1 text-[10px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
|
|
534
519
|
aria-expanded={adding}
|
|
535
520
|
disabled={disabled}
|
|
536
521
|
onClick={() => {
|
|
@@ -542,7 +527,7 @@ export function FxSection({
|
|
|
542
527
|
setPicking(false);
|
|
543
528
|
}}
|
|
544
529
|
>
|
|
545
|
-
{adding ? "Close" : "
|
|
530
|
+
{adding ? "Close" : "+ effect"}
|
|
546
531
|
</button>
|
|
547
532
|
</div>
|
|
548
533
|
</div>
|