@signalsandsorcery/plugin-sdk 2.25.1 → 2.28.1

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/index.mjs CHANGED
@@ -2465,6 +2465,8 @@ function parseCrossfadePairs(sceneData) {
2465
2465
  sliderPos: g.origin.meta.sliderPos,
2466
2466
  originDbId: g.origin.dbId,
2467
2467
  targetDbId: g.target.dbId,
2468
+ originSourceDbId: g.origin.meta.sourceTrackDbId,
2469
+ targetSourceDbId: g.target.meta.sourceTrackDbId,
2468
2470
  originSourceName: g.origin.meta.sourceName,
2469
2471
  originSoundLabel: g.origin.meta.soundLabel,
2470
2472
  targetSourceName: g.target.meta.sourceName,
@@ -2568,9 +2570,448 @@ function buildCrossfadeInpaintPrompt(input) {
2568
2570
  return lines.join("\n");
2569
2571
  }
2570
2572
 
2573
+ // src/fade-meta.ts
2574
+ function asFadeMeta(val) {
2575
+ if (!val || typeof val !== "object") return null;
2576
+ const m = val;
2577
+ if (m.direction !== "in" && m.direction !== "out") return null;
2578
+ if (m.gesture !== "volume" && m.gesture !== "build") return null;
2579
+ return {
2580
+ direction: m.direction,
2581
+ gesture: m.gesture,
2582
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2583
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2584
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2585
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2586
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2587
+ };
2588
+ }
2589
+ function parseFades(sceneData) {
2590
+ const out = [];
2591
+ for (const [key, val] of Object.entries(sceneData)) {
2592
+ const match = /^track:(.+):fade$/.exec(key);
2593
+ if (!match) continue;
2594
+ const meta = asFadeMeta(val);
2595
+ if (!meta) continue;
2596
+ out.push({ dbId: match[1], meta });
2597
+ }
2598
+ return out;
2599
+ }
2600
+ function buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture, steps = 32) {
2601
+ const durationSeconds = bars * 4 * 60 / Math.max(1, bpm);
2602
+ if (gesture === "build") {
2603
+ return [
2604
+ { time: 0, db: 0 },
2605
+ { time: Math.round(durationSeconds * 1e3) / 1e3, db: 0 }
2606
+ ];
2607
+ }
2608
+ const s = Math.min(0.98, Math.max(0.02, sliderPos));
2609
+ const round = (n) => Math.round(n * 1e3) / 1e3;
2610
+ const points = [];
2611
+ for (let i = 0; i <= steps; i++) {
2612
+ const x = i / steps;
2613
+ const time = round(x * durationSeconds);
2614
+ const theta = x <= s ? x / s * (Math.PI / 4) : Math.PI / 4 + (x - s) / (1 - s) * (Math.PI / 4);
2615
+ const gain = direction === "out" ? Math.cos(theta) : Math.sin(theta);
2616
+ points.push({ time, db: Math.round(gainToDb(gain) * 100) / 100 });
2617
+ }
2618
+ return points;
2619
+ }
2620
+ var TEXTURAL_ROLES = /* @__PURE__ */ new Set([
2621
+ "pads",
2622
+ "pad",
2623
+ "strings",
2624
+ "atmospheres",
2625
+ "atmosphere",
2626
+ "atmos",
2627
+ "drones",
2628
+ "drone",
2629
+ "soundscapes",
2630
+ "soundscape"
2631
+ ]);
2632
+ function defaultFadeGesture(role) {
2633
+ if (!role) return "build";
2634
+ const norm = role.toLowerCase().replace(/[\s_-]+/g, " ").trim();
2635
+ if (TEXTURAL_ROLES.has(norm)) return "volume";
2636
+ for (const token of norm.split(" ")) {
2637
+ if (TEXTURAL_ROLES.has(token)) return "volume";
2638
+ }
2639
+ return "build";
2640
+ }
2641
+
2642
+ // src/components/FadeTrackRow.tsx
2643
+ import React10 from "react";
2644
+ import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
2645
+ function FadeCaption({
2646
+ layer,
2647
+ direction,
2648
+ gesture
2649
+ }) {
2650
+ const tag = direction === "in" ? "Fade in" : "Fade out";
2651
+ return /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2652
+ /* @__PURE__ */ jsx13("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2653
+ /* @__PURE__ */ jsx13("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2654
+ layer.soundLabel && /* @__PURE__ */ jsxs9("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2655
+ "\xB7 ",
2656
+ layer.soundLabel
2657
+ ] }),
2658
+ /* @__PURE__ */ jsxs9("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", title: `Fade gesture: ${gesture}`, children: [
2659
+ "\xB7 ",
2660
+ gesture
2661
+ ] })
2662
+ ] });
2663
+ }
2664
+ function FadeTrackRow({
2665
+ layer,
2666
+ direction,
2667
+ gesture,
2668
+ sliderPos = 0.5,
2669
+ onMuteToggle,
2670
+ onSoloToggle,
2671
+ onVolumeChange,
2672
+ onPanChange,
2673
+ onDelete,
2674
+ onSliderChange,
2675
+ levels,
2676
+ accentColor = "#9333EA"
2677
+ }) {
2678
+ const [confirmDelete, setConfirmDelete] = React10.useState(false);
2679
+ const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2680
+ const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2681
+ const badge = direction === "in" ? "\u2197 Fade in" : "\u2198 Fade out";
2682
+ return /* @__PURE__ */ jsxs9(
2683
+ "div",
2684
+ {
2685
+ "data-testid": "fade-track-row",
2686
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2687
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2688
+ children: [
2689
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2690
+ /* @__PURE__ */ jsx13(
2691
+ "span",
2692
+ {
2693
+ "data-testid": "fade-direction-badge",
2694
+ className: "text-[10px] font-bold uppercase tracking-wide",
2695
+ style: { color: accentColor },
2696
+ children: badge
2697
+ }
2698
+ ),
2699
+ /* @__PURE__ */ jsx13(
2700
+ "button",
2701
+ {
2702
+ "data-testid": "fade-delete-button",
2703
+ onClick: () => setConfirmDelete(true),
2704
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2705
+ title: "Delete fade",
2706
+ "aria-label": "Delete fade",
2707
+ children: "x"
2708
+ }
2709
+ )
2710
+ ] }),
2711
+ /* @__PURE__ */ jsx13(
2712
+ TrackRow,
2713
+ {
2714
+ track: { id: layer.trackId, name: "", role: layer.role },
2715
+ runtimeState: layer.runtimeState,
2716
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2717
+ drawerOpen: false,
2718
+ drawerTab: "fx",
2719
+ levels,
2720
+ accentColor,
2721
+ contentSlot: /* @__PURE__ */ jsx13(FadeCaption, { layer, direction, gesture }),
2722
+ onMuteToggle,
2723
+ onSoloToggle,
2724
+ onVolumeChange,
2725
+ onPanChange
2726
+ }
2727
+ ),
2728
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "fade-slider-row", children: [
2729
+ /* @__PURE__ */ jsx13(
2730
+ "span",
2731
+ {
2732
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2733
+ title: leftLabel,
2734
+ children: leftLabel
2735
+ }
2736
+ ),
2737
+ /* @__PURE__ */ jsx13(
2738
+ "input",
2739
+ {
2740
+ type: "range",
2741
+ "data-testid": "fade-slider",
2742
+ min: 0,
2743
+ max: 1,
2744
+ step: 0.01,
2745
+ value: sliderPos,
2746
+ disabled: !onSliderChange,
2747
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2748
+ style: { accentColor },
2749
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2750
+ "aria-label": "Fade position"
2751
+ }
2752
+ ),
2753
+ /* @__PURE__ */ jsx13(
2754
+ "span",
2755
+ {
2756
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2757
+ title: rightLabel,
2758
+ children: rightLabel
2759
+ }
2760
+ )
2761
+ ] }),
2762
+ /* @__PURE__ */ jsx13(
2763
+ ConfirmDialog,
2764
+ {
2765
+ open: confirmDelete,
2766
+ title: "Delete fade?",
2767
+ message: /* @__PURE__ */ jsx13(Fragment3, { children: "This fade track will be permanently removed from this scene. This cannot be undone." }),
2768
+ confirmLabel: "Delete",
2769
+ onConfirm: () => {
2770
+ setConfirmDelete(false);
2771
+ onDelete();
2772
+ },
2773
+ onCancel: () => setConfirmDelete(false),
2774
+ testIdPrefix: "fade-delete-confirm"
2775
+ }
2776
+ )
2777
+ ]
2778
+ }
2779
+ );
2780
+ }
2781
+
2782
+ // src/components/FadeModal.tsx
2783
+ import { useCallback as useCallback4, useEffect as useEffect6, useMemo as useMemo3, useRef as useRef7, useState as useState7 } from "react";
2784
+ import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
2785
+ function shortId(dbId) {
2786
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
2787
+ }
2788
+ var normRole = (r) => (r ?? "").toLowerCase().trim();
2789
+ function computeOrphans(from, to, excludeSet) {
2790
+ const bucket = (list) => {
2791
+ const m = /* @__PURE__ */ new Map();
2792
+ for (const t of list) {
2793
+ const k = normRole(t.role);
2794
+ const arr = m.get(k);
2795
+ if (arr) arr.push(t);
2796
+ else m.set(k, [t]);
2797
+ }
2798
+ return m;
2799
+ };
2800
+ const fromByRole = bucket(from);
2801
+ const toByRole = bucket(to);
2802
+ const roles = /* @__PURE__ */ new Set([...fromByRole.keys(), ...toByRole.keys()]);
2803
+ const fadeOut = [];
2804
+ const fadeIn = [];
2805
+ for (const role of roles) {
2806
+ const f = fromByRole.get(role) ?? [];
2807
+ const t = toByRole.get(role) ?? [];
2808
+ const shared = Math.min(f.length, t.length);
2809
+ fadeOut.push(...f.slice(shared));
2810
+ fadeIn.push(...t.slice(shared));
2811
+ }
2812
+ return {
2813
+ fadeOut: fadeOut.filter((x) => !excludeSet.has(x.dbId)),
2814
+ fadeIn: fadeIn.filter((x) => !excludeSet.has(x.dbId))
2815
+ };
2816
+ }
2817
+ function OrphanRow({
2818
+ track,
2819
+ gesture,
2820
+ selected,
2821
+ disabled,
2822
+ onSelect,
2823
+ testId
2824
+ }) {
2825
+ const primary = track.prompt?.trim() || track.name;
2826
+ const meta = [track.role, shortId(track.dbId), gesture].filter(Boolean).join(" \xB7 ");
2827
+ return /* @__PURE__ */ jsxs10(
2828
+ "button",
2829
+ {
2830
+ type: "button",
2831
+ role: "radio",
2832
+ "aria-checked": selected,
2833
+ "data-testid": testId,
2834
+ "data-value": track.dbId,
2835
+ onClick: onSelect,
2836
+ disabled,
2837
+ className: `w-full text-left px-2 py-1.5 rounded-sm border transition-colors disabled:opacity-50 ${selected ? "bg-sas-accent/15 border-sas-accent" : "bg-sas-panel border-sas-border hover:border-sas-accent/50"}`,
2838
+ children: [
2839
+ /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
2840
+ meta && /* @__PURE__ */ jsx14("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
2841
+ ]
2842
+ }
2843
+ );
2844
+ }
2845
+ function FadeModal({
2846
+ host,
2847
+ open,
2848
+ fromSceneId,
2849
+ toSceneId,
2850
+ fromSceneName,
2851
+ toSceneName,
2852
+ excludeSourceDbIds,
2853
+ onClose,
2854
+ onCreate,
2855
+ testIdPrefix = "fade-modal"
2856
+ }) {
2857
+ const [load, setLoad] = useState7({ status: "loading" });
2858
+ const [selectedDbId, setSelectedDbId] = useState7("");
2859
+ const [isCreating, setIsCreating] = useState7(false);
2860
+ const [error, setError] = useState7(null);
2861
+ const [fromName, setFromName] = useState7(null);
2862
+ const [toName, setToName] = useState7(null);
2863
+ const cancelRef = useRef7(null);
2864
+ const refresh = useCallback4(async () => {
2865
+ if (!host.listSceneFamilyTracks) {
2866
+ setLoad({ status: "error", message: "This host does not support fades." });
2867
+ return;
2868
+ }
2869
+ setLoad({ status: "loading" });
2870
+ try {
2871
+ const [from, to, fName, tName] = await Promise.all([
2872
+ host.listSceneFamilyTracks(fromSceneId),
2873
+ host.listSceneFamilyTracks(toSceneId),
2874
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
2875
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2876
+ ]);
2877
+ setFromName(fName);
2878
+ setToName(tName);
2879
+ setLoad({ status: "ready", from, to });
2880
+ } catch (err) {
2881
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2882
+ }
2883
+ }, [host, fromSceneId, toSceneId]);
2884
+ useEffect6(() => {
2885
+ if (open) {
2886
+ setError(null);
2887
+ setIsCreating(false);
2888
+ setSelectedDbId("");
2889
+ void refresh();
2890
+ }
2891
+ }, [open, refresh]);
2892
+ const excludeSet = useMemo3(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2893
+ const { fadeOut, fadeIn } = useMemo3(
2894
+ () => load.status === "ready" ? computeOrphans(load.from, load.to, excludeSet) : { fadeOut: [], fadeIn: [] },
2895
+ [load, excludeSet]
2896
+ );
2897
+ const allOrphans = useMemo3(
2898
+ () => [
2899
+ ...fadeOut.map((t) => ({ track: t, direction: "out" })),
2900
+ ...fadeIn.map((t) => ({ track: t, direction: "in" }))
2901
+ ],
2902
+ [fadeOut, fadeIn]
2903
+ );
2904
+ useEffect6(() => {
2905
+ if (!allOrphans.some((o) => o.track.dbId === selectedDbId)) {
2906
+ setSelectedDbId(allOrphans[0]?.track.dbId ?? "");
2907
+ }
2908
+ }, [allOrphans, selectedDbId]);
2909
+ const selected = allOrphans.find((o) => o.track.dbId === selectedDbId) ?? null;
2910
+ const canCreate = !isCreating && !!selected;
2911
+ const handleClose = useCallback4(() => {
2912
+ if (!isCreating) onClose();
2913
+ }, [isCreating, onClose]);
2914
+ const handleCreate = useCallback4(async () => {
2915
+ if (!selected) return;
2916
+ setIsCreating(true);
2917
+ setError(null);
2918
+ try {
2919
+ await onCreate(
2920
+ { dbId: selected.track.dbId, name: selected.track.name, role: selected.track.role },
2921
+ selected.direction,
2922
+ defaultFadeGesture(selected.track.role)
2923
+ );
2924
+ onClose();
2925
+ } catch (err) {
2926
+ setError(err instanceof Error ? err.message : "Failed to create fade.");
2927
+ setIsCreating(false);
2928
+ }
2929
+ }, [selected, onCreate, onClose]);
2930
+ const fromLabel = fromName ?? fromSceneName ?? null;
2931
+ const toLabel = toName ?? toSceneName ?? null;
2932
+ if (!open) return null;
2933
+ const renderSection = (heading, list, section) => {
2934
+ if (list.length === 0) return null;
2935
+ return /* @__PURE__ */ jsxs10("div", { className: "block", children: [
2936
+ /* @__PURE__ */ jsx14("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: heading }),
2937
+ /* @__PURE__ */ jsx14(
2938
+ "div",
2939
+ {
2940
+ role: "radiogroup",
2941
+ "aria-label": heading,
2942
+ "data-testid": `${testIdPrefix}-${section === "out" ? "fade-out" : "fade-in"}-list`,
2943
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
2944
+ children: list.map((t) => /* @__PURE__ */ jsx14(
2945
+ OrphanRow,
2946
+ {
2947
+ track: t,
2948
+ gesture: defaultFadeGesture(t.role),
2949
+ selected: t.dbId === selectedDbId,
2950
+ disabled: isCreating,
2951
+ onSelect: () => setSelectedDbId(t.dbId),
2952
+ testId: `${testIdPrefix}-option-${t.dbId}`
2953
+ },
2954
+ t.dbId
2955
+ ))
2956
+ }
2957
+ )
2958
+ ] });
2959
+ };
2960
+ return /* @__PURE__ */ jsx14(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs10(
2961
+ "div",
2962
+ {
2963
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2964
+ onClick: (e) => e.stopPropagation(),
2965
+ "data-testid": `${testIdPrefix}-box`,
2966
+ children: [
2967
+ /* @__PURE__ */ jsx14("h3", { className: "text-sm font-bold text-sas-text", children: "Add fade" }),
2968
+ /* @__PURE__ */ jsxs10("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2969
+ "Tracks with no counterpart between",
2970
+ " ",
2971
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2972
+ " and",
2973
+ " ",
2974
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2975
+ " can gracefully fade out (leaving) or fade in (entering) across this transition."
2976
+ ] }),
2977
+ load.status === "loading" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2978
+ load.status === "error" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2979
+ load.status === "ready" && (allOrphans.length === 0 ? /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", "data-testid": `${testIdPrefix}-empty`, children: "Every track has a counterpart in the other scene \u2014 nothing to fade. Use \u201C+ Crossfade\u201D to bridge matching tracks." }) : /* @__PURE__ */ jsxs10(Fragment4, { children: [
2980
+ renderSection(`Fade out${fromLabel ? ` (from ${fromLabel})` : ""}`, fadeOut, "out"),
2981
+ renderSection(`Fade in${toLabel ? ` (to ${toLabel})` : ""}`, fadeIn, "in")
2982
+ ] })),
2983
+ error && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
2984
+ /* @__PURE__ */ jsxs10("div", { className: "flex justify-end gap-2 pt-1", children: [
2985
+ /* @__PURE__ */ jsx14(
2986
+ "button",
2987
+ {
2988
+ ref: cancelRef,
2989
+ "data-testid": `${testIdPrefix}-cancel`,
2990
+ onClick: onClose,
2991
+ disabled: isCreating,
2992
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
2993
+ children: "Cancel"
2994
+ }
2995
+ ),
2996
+ /* @__PURE__ */ jsx14(
2997
+ "button",
2998
+ {
2999
+ "data-testid": `${testIdPrefix}-confirm`,
3000
+ onClick: handleCreate,
3001
+ disabled: !canCreate,
3002
+ className: `px-3 py-1 text-xs rounded-sm border transition-colors ${canCreate ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed"}`,
3003
+ children: isCreating ? "Generating fade\u2026" : "Create fade"
3004
+ }
3005
+ )
3006
+ ] })
3007
+ ]
3008
+ }
3009
+ ) });
3010
+ }
3011
+
2571
3012
  // src/components/ImportTrackModal.tsx
