@hyperframes/studio 0.8.5 → 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.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10337 -7200
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
  142. package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
  143. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  144. package/src/player/lib/timelineDOM.ts +51 -0
  145. package/src/player/store/audioSoloSlice.test.ts +113 -0
  146. package/src/player/store/audioSoloSlice.ts +43 -0
  147. package/src/player/store/editingModeSlice.ts +45 -0
  148. package/src/player/store/groupLevels.ts +33 -0
  149. package/src/player/store/keyframeSlice.ts +26 -0
  150. package/src/player/store/playerStore.ts +21 -22
  151. package/src/player/store/timelineElement.ts +10 -0
  152. package/src/telemetry/events.ts +12 -0
  153. package/dist/assets/index-Cx_UrhoW.js +0 -428
  154. 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, "Add effect")?.click());
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, "Add effect")?.click());
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, "Add effect")?.click());
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
- className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
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
- <Plus size={13} />
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
- void actions.importLut(event.currentTarget.files, onImportAssets, () =>
413
- track("button", "Import LUT"),
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
- className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
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
- 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 disabled:cursor-not-allowed disabled:text-neutral-600"
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
@@ -27,6 +27,7 @@ export type PropertyPanelFlatProps = Pick<
27
27
  | "onRemoveTextField"
28
28
  | "onAskAgent"
29
29
  | "onToggleElementHidden"
30
+ | "onAutoGroupCarveSources"
30
31
  | "onImportAssets"
31
32
  | "onAddMediaOverlay"
32
33
  | "onImportFonts"
@@ -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
- return;
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) => setQuery(e.target.value)}
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
- if (e.key === "Enter" && filteredOptions[0]) {
419
+ const target = filteredOptions[activeIndex] ?? filteredOptions[0];
420
+ if (e.key === "Enter" && target) {
393
421
  e.preventDefault();
394
- commitFamily(filteredOptions[0]);
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
- option.family === currentFamily
447
- ? "bg-studio-accent/15 text-neutral-50"
448
- : "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
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">{option.family}</span>
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", "Add effect")).toBeTruthy();
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", "Add effect"));
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", "Add effect"));
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 preset = getAudioFxPreset(id);
142
- if (!preset) return;
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 === preset.id));
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-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
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 w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
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" : "Add effect"}
530
+ {adding ? "Close" : "+ effect"}
546
531
  </button>
547
532
  </div>
548
533
  </div>