2572
- import { useCallback as useCallback4, useEffect as useEffect6, useState as useState7 } from "react";
2573
- import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
3013
+ import { useCallback as useCallback5, useEffect as useEffect7, useState as useState8 } from "react";
3014
+ import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
2574
3015
  function ImportTrackModal({
2575
3016
  host,
2576
3017
  open,
@@ -2582,10 +3023,10 @@ function ImportTrackModal({
2582
3023
  onPick,
2583
3024
  onPortTrack
2584
3025
  }) {
2585
- const [load, setLoad] = useState7({ status: "loading" });
2586
- const [selectedSceneId, setSelectedSceneId] = useState7(null);
2587
- const [importingTrackId, setImportingTrackId] = useState7(null);
2588
- const refresh = useCallback4(async () => {
3026
+ const [load, setLoad] = useState8({ status: "loading" });
3027
+ const [selectedSceneId, setSelectedSceneId] = useState8(null);
3028
+ const [importingTrackId, setImportingTrackId] = useState8(null);
3029
+ const refresh = useCallback5(async () => {
2589
3030
  if (!host.listImportableTracks) {
2590
3031
  setLoad({ status: "error", message: "This host does not support importing tracks." });
2591
3032
  return;
@@ -2601,14 +3042,14 @@ function ImportTrackModal({
2601
3042
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2602
3043
  }
2603
3044
  }, [host, mode, onPortTrack]);
2604
- useEffect6(() => {
3045
+ useEffect7(() => {
2605
3046
  if (open) {
2606
3047
  setSelectedSceneId(null);
2607
3048
  setImportingTrackId(null);
2608
3049
  void refresh();
2609
3050
  }
2610
3051
  }, [open, refresh]);
2611
- const handleImport = useCallback4(
3052
+ const handleImport = useCallback5(
2612
3053
  async (track, sourceSceneId, sceneName, isSameScene) => {
2613
3054
  if (isSameScene && onPortTrack) {
2614
3055
  if (!track.importable) return;
@@ -2649,16 +3090,16 @@ function ImportTrackModal({
2649
3090
  if (!open) return null;
2650
3091
  const scenes = load.status === "ready" ? load.scenes : [];
2651
3092
  const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2652
- return /* @__PURE__ */ jsx13(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ jsxs9(
3093
+ return /* @__PURE__ */ jsx15(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ jsxs11(
2653
3094
  "div",
2654
3095
  {
2655
3096
  className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2656
3097
  onClick: (e) => e.stopPropagation(),
2657
3098
  "data-testid": `${testIdPrefix}-modal`,
2658
3099
  children: [
2659
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2660
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2", children: [
2661
- selectedScene && /* @__PURE__ */ jsx13(
3100
+ /* @__PURE__ */ jsxs11("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
3101
+ /* @__PURE__ */ jsxs11("div", { className: "flex items-center gap-2", children: [
3102
+ selectedScene && /* @__PURE__ */ jsx15(
2662
3103
  "button",
2663
3104
  {
2664
3105
  className: "text-sas-muted hover:text-sas-accent text-xs",
@@ -2667,9 +3108,9 @@ function ImportTrackModal({
2667
3108
  children: "\u2190"
2668
3109
  }
2669
3110
  ),
2670
- /* @__PURE__ */ jsx13("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
3111
+ /* @__PURE__ */ jsx15("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2671
3112
  ] }),
2672
- /* @__PURE__ */ jsx13(
3113
+ /* @__PURE__ */ jsx15(
2673
3114
  "button",
2674
3115
  {
2675
3116
  className: "text-sas-muted hover:text-sas-accent text-sm",
@@ -2679,30 +3120,30 @@ function ImportTrackModal({
2679
3120
  }
2680
3121
  )
2681
3122
  ] }),
2682
- /* @__PURE__ */ jsxs9("div", { className: "overflow-y-auto p-2 flex-1", children: [
2683
- load.status === "loading" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
2684
- load.status === "error" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
2685
- load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
2686
- load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
3123
+ /* @__PURE__ */ jsxs11("div", { className: "overflow-y-auto p-2 flex-1", children: [
3124
+ load.status === "loading" && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
3125
+ load.status === "error" && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
3126
+ load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
3127
+ load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ jsx15("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ jsx15("li", { children: /* @__PURE__ */ jsxs11(
2687
3128
  "button",
2688
3129
  {
2689
3130
  className: "w-full flex items-center justify-between px-2 py-1.5 rounded-sm border border-sas-border bg-sas-panel-alt text-left text-xs text-sas-text hover:border-sas-accent hover:text-sas-accent transition-colors",
2690
3131
  onClick: () => setSelectedSceneId(scene.sceneId),
2691
3132
  "data-testid": `${testIdPrefix}-scene`,
2692
3133
  children: [
2693
- /* @__PURE__ */ jsx13("span", { className: "truncate", children: scene.sceneName }),
2694
- /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
3134
+ /* @__PURE__ */ jsx15("span", { className: "truncate", children: scene.sceneName }),
3135
+ /* @__PURE__ */ jsxs11("span", { className: "text-sas-muted", children: [
2695
3136
  scene.tracks.length,
2696
3137
  " \u2192"
2697
3138
  ] })
2698
3139
  ]
2699
3140
  }
2700
3141
  ) }, scene.sceneId)) }),
2701
- selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
3142
+ selectedScene && /* @__PURE__ */ jsx15("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2702
3143
  const busy = importingTrackId === track.trackId;
2703
3144
  const gated = mode === "track" && !track.importable;
2704
3145
  const disabled = gated || busy;
2705
- return /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
3146
+ return /* @__PURE__ */ jsx15("li", { children: /* @__PURE__ */ jsxs11(
2706
3147
  "button",
2707
3148
  {
2708
3149
  className: `w-full flex items-center justify-between px-2 py-1.5 rounded-sm border text-left text-xs transition-colors ${disabled ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-text hover:border-sas-accent hover:text-sas-accent"}`,
@@ -2712,14 +3153,14 @@ function ImportTrackModal({
2712
3153
  "data-testid": `${testIdPrefix}-track`,
2713
3154
  "data-importable": mode === "sound" || track.importable ? "true" : "false",
2714
3155
  children: [
2715
- /* @__PURE__ */ jsxs9("span", { className: "truncate", children: [
3156
+ /* @__PURE__ */ jsxs11("span", { className: "truncate", children: [
2716
3157
  track.name,
2717
- track.role ? /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
3158
+ track.role ? /* @__PURE__ */ jsxs11("span", { className: "text-sas-muted", children: [
2718
3159
  " \xB7 ",
2719
3160
  track.role
2720
3161
  ] }) : null
2721
3162
  ] }),
2722
- busy ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2298" }) : null
3163
+ busy ? /* @__PURE__ */ jsx15("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ jsx15("span", { className: "text-sas-muted", children: "\u2298" }) : null
2723
3164
  ]
2724
3165
  }
2725
3166
  ) }, track.dbId);
@@ -2731,8 +3172,38 @@ function ImportTrackModal({
2731
3172
  }
2732
3173
 
2733
3174
  // src/components/CrossfadeModal.tsx
2734
- import { useCallback as useCallback5, useEffect as useEffect7, useMemo as useMemo3, useRef as useRef7, useState as useState8 } from "react";
2735
- import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
3175
+ import { useCallback as useCallback6, useEffect as useEffect8, useMemo as useMemo4, useRef as useRef8, useState as useState9 } from "react";
3176
+ import { Fragment as Fragment5, jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
3177
+ function shortId2(dbId) {
3178
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3179
+ }
3180
+ function CandidateRow({
3181
+ track,
3182
+ selected,
3183
+ disabled,
3184
+ onSelect,
3185
+ testId
3186
+ }) {
3187
+ const primary = track.prompt?.trim() || track.name;
3188
+ const meta = [track.role, shortId2(track.dbId)].filter(Boolean).join(" \xB7 ");
3189
+ return /* @__PURE__ */ jsxs12(
3190
+ "button",
3191
+ {
3192
+ type: "button",
3193
+ role: "radio",
3194
+ "aria-checked": selected,
3195
+ "data-testid": testId,
3196
+ "data-value": track.dbId,
3197
+ onClick: onSelect,
3198
+ disabled,
3199
+ className: `w-full text-left px-2 py-1.5 rounded-sm border transition-colors disabled:opacity-50 ${selected ? "bg-sas-accent/15 border-sas-accent" : "bg-sas-panel border-sas-border hover:border-sas-accent/50"}`,
3200
+ children: [
3201
+ /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
3202
+ meta && /* @__PURE__ */ jsx16("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
3203
+ ]
3204
+ }
3205
+ );
3206
+ }
2736
3207
  function CrossfadeModal({
2737
3208
  host,
2738
3209
  open,
@@ -2740,34 +3211,40 @@ function CrossfadeModal({
2740
3211
  toSceneId,
2741
3212
  fromSceneName,
2742
3213
  toSceneName,
3214
+ excludeSourceDbIds,
2743
3215
  onClose,
2744
3216
  onCreate,
2745
3217
  testIdPrefix = "crossfade-modal"
2746
3218
  }) {
2747
- const [load, setLoad] = useState8({ status: "loading" });
2748
- const [originDbId, setOriginDbId] = useState8("");
2749
- const [targetDbId, setTargetDbId] = useState8("");
2750
- const [isCreating, setIsCreating] = useState8(false);
2751
- const [error, setError] = useState8(null);
2752
- const cancelRef = useRef7(null);
2753
- const refresh = useCallback5(async () => {
3219
+ const [load, setLoad] = useState9({ status: "loading" });
3220
+ const [originDbId, setOriginDbId] = useState9("");
3221
+ const [targetDbId, setTargetDbId] = useState9("");
3222
+ const [isCreating, setIsCreating] = useState9(false);
3223
+ const [error, setError] = useState9(null);
3224
+ const [fromName, setFromName] = useState9(null);
3225
+ const [toName, setToName] = useState9(null);
3226
+ const cancelRef = useRef8(null);
3227
+ const refresh = useCallback6(async () => {
2754
3228
  if (!host.listSceneFamilyTracks) {
2755
3229
  setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2756
3230
  return;
2757
3231
  }
2758
3232
  setLoad({ status: "loading" });
2759
3233
  try {
2760
- const [origin, target] = await Promise.all([
3234
+ const [origin, target, fName, tName] = await Promise.all([
2761
3235
  host.listSceneFamilyTracks(fromSceneId),
2762
- host.listSceneFamilyTracks(toSceneId)
3236
+ host.listSceneFamilyTracks(toSceneId),
3237
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3238
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2763
3239
  ]);
3240
+ setFromName(fName);
3241
+ setToName(tName);
2764
3242
  setLoad({ status: "ready", origin, target });
2765
- setOriginDbId(origin[0]?.dbId ?? "");
2766
3243
  } catch (err) {
2767
3244
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2768
3245
  }
2769
3246
  }, [host, fromSceneId, toSceneId]);
2770
- useEffect7(() => {
3247
+ useEffect8(() => {
2771
3248
  if (open) {
2772
3249
  setError(null);
2773
3250
  setIsCreating(false);
@@ -2776,27 +3253,32 @@ function CrossfadeModal({
2776
3253
  void refresh();
2777
3254
  }
2778
3255
  }, [open, refresh]);
2779
- const originTrack = useMemo3(
2780
- () => load.status === "ready" ? load.origin.find((t) => t.dbId === originDbId) ?? null : null,
2781
- [load, originDbId]
3256
+ const excludeSet = useMemo4(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3257
+ const originCandidates = useMemo4(
3258
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
3259
+ [load, excludeSet]
2782
3260
  );
2783
- const originRole = originTrack?.role;
2784
- const targetCandidates = useMemo3(() => {
2785
- if (load.status !== "ready") return [];
2786
- if (!originRole) return load.target;
2787
- return load.target.filter((t) => t.role === originRole);
2788
- }, [load, originRole]);
2789
- useEffect7(() => {
3261
+ const targetCandidates = useMemo4(
3262
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
3263
+ [load, excludeSet]
3264
+ );
3265
+ useEffect8(() => {
3266
+ if (!originCandidates.some((t) => t.dbId === originDbId)) {
3267
+ setOriginDbId(originCandidates[0]?.dbId ?? "");
3268
+ }
3269
+ }, [originCandidates, originDbId]);
3270
+ useEffect8(() => {
2790
3271
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2791
3272
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2792
3273
  }
2793
3274
  }, [targetCandidates, targetDbId]);
3275
+ const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2794
3276
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2795
3277
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2796
- const handleClose = useCallback5(() => {
3278
+ const handleClose = useCallback6(() => {
2797
3279
  if (!isCreating) onClose();
2798
3280
  }, [isCreating, onClose]);
2799
- const handleCreate = useCallback5(async () => {
3281
+ const handleCreate = useCallback6(async () => {
2800
3282
  if (!originTrack || !targetTrack) return;
2801
3283
  setIsCreating(true);
2802
3284
  setError(null);
@@ -2811,79 +3293,100 @@ function CrossfadeModal({
2811
3293
  setIsCreating(false);
2812
3294
  }
2813
3295
  }, [originTrack, targetTrack, onCreate, onClose]);
3296
+ const fromLabel = fromName ?? fromSceneName ?? null;
3297
+ const toLabel = toName ?? toSceneName ?? null;
2814
3298
  if (!open) return null;
2815
- return /* @__PURE__ */ jsx14(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs10(
3299
+ return /* @__PURE__ */ jsx16(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs12(
2816
3300
  "div",
2817
3301
  {
2818
3302
  className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2819
3303
  onClick: (e) => e.stopPropagation(),
2820
3304
  "data-testid": `${testIdPrefix}-box`,
2821
3305
  children: [
2822
- /* @__PURE__ */ jsx14("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2823
- /* @__PURE__ */ jsxs10("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3306
+ /* @__PURE__ */ jsx16("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
3307
+ /* @__PURE__ */ jsxs12("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2824
3308
  "Bridge a track from",
2825
3309
  " ",
2826
- /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: fromSceneName ?? "the origin scene" }),
3310
+ /* @__PURE__ */ jsx16("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2827
3311
  " into one from",
2828
3312
  " ",
2829
- /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: toSceneName ?? "the target scene" }),
3313
+ /* @__PURE__ */ jsx16("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2830
3314
  ". Both layers share one generated part; each keeps its own preset."
2831
3315
  ] }),
2832
- load.status === "loading" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2833
- load.status === "error" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2834
- load.status === "ready" && (load.origin.length === 0 ? /* @__PURE__ */ jsx14(
3316
+ load.status === "loading" && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3317
+ load.status === "error" && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3318
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ jsxs12(
2835
3319
  "div",
2836
3320
  {
2837
3321
  className: "text-xs text-sas-muted py-4 text-center",
2838
3322
  "data-testid": `${testIdPrefix}-empty-origin`,
2839
- children: "No matching tracks in the origin scene. Add one there first."
3323
+ children: [
3324
+ "No available tracks in ",
3325
+ fromLabel ?? "the origin scene",
3326
+ ". Add one (or free one from another crossfade) first."
3327
+ ]
2840
3328
  }
2841
- ) : /* @__PURE__ */ jsxs10(Fragment3, { children: [
2842
- /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2843
- /* @__PURE__ */ jsx14("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "Origin (top)" }),
2844
- /* @__PURE__ */ jsx14(
2845
- "select",
3329
+ ) : /* @__PURE__ */ jsxs12(Fragment5, { children: [
3330
+ /* @__PURE__ */ jsxs12("div", { className: "block", children: [
3331
+ /* @__PURE__ */ jsxs12("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3332
+ "Origin ",
3333
+ fromLabel ? `(${fromLabel})` : "(top)"
3334
+ ] }),
3335
+ /* @__PURE__ */ jsx16(
3336
+ "div",
2846
3337
  {
2847
- "data-testid": `${testIdPrefix}-origin-select`,
2848
- value: originDbId,
2849
- onChange: (e) => setOriginDbId(e.target.value),
2850
- disabled: isCreating,
2851
- className: "sas-input w-full mt-0.5 text-xs",
2852
- children: load.origin.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2853
- t.name,
2854
- t.role ? ` \xB7 ${t.role}` : ""
2855
- ] }, t.dbId))
3338
+ role: "radiogroup",
3339
+ "aria-label": "Origin track",
3340
+ "data-testid": `${testIdPrefix}-origin-list`,
3341
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3342
+ children: originCandidates.map((t) => /* @__PURE__ */ jsx16(
3343
+ CandidateRow,
3344
+ {
3345
+ track: t,
3346
+ selected: t.dbId === originDbId,
3347
+ disabled: isCreating,
3348
+ onSelect: () => setOriginDbId(t.dbId),
3349
+ testId: `${testIdPrefix}-origin-option-${t.dbId}`
3350
+ },
3351
+ t.dbId
3352
+ ))
2856
3353
  }
2857
3354
  )
2858
3355
  ] }),
2859
- /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2860
- /* @__PURE__ */ jsxs10("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2861
- "Target (bottom)",
2862
- originRole ? ` \xB7 ${originRole}` : ""
3356
+ /* @__PURE__ */ jsxs12("div", { className: "block", children: [
3357
+ /* @__PURE__ */ jsxs12("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3358
+ "Target ",
3359
+ toLabel ? `(${toLabel})` : "(bottom)"
2863
3360
  ] }),
2864
- targetCandidates.length === 0 ? /* @__PURE__ */ jsxs10("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2865
- "No ",
2866
- originRole ?? "matching",
2867
- " track in the target scene to crossfade into."
2868
- ] }) : /* @__PURE__ */ jsx14(
2869
- "select",
3361
+ targetCandidates.length === 0 ? /* @__PURE__ */ jsxs12("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
3362
+ "No available tracks in ",
3363
+ toLabel ?? "the target scene",
3364
+ " to crossfade into."
3365
+ ] }) : /* @__PURE__ */ jsx16(
3366
+ "div",
2870
3367
  {
2871
- "data-testid": `${testIdPrefix}-target-select`,
2872
- value: targetDbId,
2873
- onChange: (e) => setTargetDbId(e.target.value),
2874
- disabled: isCreating,
2875
- className: "sas-input w-full mt-0.5 text-xs",
2876
- children: targetCandidates.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2877
- t.name,
2878
- t.role ? ` \xB7 ${t.role}` : ""
2879
- ] }, t.dbId))
3368
+ role: "radiogroup",
3369
+ "aria-label": "Target track",
3370
+ "data-testid": `${testIdPrefix}-target-list`,
3371
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3372
+ children: targetCandidates.map((t) => /* @__PURE__ */ jsx16(
3373
+ CandidateRow,
3374
+ {
3375
+ track: t,
3376
+ selected: t.dbId === targetDbId,
3377
+ disabled: isCreating,
3378
+ onSelect: () => setTargetDbId(t.dbId),
3379
+ testId: `${testIdPrefix}-target-option-${t.dbId}`
3380
+ },
3381
+ t.dbId
3382
+ ))
2880
3383
  }
2881
3384
  )
2882
3385
  ] })
2883
3386
  ] })),
2884
- error && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
2885
- /* @__PURE__ */ jsxs10("div", { className: "flex justify-end gap-2 pt-1", children: [
2886
- /* @__PURE__ */ jsx14(
3387
+ error && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3388
+ /* @__PURE__ */ jsxs12("div", { className: "flex justify-end gap-2 pt-1", children: [
3389
+ /* @__PURE__ */ jsx16(
2887
3390
  "button",
2888
3391
  {
2889
3392
  ref: cancelRef,
@@ -2894,7 +3397,7 @@ function CrossfadeModal({
2894
3397
  children: "Cancel"
2895
3398
  }
2896
3399
  ),
2897
- /* @__PURE__ */ jsx14(
3400
+ /* @__PURE__ */ jsx16(
2898
3401
  "button",
2899
3402
  {
2900
3403
  "data-testid": `${testIdPrefix}-confirm`,
@@ -2911,8 +3414,8 @@ function CrossfadeModal({
2911
3414
  }
2912
3415
 
2913
3416
  // src/components/DownloadPackButton.tsx
2914
- import { useCallback as useCallback6, useEffect as useEffect8, useState as useState9 } from "react";
2915
- import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
3417
+ import { useCallback as useCallback7, useEffect as useEffect9, useState as useState10 } from "react";
3418
+ import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
2916
3419
  function formatSize(bytes) {
2917
3420
  if (!bytes || bytes <= 0) return "";
2918
3421
  const gb = bytes / 1024 ** 3;
@@ -2928,10 +3431,10 @@ var DownloadPackButton = ({
2928
3431
  variant = "compact",
2929
3432
  onDownloadComplete
2930
3433
  }) => {
2931
- const [status, setStatus] = useState9("idle");
2932
- const [progress, setProgress] = useState9(0);
2933
- const [errorMessage, setErrorMessage] = useState9(null);
2934
- useEffect8(() => {
3434
+ const [status, setStatus] = useState10("idle");
3435
+ const [progress, setProgress] = useState10(0);
3436
+ const [errorMessage, setErrorMessage] = useState10(null);
3437
+ useEffect9(() => {
2935
3438
  const unsub = host.onSamplePackProgress(packId, (p) => {
2936
3439
  setStatus(p.status);
2937
3440
  setProgress(p.progress);
@@ -2946,7 +3449,7 @@ var DownloadPackButton = ({
2946
3449
  });
2947
3450
  return unsub;
2948
3451
  }, [host, packId, onDownloadComplete]);
2949
- const handleClick = useCallback6(async () => {
3452
+ const handleClick = useCallback7(async () => {
2950
3453
  if (status !== "idle" && status !== "error") return;
2951
3454
  try {
2952
3455
  setStatus("downloading");
@@ -3000,8 +3503,8 @@ var DownloadPackButton = ({
3000
3503
  } else {
3001
3504
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3002
3505
  }
3003
- return /* @__PURE__ */ jsxs11("div", { children: [
3004
- /* @__PURE__ */ jsx15(
3506
+ return /* @__PURE__ */ jsxs13("div", { children: [
3507
+ /* @__PURE__ */ jsx17(
3005
3508
  "button",
3006
3509
  {
3007
3510
  "data-testid": `download-pack-button-${packId}`,
@@ -3012,12 +3515,12 @@ var DownloadPackButton = ({
3012
3515
  children: buttonLabel
3013
3516
  }
3014
3517
  ),
3015
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx15("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3518
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx17("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3016
3519
  ] });
3017
3520
  };
3018
3521
 
3019
3522
  // src/components/SamplePackCTACard.tsx
3020
- import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
3523
+ import { jsx as jsx18, jsxs as jsxs14 } from "react/jsx-runtime";
3021
3524
  var SamplePackCTACard = ({
3022
3525
  host,
3023
3526
  pack,
@@ -3025,7 +3528,7 @@ var SamplePackCTACard = ({
3025
3528
  onDownloadComplete
3026
3529
  }) => {
3027
3530
  if (status === "checking") {
3028
- return /* @__PURE__ */ jsx16(
3531
+ return /* @__PURE__ */ jsx18(
3029
3532
  "div",
3030
3533
  {
3031
3534
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -3036,16 +3539,16 @@ var SamplePackCTACard = ({
3036
3539
  }
3037
3540
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3038
3541
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3039
- return /* @__PURE__ */ jsxs12(
3542
+ return /* @__PURE__ */ jsxs14(
3040
3543
  "div",
3041
3544
  {
3042
3545
  "data-testid": `sample-pack-cta-${pack.packId}`,
3043
3546
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3044
3547
  children: [
3045
- /* @__PURE__ */ jsx16("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3046
- /* @__PURE__ */ jsx16("div", { className: "text-base text-sas-text mb-1", children: headline }),
3047
- /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3048
- /* @__PURE__ */ jsx16(
3548
+ /* @__PURE__ */ jsx18("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3549
+ /* @__PURE__ */ jsx18("div", { className: "text-base text-sas-text mb-1", children: headline }),
3550
+ /* @__PURE__ */ jsx18("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3551
+ /* @__PURE__ */ jsx18(
3049
3552
  DownloadPackButton,
3050
3553
  {
3051
3554
  host,
@@ -3062,7 +3565,7 @@ var SamplePackCTACard = ({
3062
3565
  };
3063
3566
 
3064
3567
  // src/components/WaveformView.tsx
3065
- import { useEffect as useEffect9, useRef as useRef8, useState as useState10 } from "react";
3568
+ import { useEffect as useEffect10, useRef as useRef9, useState as useState11 } from "react";
3066
3569
 
3067
3570
  // src/components/waveform.ts
3068
3571
  function computePeaks(audioBuffer, bins, targetSamples) {
@@ -3125,7 +3628,7 @@ function drawWaveform(canvas, peaks, options = {}) {
3125
3628
  }
3126
3629
 
3127
3630
  // src/components/WaveformView.tsx
3128
- import { jsx as jsx17 } from "react/jsx-runtime";
3631
+ import { jsx as jsx19 } from "react/jsx-runtime";
3129
3632
  var WaveformView = ({
3130
3633
  host,
3131
3634
  filePath,
@@ -3134,9 +3637,9 @@ var WaveformView = ({
3134
3637
  fillStyle,
3135
3638
  targetSamples
3136
3639
  }) => {
3137
- const canvasRef = useRef8(null);
3138
- const [peaks, setPeaks] = useState10(null);
3139
- useEffect9(() => {
3640
+ const canvasRef = useRef9(null);
3641
+ const [peaks, setPeaks] = useState11(null);
3642
+ useEffect10(() => {
3140
3643
  let cancelled = false;
3141
3644
  let audioContext = null;
3142
3645
  (async () => {
@@ -3162,7 +3665,7 @@ var WaveformView = ({
3162
3665
  cancelled = true;
3163
3666
  };
3164
3667
  }, [host, filePath, bins, targetSamples]);
3165
- useEffect9(() => {
3668
+ useEffect10(() => {
3166
3669
  if (!peaks) return;
3167
3670
  const canvas = canvasRef.current;
3168
3671
  if (!canvas) return;
@@ -3173,7 +3676,7 @@ var WaveformView = ({
3173
3676
  observer.observe(canvas);
3174
3677
  return () => observer.disconnect();
3175
3678
  }, [peaks, fillStyle]);
3176
- return /* @__PURE__ */ jsx17(
3679
+ return /* @__PURE__ */ jsx19(
3177
3680
  "canvas",
3178
3681
  {
3179
3682
  ref: canvasRef,
@@ -3184,8 +3687,8 @@ var WaveformView = ({
3184
3687
  };
3185
3688
 
3186
3689
  // src/components/ScrollingWaveform.tsx
3187
- import { useEffect as useEffect10, useRef as useRef9 } from "react";
3188
- import { jsx as jsx18 } from "react/jsx-runtime";
3690
+ import { useEffect as useEffect11, useRef as useRef10 } from "react";
3691
+ import { jsx as jsx20 } from "react/jsx-runtime";
3189
3692
  var ScrollingWaveform = ({
3190
3693
  getPeakDb,
3191
3694
  active,
@@ -3193,11 +3696,11 @@ var ScrollingWaveform = ({
3193
3696
  className,
3194
3697
  fillStyle
3195
3698
  }) => {
3196
- const canvasRef = useRef9(null);
3197
- const ringRef = useRef9(new Float32Array(columns));
3198
- const writeIdxRef = useRef9(0);
3199
- const rafRef = useRef9(null);
3200
- useEffect10(() => {
3699
+ const canvasRef = useRef10(null);
3700
+ const ringRef = useRef10(new Float32Array(columns));
3701
+ const writeIdxRef = useRef10(0);
3702
+ const rafRef = useRef10(null);
3703
+ useEffect11(() => {
3201
3704
  if (ringRef.current.length !== columns) {
3202
3705
  const next = new Float32Array(columns);
3203
3706
  const prev = ringRef.current;
@@ -3209,7 +3712,7 @@ var ScrollingWaveform = ({
3209
3712
  writeIdxRef.current = writeIdxRef.current % columns;
3210
3713
  }
3211
3714
  }, [columns]);
3212
- useEffect10(() => {
3715
+ useEffect11(() => {
3213
3716
  if (!active) {
3214
3717
  if (rafRef.current !== null) {
3215
3718
  cancelAnimationFrame(rafRef.current);
@@ -3261,7 +3764,7 @@ var ScrollingWaveform = ({
3261
3764
  }
3262
3765
  };
3263
3766
  }, [active, getPeakDb, fillStyle]);
3264
- return /* @__PURE__ */ jsx18(
3767
+ return /* @__PURE__ */ jsx20(
3265
3768
  "canvas",
3266
3769
  {
3267
3770
  ref: canvasRef,
@@ -3272,8 +3775,8 @@ var ScrollingWaveform = ({
3272
3775
  };
3273
3776
 
3274
3777
  // src/components/OffsetScrubber.tsx
3275
- import { useCallback as useCallback7, useEffect as useEffect11, useMemo as useMemo4, useRef as useRef10, useState as useState11 } from "react";
3276
- import { jsx as jsx19, jsxs as jsxs13 } from "react/jsx-runtime";
3778
+ import { useCallback as useCallback8, useEffect as useEffect12, useMemo as useMemo5, useRef as useRef11, useState as useState12 } from "react";
3779
+ import { jsx as jsx21, jsxs as jsxs15 } from "react/jsx-runtime";
3277
3780
  var SLIDER_HEIGHT_PX = 28;
3278
3781
  var TICK_HEIGHT_PX = 14;
3279
3782
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -3286,40 +3789,40 @@ function OffsetScrubber({
3286
3789
  onChange,
3287
3790
  disabled = false
3288
3791
  }) {
3289
- const trackRef = useRef10(null);
3290
- const [draftOffset, setDraftOffset] = useState11(offsetSamples);
3291
- const [isDragging, setIsDragging] = useState11(false);
3292
- useEffect11(() => {
3792
+ const trackRef = useRef11(null);
3793
+ const [draftOffset, setDraftOffset] = useState12(offsetSamples);
3794
+ const [isDragging, setIsDragging] = useState12(false);
3795
+ useEffect12(() => {
3293
3796
  if (!isDragging) setDraftOffset(offsetSamples);
3294
3797
  }, [offsetSamples, isDragging]);
3295
3798
  const sampleRate = cuePoints?.sample_rate ?? 44100;
3296
3799
  const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3297
- const beatsForRange = useMemo4(() => {
3800
+ const beatsForRange = useMemo5(() => {
3298
3801
  return Math.round(60 / projectBpm * sampleRate);
3299
3802
  }, [projectBpm, sampleRate]);
3300
3803
  const rangeSamples = beatsForRange * meter;
3301
- const sampleToFraction = useCallback7(
3804
+ const sampleToFraction = useCallback8(
3302
3805
  (sample) => {
3303
3806
  const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3304
3807
  return (clamped + rangeSamples) / (2 * rangeSamples);
3305
3808
  },
3306
3809
  [rangeSamples]
3307
3810
  );
3308
- const fractionToSample = useCallback7(
3811
+ const fractionToSample = useCallback8(
3309
3812
  (fraction) => {
3310
3813
  const clamped = Math.max(0, Math.min(1, fraction));
3311
3814
  return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3312
3815
  },
3313
3816
  [rangeSamples]
3314
3817
  );
3315
- const snapTargets = useMemo4(() => {
3818
+ const snapTargets = useMemo5(() => {
3316
3819
  if (!cuePoints || cuePoints.beats.length === 0) return [];
3317
3820
  const downbeat = cuePoints.beats[0];
3318
3821
  const positives = cuePoints.beats.map((b) => b - downbeat);
3319
3822
  const negatives = positives.slice(1).map((p) => -p);
3320
3823
  return [...negatives, ...positives].sort((a, b) => a - b);
3321
3824
  }, [cuePoints]);
3322
- const snapToBeat = useCallback7(
3825
+ const snapToBeat = useCallback8(
3323
3826
  (sample) => {
3324
3827
  if (snapTargets.length === 0) return sample;
3325
3828
  let best = snapTargets[0];
@@ -3335,7 +3838,7 @@ function OffsetScrubber({
3335
3838
  },
3336
3839
  [snapTargets]
3337
3840
  );
3338
- const handlePointerDown = useCallback7(
3841
+ const handlePointerDown = useCallback8(
3339
3842
  (e) => {
3340
3843
  if (disabled || !cuePoints) return;
3341
3844
  e.preventDefault();
@@ -3369,7 +3872,7 @@ function OffsetScrubber({
3369
3872
  },
3370
3873
  [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3371
3874
  );
3372
- const handleResetToZero = useCallback7(() => {
3875
+ const handleResetToZero = useCallback8(() => {
3373
3876
  if (disabled) return;
3374
3877
  setDraftOffset(0);
3375
3878
  onChange(0);
@@ -3377,7 +3880,7 @@ function OffsetScrubber({
3377
3880
  const thumbFraction = sampleToFraction(draftOffset);
3378
3881
  const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3379
3882
  const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3380
- const ticks = useMemo4(() => {
3883
+ const ticks = useMemo5(() => {
3381
3884
  if (!cuePoints) return [];
3382
3885
  const downbeat = cuePoints.beats[0] ?? 0;
3383
3886
  return cuePoints.beats.map((b, i) => {
@@ -3388,9 +3891,9 @@ function OffsetScrubber({
3388
3891
  });
3389
3892
  }, [cuePoints, sampleToFraction]);
3390
3893
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3391
- return /* @__PURE__ */ jsxs13("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3392
- /* @__PURE__ */ jsx19("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3393
- /* @__PURE__ */ jsxs13(
3894
+ return /* @__PURE__ */ jsxs15("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3895
+ /* @__PURE__ */ jsx21("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3896
+ /* @__PURE__ */ jsxs15(
3394
3897
  "div",
3395
3898
  {
3396
3899
  ref: trackRef,
@@ -3406,7 +3909,7 @@ function OffsetScrubber({
3406
3909
  "aria-valuenow": draftOffset,
3407
3910
  "aria-disabled": isDisabled,
3408
3911
  children: [
3409
- /* @__PURE__ */ jsx19(
3912
+ /* @__PURE__ */ jsx21(
3410
3913
  "div",
3411
3914
  {
3412
3915
  "aria-hidden": "true",
@@ -3414,7 +3917,7 @@ function OffsetScrubber({
3414
3917
  style: { left: "50%" }
3415
3918
  }
3416
3919
  ),
3417
- ticks.map((t) => /* @__PURE__ */ jsx19(
3920
+ ticks.map((t) => /* @__PURE__ */ jsx21(
3418
3921
  "div",
3419
3922
  {
3420
3923
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -3429,7 +3932,7 @@ function OffsetScrubber({
3429
3932
  },
3430
3933
  t.i
3431
3934
  )),
3432
- /* @__PURE__ */ jsx19(
3935
+ /* @__PURE__ */ jsx21(
3433
3936
  "div",
3434
3937
  {
3435
3938
  "data-testid": "offset-scrubber-thumb",
@@ -3446,7 +3949,7 @@ function OffsetScrubber({
3446
3949
  ]
3447
3950
  }
3448
3951
  ),
3449
- /* @__PURE__ */ jsx19(
3952
+ /* @__PURE__ */ jsx21(
3450
3953
  "span",
3451
3954
  {
3452
3955
  "data-testid": "offset-scrubber-readout",
@@ -3454,7 +3957,7 @@ function OffsetScrubber({
3454
3957
  children: formatOffset(draftOffset, sampleRate)
3455
3958
  }
3456
3959
  ),
3457
- /* @__PURE__ */ jsx19(
3960
+ /* @__PURE__ */ jsx21(
3458
3961
  "button",
3459
3962
  {
3460
3963
  type: "button",
@@ -3466,7 +3969,7 @@ function OffsetScrubber({
3466
3969
  children: "\u2316"
3467
3970
  }
3468
3971
  ),
3469
- bpmMismatch && /* @__PURE__ */ jsx19(
3972
+ bpmMismatch && /* @__PURE__ */ jsx21(
3470
3973
  "span",
3471
3974
  {
3472
3975
  "data-testid": "offset-bpm-mismatch",
@@ -3538,13 +4041,13 @@ function synthesizeCuePoints({
3538
4041
  }
3539
4042
 
3540
4043
  // src/hooks/useSceneState.ts
3541
- import { useState as useState12, useCallback as useCallback8, useRef as useRef11 } from "react";
4044
+ import { useState as useState13, useCallback as useCallback9, useRef as useRef12 } from "react";
3542
4045
  function useSceneState(activeSceneId, initialValue) {
3543
- const [stateMap, setStateMap] = useState12(() => /* @__PURE__ */ new Map());
3544
- const activeSceneIdRef = useRef11(activeSceneId);
4046
+ const [stateMap, setStateMap] = useState13(() => /* @__PURE__ */ new Map());
4047
+ const activeSceneIdRef = useRef12(activeSceneId);
3545
4048
  activeSceneIdRef.current = activeSceneId;
3546
4049
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
3547
- const setForCurrentScene = useCallback8((value) => {
4050
+ const setForCurrentScene = useCallback9((value) => {
3548
4051
  const sid = activeSceneIdRef.current;
3549
4052
  if (sid === null) return;
3550
4053
  setStateMap((prev) => {
@@ -3555,7 +4058,7 @@ function useSceneState(activeSceneId, initialValue) {
3555
4058
  return newMap;
3556
4059
  });
3557
4060
  }, [initialValue]);
3558
- const setForScene = useCallback8((sceneId, value) => {
4061
+ const setForScene = useCallback9((sceneId, value) => {
3559
4062
  setStateMap((prev) => {
3560
4063
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
3561
4064
  const next = typeof value === "function" ? value(current) : value;
@@ -3568,10 +4071,10 @@ function useSceneState(activeSceneId, initialValue) {
3568
4071
  }
3569
4072
 
3570
4073
  // src/hooks/useAnySolo.ts
3571
- import { useEffect as useEffect12, useState as useState13 } from "react";
4074
+ import { useEffect as useEffect13, useState as useState14 } from "react";
3572
4075
  function useAnySolo(host) {
3573
- const [anySolo, setAnySolo] = useState13(false);
3574
- useEffect12(() => {
4076
+ const [anySolo, setAnySolo] = useState14(false);
4077
+ useEffect13(() => {
3575
4078
  let active = true;
3576
4079
  const refresh = () => {
3577
4080
  host.isAnySoloActive().then((v) => {
@@ -3590,7 +4093,7 @@ function useAnySolo(host) {
3590
4093
  }
3591
4094
 
3592
4095
  // src/hooks/useSoundHistory.ts
3593
- import { useCallback as useCallback9, useMemo as useMemo5, useRef as useRef12, useState as useState14 } from "react";
4096
+ import { useCallback as useCallback10, useMemo as useMemo6, useRef as useRef13, useState as useState15 } from "react";
3594
4097
  var EMPTY = { entries: [], cursor: -1 };
3595
4098
  function sameDescriptor(a, b) {
3596
4099
  if (a === b) return true;
@@ -3602,14 +4105,14 @@ function sameDescriptor(a, b) {
3602
4105
  }
3603
4106
  function useSoundHistory(applySound, opts = {}) {
3604
4107
  const max = Math.max(2, opts.max ?? 24);
3605
- const applyRef = useRef12(applySound);
4108
+ const applyRef = useRef13(applySound);
3606
4109
  applyRef.current = applySound;
3607
- const onChangeRef = useRef12(opts.onChange);
4110
+ const onChangeRef = useRef13(opts.onChange);
3608
4111
  onChangeRef.current = opts.onChange;
3609
- const dataRef = useRef12({});
3610
- const [, setVersion] = useState14(0);
3611
- const bump = useCallback9(() => setVersion((v) => v + 1), []);
3612
- const commit = useCallback9(
4112
+ const dataRef = useRef13({});
4113
+ const [, setVersion] = useState15(0);
4114
+ const bump = useCallback10(() => setVersion((v) => v + 1), []);
4115
+ const commit = useCallback10(
3613
4116
  (trackId, next, notify) => {
3614
4117
  dataRef.current = { ...dataRef.current, [trackId]: next };
3615
4118
  bump();
@@ -3617,7 +4120,7 @@ function useSoundHistory(applySound, opts = {}) {
3617
4120
  },
3618
4121
  [bump]
3619
4122
  );
3620
- const record = useCallback9(
4123
+ const record = useCallback10(
3621
4124
  (trackId, descriptor, label) => {
3622
4125
  const h = dataRef.current[trackId];
3623
4126
  const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
@@ -3632,7 +4135,7 @@ function useSoundHistory(applySound, opts = {}) {
3632
4135
  },
3633
4136
  [max, commit]
3634
4137
  );
3635
- const restoreTo = useCallback9(
4138
+ const restoreTo = useCallback10(
3636
4139
  async (trackId, index) => {
3637
4140
  const h = dataRef.current[trackId];
3638
4141
  if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
@@ -3642,7 +4145,7 @@ function useSoundHistory(applySound, opts = {}) {
3642
4145
  },
3643
4146
  [commit]
3644
4147
  );
3645
- const undo = useCallback9(
4148
+ const undo = useCallback10(
3646
4149
  (trackId) => {
3647
4150
  const h = dataRef.current[trackId];
3648
4151
  if (!h || h.cursor <= 0) return Promise.resolve(false);
@@ -3650,7 +4153,7 @@ function useSoundHistory(applySound, opts = {}) {
3650
4153
  },
3651
4154
  [restoreTo]
3652
4155
  );
3653
- const toggleFavorite = useCallback9(
4156
+ const toggleFavorite = useCallback10(
3654
4157
  (trackId, index) => {
3655
4158
  const h = dataRef.current[trackId];
3656
4159
  if (!h || index < 0 || index >= h.entries.length) return;
@@ -3659,7 +4162,7 @@ function useSoundHistory(applySound, opts = {}) {
3659
4162
  },
3660
4163
  [commit]
3661
4164
  );
3662
- const restore = useCallback9(
4165
+ const restore = useCallback10(
3663
4166
  (trackId, state) => {
3664
4167
  const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3665
4168
  const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
@@ -3668,15 +4171,15 @@ function useSoundHistory(applySound, opts = {}) {
3668
4171
  },
3669
4172
  [commit]
3670
4173
  );
3671
- const list = useCallback9(
4174
+ const list = useCallback10(
3672
4175
  (trackId) => dataRef.current[trackId] ?? EMPTY,
3673
4176
  []
3674
4177
  );
3675
- const canUndo = useCallback9((trackId) => {
4178
+ const canUndo = useCallback10((trackId) => {
3676
4179
  const h = dataRef.current[trackId];
3677
4180
  return !!h && h.cursor > 0;
3678
4181
  }, []);
3679
- const clear = useCallback9(
4182
+ const clear = useCallback10(
3680
4183
  (trackId) => {
3681
4184
  if (dataRef.current[trackId]) {
3682
4185
  const next = { ...dataRef.current };
@@ -3688,18 +4191,18 @@ function useSoundHistory(applySound, opts = {}) {
3688
4191
  },
3689
4192
  [bump]
3690
4193
  );
3691
- const reset = useCallback9(() => {
4194
+ const reset = useCallback10(() => {
3692
4195
  dataRef.current = {};
3693
4196
  bump();
3694
4197
  }, [bump]);
3695
- return useMemo5(
4198
+ return useMemo6(
3696
4199
  () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3697
4200
  [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3698
4201
  );
3699
4202
  }
3700
4203
 
3701
4204
  // src/hooks/useTrackReorder.ts
3702
- import { useCallback as useCallback10, useRef as useRef13, useState as useState15 } from "react";
4205
+ import { useCallback as useCallback11, useRef as useRef14, useState as useState16 } from "react";
3703
4206
  function moveItem(arr, from, to) {
3704
4207
  const next = arr.slice();
3705
4208
  if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
@@ -3716,12 +4219,12 @@ function useTrackReorder({
3716
4219
  getId,
3717
4220
  onError
3718
4221
  }) {
3719
- const [draggingIndex, setDraggingIndex] = useState15(null);
3720
- const [dragOverIndex, setDragOverIndex] = useState15(null);
3721
- const fromRef = useRef13(null);
3722
- const itemsRef = useRef13(items);
4222
+ const [draggingIndex, setDraggingIndex] = useState16(null);
4223
+ const [dragOverIndex, setDragOverIndex] = useState16(null);
4224
+ const fromRef = useRef14(null);
4225
+ const itemsRef = useRef14(items);
3723
4226
  itemsRef.current = items;
3724
- const dragPropsFor = useCallback10(
4227
+ const dragPropsFor = useCallback11(
3725
4228
  (index) => ({
3726
4229
  handleProps: {
3727
4230
  draggable: true,
@@ -3783,7 +4286,7 @@ function useTrackReorder({
3783
4286
  }
3784
4287
 
3785
4288
  // src/constants/sdk-version.ts
3786
- var PLUGIN_SDK_VERSION = "2.25.0";
4289
+ var PLUGIN_SDK_VERSION = "2.28.0";
3787
4290
 
3788
4291
  // src/utils/format-concurrent-tracks.ts
3789
4292
  function formatConcurrentTracks(ctx) {
@@ -3943,6 +4446,8 @@ export {
3943
4446
  FX_DISPLAY_LABELS,
3944
4447
  FX_ENGINE_PLUGIN_NAMES,
3945
4448
  FX_PRESET_CONFIGS,
4449
+ FadeModal,
4450
+ FadeTrackRow,
3946
4451
  FxToggleBar,
3947
4452
  GUTTER_W,
3948
4453
  ImportTrackModal,
@@ -3961,6 +4466,7 @@ export {
3961
4466
  SamplePackCTACard,
3962
4467
  ScrollingWaveform,
3963
4468
  SorceryProgressBar,
4469
+ TEXTURAL_ROLES,
3964
4470
  TrackDrawer,
3965
4471
  TrackMeterStrip,
3966
4472
  TrackRow,
@@ -3968,17 +4474,21 @@ export {
3968
4474
  WaveformView,
3969
4475
  analyzeWavPeak,
3970
4476
  asCrossfadeMeta,
4477
+ asFadeMeta,
3971
4478
  buildCrossfadeInpaintPrompt,
3972
4479
  buildCrossfadeVolumeCurves,
4480
+ buildFadeVolumeCurve,
3973
4481
  calculateTimeBasedTarget,
3974
4482
  cellToPx,
3975
4483
  centerScrollTop,
3976
4484
  computePeaks,
3977
4485
  dbToSlider,
4486
+ defaultFadeGesture,
3978
4487
  drawWaveform,
3979
4488
  formatConcurrentTracks,
3980
4489
  moveItem,
3981
4490
  parseCrossfadePairs,
4491
+ parseFades,
3982
4492
  pickTopKWeighted,
3983
4493
  pitchToName,
3984
4494
  pxToCell,