@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.js CHANGED
@@ -47,6 +47,8 @@ __export(index_exports, {
47
47
  FX_DISPLAY_LABELS: () => FX_DISPLAY_LABELS,
48
48
  FX_ENGINE_PLUGIN_NAMES: () => FX_ENGINE_PLUGIN_NAMES,
49
49
  FX_PRESET_CONFIGS: () => FX_PRESET_CONFIGS,
50
+ FadeModal: () => FadeModal,
51
+ FadeTrackRow: () => FadeTrackRow,
50
52
  FxToggleBar: () => FxToggleBar,
51
53
  GUTTER_W: () => GUTTER_W,
52
54
  ImportTrackModal: () => ImportTrackModal,
@@ -65,6 +67,7 @@ __export(index_exports, {
65
67
  SamplePackCTACard: () => SamplePackCTACard,
66
68
  ScrollingWaveform: () => ScrollingWaveform,
67
69
  SorceryProgressBar: () => SorceryProgressBar,
70
+ TEXTURAL_ROLES: () => TEXTURAL_ROLES,
68
71
  TrackDrawer: () => TrackDrawer,
69
72
  TrackMeterStrip: () => TrackMeterStrip,
70
73
  TrackRow: () => TrackRow,
@@ -72,17 +75,21 @@ __export(index_exports, {
72
75
  WaveformView: () => WaveformView,
73
76
  analyzeWavPeak: () => analyzeWavPeak,
74
77
  asCrossfadeMeta: () => asCrossfadeMeta,
78
+ asFadeMeta: () => asFadeMeta,
75
79
  buildCrossfadeInpaintPrompt: () => buildCrossfadeInpaintPrompt,
76
80
  buildCrossfadeVolumeCurves: () => buildCrossfadeVolumeCurves,
81
+ buildFadeVolumeCurve: () => buildFadeVolumeCurve,
77
82
  calculateTimeBasedTarget: () => calculateTimeBasedTarget,
78
83
  cellToPx: () => cellToPx,
79
84
  centerScrollTop: () => centerScrollTop,
80
85
  computePeaks: () => computePeaks,
81
86
  dbToSlider: () => dbToSlider,
87
+ defaultFadeGesture: () => defaultFadeGesture,
82
88
  drawWaveform: () => drawWaveform,
83
89
  formatConcurrentTracks: () => formatConcurrentTracks,
84
90
  moveItem: () => moveItem,
85
91
  parseCrossfadePairs: () => parseCrossfadePairs,
92
+ parseFades: () => parseFades,
86
93
  pickTopKWeighted: () => pickTopKWeighted,
87
94
  pitchToName: () => pitchToName,
88
95
  pxToCell: () => pxToCell,
@@ -2570,6 +2577,8 @@ function parseCrossfadePairs(sceneData) {
2570
2577
  sliderPos: g.origin.meta.sliderPos,
2571
2578
  originDbId: g.origin.dbId,
2572
2579
  targetDbId: g.target.dbId,
2580
+ originSourceDbId: g.origin.meta.sourceTrackDbId,
2581
+ targetSourceDbId: g.target.meta.sourceTrackDbId,
2573
2582
  originSourceName: g.origin.meta.sourceName,
2574
2583
  originSoundLabel: g.origin.meta.soundLabel,
2575
2584
  targetSourceName: g.target.meta.sourceName,
@@ -2673,9 +2682,448 @@ function buildCrossfadeInpaintPrompt(input) {
2673
2682
  return lines.join("\n");
2674
2683
  }
2675
2684
 
2676
- // src/components/ImportTrackModal.tsx
2677
- var import_react11 = require("react");
2685
+ // src/fade-meta.ts
2686
+ function asFadeMeta(val) {
2687
+ if (!val || typeof val !== "object") return null;
2688
+ const m = val;
2689
+ if (m.direction !== "in" && m.direction !== "out") return null;
2690
+ if (m.gesture !== "volume" && m.gesture !== "build") return null;
2691
+ return {
2692
+ direction: m.direction,
2693
+ gesture: m.gesture,
2694
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2695
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2696
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2697
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2698
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2699
+ };
2700
+ }
2701
+ function parseFades(sceneData) {
2702
+ const out = [];
2703
+ for (const [key, val] of Object.entries(sceneData)) {
2704
+ const match = /^track:(.+):fade$/.exec(key);
2705
+ if (!match) continue;
2706
+ const meta = asFadeMeta(val);
2707
+ if (!meta) continue;
2708
+ out.push({ dbId: match[1], meta });
2709
+ }
2710
+ return out;
2711
+ }
2712
+ function buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture, steps = 32) {
2713
+ const durationSeconds = bars * 4 * 60 / Math.max(1, bpm);
2714
+ if (gesture === "build") {
2715
+ return [
2716
+ { time: 0, db: 0 },
2717
+ { time: Math.round(durationSeconds * 1e3) / 1e3, db: 0 }
2718
+ ];
2719
+ }
2720
+ const s = Math.min(0.98, Math.max(0.02, sliderPos));
2721
+ const round = (n) => Math.round(n * 1e3) / 1e3;
2722
+ const points = [];
2723
+ for (let i = 0; i <= steps; i++) {
2724
+ const x = i / steps;
2725
+ const time = round(x * durationSeconds);
2726
+ const theta = x <= s ? x / s * (Math.PI / 4) : Math.PI / 4 + (x - s) / (1 - s) * (Math.PI / 4);
2727
+ const gain = direction === "out" ? Math.cos(theta) : Math.sin(theta);
2728
+ points.push({ time, db: Math.round(gainToDb(gain) * 100) / 100 });
2729
+ }
2730
+ return points;
2731
+ }
2732
+ var TEXTURAL_ROLES = /* @__PURE__ */ new Set([
2733
+ "pads",
2734
+ "pad",
2735
+ "strings",
2736
+ "atmospheres",
2737
+ "atmosphere",
2738
+ "atmos",
2739
+ "drones",
2740
+ "drone",
2741
+ "soundscapes",
2742
+ "soundscape"
2743
+ ]);
2744
+ function defaultFadeGesture(role) {
2745
+ if (!role) return "build";
2746
+ const norm = role.toLowerCase().replace(/[\s_-]+/g, " ").trim();
2747
+ if (TEXTURAL_ROLES.has(norm)) return "volume";
2748
+ for (const token of norm.split(" ")) {
2749
+ if (TEXTURAL_ROLES.has(token)) return "volume";
2750
+ }
2751
+ return "build";
2752
+ }
2753
+
2754
+ // src/components/FadeTrackRow.tsx
2755
+ var import_react11 = __toESM(require("react"));
2678
2756
  var import_jsx_runtime13 = require("react/jsx-runtime");
2757
+ function FadeCaption({
2758
+ layer,
2759
+ direction,
2760
+ gesture
2761
+ }) {
2762
+ const tag = direction === "in" ? "Fade in" : "Fade out";
2763
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2764
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2765
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2766
+ layer.soundLabel && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2767
+ "\xB7 ",
2768
+ layer.soundLabel
2769
+ ] }),
2770
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", title: `Fade gesture: ${gesture}`, children: [
2771
+ "\xB7 ",
2772
+ gesture
2773
+ ] })
2774
+ ] });
2775
+ }
2776
+ function FadeTrackRow({
2777
+ layer,
2778
+ direction,
2779
+ gesture,
2780
+ sliderPos = 0.5,
2781
+ onMuteToggle,
2782
+ onSoloToggle,
2783
+ onVolumeChange,
2784
+ onPanChange,
2785
+ onDelete,
2786
+ onSliderChange,
2787
+ levels,
2788
+ accentColor = "#9333EA"
2789
+ }) {
2790
+ const [confirmDelete, setConfirmDelete] = import_react11.default.useState(false);
2791
+ const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2792
+ const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2793
+ const badge = direction === "in" ? "\u2197 Fade in" : "\u2198 Fade out";
2794
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2795
+ "div",
2796
+ {
2797
+ "data-testid": "fade-track-row",
2798
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2799
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2800
+ children: [
2801
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2802
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2803
+ "span",
2804
+ {
2805
+ "data-testid": "fade-direction-badge",
2806
+ className: "text-[10px] font-bold uppercase tracking-wide",
2807
+ style: { color: accentColor },
2808
+ children: badge
2809
+ }
2810
+ ),
2811
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2812
+ "button",
2813
+ {
2814
+ "data-testid": "fade-delete-button",
2815
+ onClick: () => setConfirmDelete(true),
2816
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2817
+ title: "Delete fade",
2818
+ "aria-label": "Delete fade",
2819
+ children: "x"
2820
+ }
2821
+ )
2822
+ ] }),
2823
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2824
+ TrackRow,
2825
+ {
2826
+ track: { id: layer.trackId, name: "", role: layer.role },
2827
+ runtimeState: layer.runtimeState,
2828
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2829
+ drawerOpen: false,
2830
+ drawerTab: "fx",
2831
+ levels,
2832
+ accentColor,
2833
+ contentSlot: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(FadeCaption, { layer, direction, gesture }),
2834
+ onMuteToggle,
2835
+ onSoloToggle,
2836
+ onVolumeChange,
2837
+ onPanChange
2838
+ }
2839
+ ),
2840
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "fade-slider-row", children: [
2841
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2842
+ "span",
2843
+ {
2844
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2845
+ title: leftLabel,
2846
+ children: leftLabel
2847
+ }
2848
+ ),
2849
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2850
+ "input",
2851
+ {
2852
+ type: "range",
2853
+ "data-testid": "fade-slider",
2854
+ min: 0,
2855
+ max: 1,
2856
+ step: 0.01,
2857
+ value: sliderPos,
2858
+ disabled: !onSliderChange,
2859
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2860
+ style: { accentColor },
2861
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2862
+ "aria-label": "Fade position"
2863
+ }
2864
+ ),
2865
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2866
+ "span",
2867
+ {
2868
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2869
+ title: rightLabel,
2870
+ children: rightLabel
2871
+ }
2872
+ )
2873
+ ] }),
2874
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2875
+ ConfirmDialog,
2876
+ {
2877
+ open: confirmDelete,
2878
+ title: "Delete fade?",
2879
+ message: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_jsx_runtime13.Fragment, { children: "This fade track will be permanently removed from this scene. This cannot be undone." }),
2880
+ confirmLabel: "Delete",
2881
+ onConfirm: () => {
2882
+ setConfirmDelete(false);
2883
+ onDelete();
2884
+ },
2885
+ onCancel: () => setConfirmDelete(false),
2886
+ testIdPrefix: "fade-delete-confirm"
2887
+ }
2888
+ )
2889
+ ]
2890
+ }
2891
+ );
2892
+ }
2893
+
2894
+ // src/components/FadeModal.tsx
2895
+ var import_react12 = require("react");
2896
+ var import_jsx_runtime14 = require("react/jsx-runtime");
2897
+ function shortId(dbId) {
2898
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
2899
+ }
2900
+ var normRole = (r) => (r ?? "").toLowerCase().trim();
2901
+ function computeOrphans(from, to, excludeSet) {
2902
+ const bucket = (list) => {
2903
+ const m = /* @__PURE__ */ new Map();
2904
+ for (const t of list) {
2905
+ const k = normRole(t.role);
2906
+ const arr = m.get(k);
2907
+ if (arr) arr.push(t);
2908
+ else m.set(k, [t]);
2909
+ }
2910
+ return m;
2911
+ };
2912
+ const fromByRole = bucket(from);
2913
+ const toByRole = bucket(to);
2914
+ const roles = /* @__PURE__ */ new Set([...fromByRole.keys(), ...toByRole.keys()]);
2915
+ const fadeOut = [];
2916
+ const fadeIn = [];
2917
+ for (const role of roles) {
2918
+ const f = fromByRole.get(role) ?? [];
2919
+ const t = toByRole.get(role) ?? [];
2920
+ const shared = Math.min(f.length, t.length);
2921
+ fadeOut.push(...f.slice(shared));
2922
+ fadeIn.push(...t.slice(shared));
2923
+ }
2924
+ return {
2925
+ fadeOut: fadeOut.filter((x) => !excludeSet.has(x.dbId)),
2926
+ fadeIn: fadeIn.filter((x) => !excludeSet.has(x.dbId))
2927
+ };
2928
+ }
2929
+ function OrphanRow({
2930
+ track,
2931
+ gesture,
2932
+ selected,
2933
+ disabled,
2934
+ onSelect,
2935
+ testId
2936
+ }) {
2937
+ const primary = track.prompt?.trim() || track.name;
2938
+ const meta = [track.role, shortId(track.dbId), gesture].filter(Boolean).join(" \xB7 ");
2939
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
2940
+ "button",
2941
+ {
2942
+ type: "button",
2943
+ role: "radio",
2944
+ "aria-checked": selected,
2945
+ "data-testid": testId,
2946
+ "data-value": track.dbId,
2947
+ onClick: onSelect,
2948
+ disabled,
2949
+ 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"}`,
2950
+ children: [
2951
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
2952
+ meta && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
2953
+ ]
2954
+ }
2955
+ );
2956
+ }
2957
+ function FadeModal({
2958
+ host,
2959
+ open,
2960
+ fromSceneId,
2961
+ toSceneId,
2962
+ fromSceneName,
2963
+ toSceneName,
2964
+ excludeSourceDbIds,
2965
+ onClose,
2966
+ onCreate,
2967
+ testIdPrefix = "fade-modal"
2968
+ }) {
2969
+ const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
2970
+ const [selectedDbId, setSelectedDbId] = (0, import_react12.useState)("");
2971
+ const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2972
+ const [error, setError] = (0, import_react12.useState)(null);
2973
+ const [fromName, setFromName] = (0, import_react12.useState)(null);
2974
+ const [toName, setToName] = (0, import_react12.useState)(null);
2975
+ const cancelRef = (0, import_react12.useRef)(null);
2976
+ const refresh = (0, import_react12.useCallback)(async () => {
2977
+ if (!host.listSceneFamilyTracks) {
2978
+ setLoad({ status: "error", message: "This host does not support fades." });
2979
+ return;
2980
+ }
2981
+ setLoad({ status: "loading" });
2982
+ try {
2983
+ const [from, to, fName, tName] = await Promise.all([
2984
+ host.listSceneFamilyTracks(fromSceneId),
2985
+ host.listSceneFamilyTracks(toSceneId),
2986
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
2987
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2988
+ ]);
2989
+ setFromName(fName);
2990
+ setToName(tName);
2991
+ setLoad({ status: "ready", from, to });
2992
+ } catch (err) {
2993
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2994
+ }
2995
+ }, [host, fromSceneId, toSceneId]);
2996
+ (0, import_react12.useEffect)(() => {
2997
+ if (open) {
2998
+ setError(null);
2999
+ setIsCreating(false);
3000
+ setSelectedDbId("");
3001
+ void refresh();
3002
+ }
3003
+ }, [open, refresh]);
3004
+ const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3005
+ const { fadeOut, fadeIn } = (0, import_react12.useMemo)(
3006
+ () => load.status === "ready" ? computeOrphans(load.from, load.to, excludeSet) : { fadeOut: [], fadeIn: [] },
3007
+ [load, excludeSet]
3008
+ );
3009
+ const allOrphans = (0, import_react12.useMemo)(
3010
+ () => [
3011
+ ...fadeOut.map((t) => ({ track: t, direction: "out" })),
3012
+ ...fadeIn.map((t) => ({ track: t, direction: "in" }))
3013
+ ],
3014
+ [fadeOut, fadeIn]
3015
+ );
3016
+ (0, import_react12.useEffect)(() => {
3017
+ if (!allOrphans.some((o) => o.track.dbId === selectedDbId)) {
3018
+ setSelectedDbId(allOrphans[0]?.track.dbId ?? "");
3019
+ }
3020
+ }, [allOrphans, selectedDbId]);
3021
+ const selected = allOrphans.find((o) => o.track.dbId === selectedDbId) ?? null;
3022
+ const canCreate = !isCreating && !!selected;
3023
+ const handleClose = (0, import_react12.useCallback)(() => {
3024
+ if (!isCreating) onClose();
3025
+ }, [isCreating, onClose]);
3026
+ const handleCreate = (0, import_react12.useCallback)(async () => {
3027
+ if (!selected) return;
3028
+ setIsCreating(true);
3029
+ setError(null);
3030
+ try {
3031
+ await onCreate(
3032
+ { dbId: selected.track.dbId, name: selected.track.name, role: selected.track.role },
3033
+ selected.direction,
3034
+ defaultFadeGesture(selected.track.role)
3035
+ );
3036
+ onClose();
3037
+ } catch (err) {
3038
+ setError(err instanceof Error ? err.message : "Failed to create fade.");
3039
+ setIsCreating(false);
3040
+ }
3041
+ }, [selected, onCreate, onClose]);
3042
+ const fromLabel = fromName ?? fromSceneName ?? null;
3043
+ const toLabel = toName ?? toSceneName ?? null;
3044
+ if (!open) return null;
3045
+ const renderSection = (heading, list, section) => {
3046
+ if (list.length === 0) return null;
3047
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "block", children: [
3048
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: heading }),
3049
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3050
+ "div",
3051
+ {
3052
+ role: "radiogroup",
3053
+ "aria-label": heading,
3054
+ "data-testid": `${testIdPrefix}-${section === "out" ? "fade-out" : "fade-in"}-list`,
3055
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3056
+ children: list.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3057
+ OrphanRow,
3058
+ {
3059
+ track: t,
3060
+ gesture: defaultFadeGesture(t.role),
3061
+ selected: t.dbId === selectedDbId,
3062
+ disabled: isCreating,
3063
+ onSelect: () => setSelectedDbId(t.dbId),
3064
+ testId: `${testIdPrefix}-option-${t.dbId}`
3065
+ },
3066
+ t.dbId
3067
+ ))
3068
+ }
3069
+ )
3070
+ ] });
3071
+ };
3072
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3073
+ "div",
3074
+ {
3075
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
3076
+ onClick: (e) => e.stopPropagation(),
3077
+ "data-testid": `${testIdPrefix}-box`,
3078
+ children: [
3079
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add fade" }),
3080
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3081
+ "Tracks with no counterpart between",
3082
+ " ",
3083
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3084
+ " and",
3085
+ " ",
3086
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3087
+ " can gracefully fade out (leaving) or fade in (entering) across this transition."
3088
+ ] }),
3089
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3090
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3091
+ load.status === "ready" && (allOrphans.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("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__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
3092
+ renderSection(`Fade out${fromLabel ? ` (from ${fromLabel})` : ""}`, fadeOut, "out"),
3093
+ renderSection(`Fade in${toLabel ? ` (to ${toLabel})` : ""}`, fadeIn, "in")
3094
+ ] })),
3095
+ error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3096
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3097
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3098
+ "button",
3099
+ {
3100
+ ref: cancelRef,
3101
+ "data-testid": `${testIdPrefix}-cancel`,
3102
+ onClick: onClose,
3103
+ disabled: isCreating,
3104
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
3105
+ children: "Cancel"
3106
+ }
3107
+ ),
3108
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3109
+ "button",
3110
+ {
3111
+ "data-testid": `${testIdPrefix}-confirm`,
3112
+ onClick: handleCreate,
3113
+ disabled: !canCreate,
3114
+ 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"}`,
3115
+ children: isCreating ? "Generating fade\u2026" : "Create fade"
3116
+ }
3117
+ )
3118
+ ] })
3119
+ ]
3120
+ }
3121
+ ) });
3122
+ }
3123
+
3124
+ // src/components/ImportTrackModal.tsx
3125
+ var import_react13 = require("react");
3126
+ var import_jsx_runtime15 = require("react/jsx-runtime");
2679
3127
  function ImportTrackModal({
2680
3128
  host,
2681
3129
  open,
@@ -2687,10 +3135,10 @@ function ImportTrackModal({
2687
3135
  onPick,
2688
3136
  onPortTrack
2689
3137
  }) {
2690
- const [load, setLoad] = (0, import_react11.useState)({ status: "loading" });
2691
- const [selectedSceneId, setSelectedSceneId] = (0, import_react11.useState)(null);
2692
- const [importingTrackId, setImportingTrackId] = (0, import_react11.useState)(null);
2693
- const refresh = (0, import_react11.useCallback)(async () => {
3138
+ const [load, setLoad] = (0, import_react13.useState)({ status: "loading" });
3139
+ const [selectedSceneId, setSelectedSceneId] = (0, import_react13.useState)(null);
3140
+ const [importingTrackId, setImportingTrackId] = (0, import_react13.useState)(null);
3141
+ const refresh = (0, import_react13.useCallback)(async () => {
2694
3142
  if (!host.listImportableTracks) {
2695
3143
  setLoad({ status: "error", message: "This host does not support importing tracks." });
2696
3144
  return;
@@ -2706,14 +3154,14 @@ function ImportTrackModal({
2706
3154
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2707
3155
  }
2708
3156
  }, [host, mode, onPortTrack]);
2709
- (0, import_react11.useEffect)(() => {
3157
+ (0, import_react13.useEffect)(() => {
2710
3158
  if (open) {
2711
3159
  setSelectedSceneId(null);
2712
3160
  setImportingTrackId(null);
2713
3161
  void refresh();
2714
3162
  }
2715
3163
  }, [open, refresh]);
2716
- const handleImport = (0, import_react11.useCallback)(
3164
+ const handleImport = (0, import_react13.useCallback)(
2717
3165
  async (track, sourceSceneId, sceneName, isSameScene) => {
2718
3166
  if (isSameScene && onPortTrack) {
2719
3167
  if (!track.importable) return;
@@ -2754,16 +3202,16 @@ function ImportTrackModal({
2754
3202
  if (!open) return null;
2755
3203
  const scenes = load.status === "ready" ? load.scenes : [];
2756
3204
  const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2757
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3205
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2758
3206
  "div",
2759
3207
  {
2760
3208
  className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2761
3209
  onClick: (e) => e.stopPropagation(),
2762
3210
  "data-testid": `${testIdPrefix}-modal`,
2763
3211
  children: [
2764
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2765
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2", children: [
2766
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3212
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
3213
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center gap-2", children: [
3214
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2767
3215
  "button",
2768
3216
  {
2769
3217
  className: "text-sas-muted hover:text-sas-accent text-xs",
@@ -2772,9 +3220,9 @@ function ImportTrackModal({
2772
3220
  children: "\u2190"
2773
3221
  }
2774
3222
  ),
2775
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
3223
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2776
3224
  ] }),
2777
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3225
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2778
3226
  "button",
2779
3227
  {
2780
3228
  className: "text-sas-muted hover:text-sas-accent text-sm",
@@ -2784,30 +3232,30 @@ function ImportTrackModal({
2784
3232
  }
2785
3233
  )
2786
3234
  ] }),
2787
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
2788
- load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
2789
- load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
2790
- load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("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." }),
2791
- load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3235
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
3236
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
3237
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
3238
+ load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("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." }),
3239
+ load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2792
3240
  "button",
2793
3241
  {
2794
3242
  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",
2795
3243
  onClick: () => setSelectedSceneId(scene.sceneId),
2796
3244
  "data-testid": `${testIdPrefix}-scene`,
2797
3245
  children: [
2798
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "truncate", children: scene.sceneName }),
2799
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3246
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "truncate", children: scene.sceneName }),
3247
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2800
3248
  scene.tracks.length,
2801
3249
  " \u2192"
2802
3250
  ] })
2803
3251
  ]
2804
3252
  }
2805
3253
  ) }, scene.sceneId)) }),
2806
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
3254
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2807
3255
  const busy = importingTrackId === track.trackId;
2808
3256
  const gated = mode === "track" && !track.importable;
2809
3257
  const disabled = gated || busy;
2810
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3258
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2811
3259
  "button",
2812
3260
  {
2813
3261
  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"}`,
@@ -2817,14 +3265,14 @@ function ImportTrackModal({
2817
3265
  "data-testid": `${testIdPrefix}-track`,
2818
3266
  "data-importable": mode === "sound" || track.importable ? "true" : "false",
2819
3267
  children: [
2820
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "truncate", children: [
3268
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "truncate", children: [
2821
3269
  track.name,
2822
- track.role ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3270
+ track.role ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2823
3271
  " \xB7 ",
2824
3272
  track.role
2825
3273
  ] }) : null
2826
3274
  ] }),
2827
- busy ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sas-muted", children: "\u2298" }) : null
3275
+ busy ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sas-muted", children: "\u2298" }) : null
2828
3276
  ]
2829
3277
  }
2830
3278
  ) }, track.dbId);
@@ -2836,8 +3284,38 @@ function ImportTrackModal({
2836
3284
  }
2837
3285
 
2838
3286
  // src/components/CrossfadeModal.tsx
2839
- var import_react12 = require("react");
2840
- var import_jsx_runtime14 = require("react/jsx-runtime");
3287
+ var import_react14 = require("react");
3288
+ var import_jsx_runtime16 = require("react/jsx-runtime");
3289
+ function shortId2(dbId) {
3290
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3291
+ }
3292
+ function CandidateRow({
3293
+ track,
3294
+ selected,
3295
+ disabled,
3296
+ onSelect,
3297
+ testId
3298
+ }) {
3299
+ const primary = track.prompt?.trim() || track.name;
3300
+ const meta = [track.role, shortId2(track.dbId)].filter(Boolean).join(" \xB7 ");
3301
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3302
+ "button",
3303
+ {
3304
+ type: "button",
3305
+ role: "radio",
3306
+ "aria-checked": selected,
3307
+ "data-testid": testId,
3308
+ "data-value": track.dbId,
3309
+ onClick: onSelect,
3310
+ disabled,
3311
+ 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"}`,
3312
+ children: [
3313
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
3314
+ meta && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
3315
+ ]
3316
+ }
3317
+ );
3318
+ }
2841
3319
  function CrossfadeModal({
2842
3320
  host,
2843
3321
  open,
@@ -2845,34 +3323,40 @@ function CrossfadeModal({
2845
3323
  toSceneId,
2846
3324
  fromSceneName,
2847
3325
  toSceneName,
3326
+ excludeSourceDbIds,
2848
3327
  onClose,
2849
3328
  onCreate,
2850
3329
  testIdPrefix = "crossfade-modal"
2851
3330
  }) {
2852
- const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
2853
- const [originDbId, setOriginDbId] = (0, import_react12.useState)("");
2854
- const [targetDbId, setTargetDbId] = (0, import_react12.useState)("");
2855
- const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2856
- const [error, setError] = (0, import_react12.useState)(null);
2857
- const cancelRef = (0, import_react12.useRef)(null);
2858
- const refresh = (0, import_react12.useCallback)(async () => {
3331
+ const [load, setLoad] = (0, import_react14.useState)({ status: "loading" });
3332
+ const [originDbId, setOriginDbId] = (0, import_react14.useState)("");
3333
+ const [targetDbId, setTargetDbId] = (0, import_react14.useState)("");
3334
+ const [isCreating, setIsCreating] = (0, import_react14.useState)(false);
3335
+ const [error, setError] = (0, import_react14.useState)(null);
3336
+ const [fromName, setFromName] = (0, import_react14.useState)(null);
3337
+ const [toName, setToName] = (0, import_react14.useState)(null);
3338
+ const cancelRef = (0, import_react14.useRef)(null);
3339
+ const refresh = (0, import_react14.useCallback)(async () => {
2859
3340
  if (!host.listSceneFamilyTracks) {
2860
3341
  setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2861
3342
  return;
2862
3343
  }
2863
3344
  setLoad({ status: "loading" });
2864
3345
  try {
2865
- const [origin, target] = await Promise.all([
3346
+ const [origin, target, fName, tName] = await Promise.all([
2866
3347
  host.listSceneFamilyTracks(fromSceneId),
2867
- host.listSceneFamilyTracks(toSceneId)
3348
+ host.listSceneFamilyTracks(toSceneId),
3349
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3350
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2868
3351
  ]);
3352
+ setFromName(fName);
3353
+ setToName(tName);
2869
3354
  setLoad({ status: "ready", origin, target });
2870
- setOriginDbId(origin[0]?.dbId ?? "");
2871
3355
  } catch (err) {
2872
3356
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2873
3357
  }
2874
3358
  }, [host, fromSceneId, toSceneId]);
2875
- (0, import_react12.useEffect)(() => {
3359
+ (0, import_react14.useEffect)(() => {
2876
3360
  if (open) {
2877
3361
  setError(null);
2878
3362
  setIsCreating(false);
@@ -2881,27 +3365,32 @@ function CrossfadeModal({
2881
3365
  void refresh();
2882
3366
  }
2883
3367
  }, [open, refresh]);
2884
- const originTrack = (0, import_react12.useMemo)(
2885
- () => load.status === "ready" ? load.origin.find((t) => t.dbId === originDbId) ?? null : null,
2886
- [load, originDbId]
3368
+ const excludeSet = (0, import_react14.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3369
+ const originCandidates = (0, import_react14.useMemo)(
3370
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
3371
+ [load, excludeSet]
2887
3372
  );
2888
- const originRole = originTrack?.role;
2889
- const targetCandidates = (0, import_react12.useMemo)(() => {
2890
- if (load.status !== "ready") return [];
2891
- if (!originRole) return load.target;
2892
- return load.target.filter((t) => t.role === originRole);
2893
- }, [load, originRole]);
2894
- (0, import_react12.useEffect)(() => {
3373
+ const targetCandidates = (0, import_react14.useMemo)(
3374
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
3375
+ [load, excludeSet]
3376
+ );
3377
+ (0, import_react14.useEffect)(() => {
3378
+ if (!originCandidates.some((t) => t.dbId === originDbId)) {
3379
+ setOriginDbId(originCandidates[0]?.dbId ?? "");
3380
+ }
3381
+ }, [originCandidates, originDbId]);
3382
+ (0, import_react14.useEffect)(() => {
2895
3383
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2896
3384
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2897
3385
  }
2898
3386
  }, [targetCandidates, targetDbId]);
3387
+ const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2899
3388
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2900
3389
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2901
- const handleClose = (0, import_react12.useCallback)(() => {
3390
+ const handleClose = (0, import_react14.useCallback)(() => {
2902
3391
  if (!isCreating) onClose();
2903
3392
  }, [isCreating, onClose]);
2904
- const handleCreate = (0, import_react12.useCallback)(async () => {
3393
+ const handleCreate = (0, import_react14.useCallback)(async () => {
2905
3394
  if (!originTrack || !targetTrack) return;
2906
3395
  setIsCreating(true);
2907
3396
  setError(null);
@@ -2916,79 +3405,100 @@ function CrossfadeModal({
2916
3405
  setIsCreating(false);
2917
3406
  }
2918
3407
  }, [originTrack, targetTrack, onCreate, onClose]);
3408
+ const fromLabel = fromName ?? fromSceneName ?? null;
3409
+ const toLabel = toName ?? toSceneName ?? null;
2919
3410
  if (!open) return null;
2920
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3411
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2921
3412
  "div",
2922
3413
  {
2923
3414
  className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2924
3415
  onClick: (e) => e.stopPropagation(),
2925
3416
  "data-testid": `${testIdPrefix}-box`,
2926
3417
  children: [
2927
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2928
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3418
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
3419
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2929
3420
  "Bridge a track from",
2930
3421
  " ",
2931
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromSceneName ?? "the origin scene" }),
3422
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2932
3423
  " into one from",
2933
3424
  " ",
2934
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toSceneName ?? "the target scene" }),
3425
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2935
3426
  ". Both layers share one generated part; each keeps its own preset."
2936
3427
  ] }),
2937
- load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2938
- load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2939
- load.status === "ready" && (load.origin.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3428
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3429
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3430
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2940
3431
  "div",
2941
3432
  {
2942
3433
  className: "text-xs text-sas-muted py-4 text-center",
2943
3434
  "data-testid": `${testIdPrefix}-empty-origin`,
2944
- children: "No matching tracks in the origin scene. Add one there first."
3435
+ children: [
3436
+ "No available tracks in ",
3437
+ fromLabel ?? "the origin scene",
3438
+ ". Add one (or free one from another crossfade) first."
3439
+ ]
2945
3440
  }
2946
- ) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2947
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2948
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "Origin (top)" }),
2949
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2950
- "select",
3441
+ ) : /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
3442
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3443
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3444
+ "Origin ",
3445
+ fromLabel ? `(${fromLabel})` : "(top)"
3446
+ ] }),
3447
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3448
+ "div",
2951
3449
  {
2952
- "data-testid": `${testIdPrefix}-origin-select`,
2953
- value: originDbId,
2954
- onChange: (e) => setOriginDbId(e.target.value),
2955
- disabled: isCreating,
2956
- className: "sas-input w-full mt-0.5 text-xs",
2957
- children: load.origin.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2958
- t.name,
2959
- t.role ? ` \xB7 ${t.role}` : ""
2960
- ] }, t.dbId))
3450
+ role: "radiogroup",
3451
+ "aria-label": "Origin track",
3452
+ "data-testid": `${testIdPrefix}-origin-list`,
3453
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3454
+ children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3455
+ CandidateRow,
3456
+ {
3457
+ track: t,
3458
+ selected: t.dbId === originDbId,
3459
+ disabled: isCreating,
3460
+ onSelect: () => setOriginDbId(t.dbId),
3461
+ testId: `${testIdPrefix}-origin-option-${t.dbId}`
3462
+ },
3463
+ t.dbId
3464
+ ))
2961
3465
  }
2962
3466
  )
2963
3467
  ] }),
2964
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2965
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2966
- "Target (bottom)",
2967
- originRole ? ` \xB7 ${originRole}` : ""
3468
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3469
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3470
+ "Target ",
3471
+ toLabel ? `(${toLabel})` : "(bottom)"
2968
3472
  ] }),
2969
- targetCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2970
- "No ",
2971
- originRole ?? "matching",
2972
- " track in the target scene to crossfade into."
2973
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2974
- "select",
3473
+ targetCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
3474
+ "No available tracks in ",
3475
+ toLabel ?? "the target scene",
3476
+ " to crossfade into."
3477
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3478
+ "div",
2975
3479
  {
2976
- "data-testid": `${testIdPrefix}-target-select`,
2977
- value: targetDbId,
2978
- onChange: (e) => setTargetDbId(e.target.value),
2979
- disabled: isCreating,
2980
- className: "sas-input w-full mt-0.5 text-xs",
2981
- children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2982
- t.name,
2983
- t.role ? ` \xB7 ${t.role}` : ""
2984
- ] }, t.dbId))
3480
+ role: "radiogroup",
3481
+ "aria-label": "Target track",
3482
+ "data-testid": `${testIdPrefix}-target-list`,
3483
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3484
+ children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3485
+ CandidateRow,
3486
+ {
3487
+ track: t,
3488
+ selected: t.dbId === targetDbId,
3489
+ disabled: isCreating,
3490
+ onSelect: () => setTargetDbId(t.dbId),
3491
+ testId: `${testIdPrefix}-target-option-${t.dbId}`
3492
+ },
3493
+ t.dbId
3494
+ ))
2985
3495
  }
2986
3496
  )
2987
3497
  ] })
2988
3498
  ] })),
2989
- error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
2990
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
2991
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3499
+ error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3500
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3501
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
2992
3502
  "button",
2993
3503
  {
2994
3504
  ref: cancelRef,
@@ -2999,7 +3509,7 @@ function CrossfadeModal({
2999
3509
  children: "Cancel"
3000
3510
  }
3001
3511
  ),
3002
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3512
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3003
3513
  "button",
3004
3514
  {
3005
3515
  "data-testid": `${testIdPrefix}-confirm`,
@@ -3016,8 +3526,8 @@ function CrossfadeModal({
3016
3526
  }
3017
3527
 
3018
3528
  // src/components/DownloadPackButton.tsx
3019
- var import_react13 = require("react");
3020
- var import_jsx_runtime15 = require("react/jsx-runtime");
3529
+ var import_react15 = require("react");
3530
+ var import_jsx_runtime17 = require("react/jsx-runtime");
3021
3531
  function formatSize(bytes) {
3022
3532
  if (!bytes || bytes <= 0) return "";
3023
3533
  const gb = bytes / 1024 ** 3;
@@ -3033,10 +3543,10 @@ var DownloadPackButton = ({
3033
3543
  variant = "compact",
3034
3544
  onDownloadComplete
3035
3545
  }) => {
3036
- const [status, setStatus] = (0, import_react13.useState)("idle");
3037
- const [progress, setProgress] = (0, import_react13.useState)(0);
3038
- const [errorMessage, setErrorMessage] = (0, import_react13.useState)(null);
3039
- (0, import_react13.useEffect)(() => {
3546
+ const [status, setStatus] = (0, import_react15.useState)("idle");
3547
+ const [progress, setProgress] = (0, import_react15.useState)(0);
3548
+ const [errorMessage, setErrorMessage] = (0, import_react15.useState)(null);
3549
+ (0, import_react15.useEffect)(() => {
3040
3550
  const unsub = host.onSamplePackProgress(packId, (p) => {
3041
3551
  setStatus(p.status);
3042
3552
  setProgress(p.progress);
@@ -3051,7 +3561,7 @@ var DownloadPackButton = ({
3051
3561
  });
3052
3562
  return unsub;
3053
3563
  }, [host, packId, onDownloadComplete]);
3054
- const handleClick = (0, import_react13.useCallback)(async () => {
3564
+ const handleClick = (0, import_react15.useCallback)(async () => {
3055
3565
  if (status !== "idle" && status !== "error") return;
3056
3566
  try {
3057
3567
  setStatus("downloading");
@@ -3105,8 +3615,8 @@ var DownloadPackButton = ({
3105
3615
  } else {
3106
3616
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3107
3617
  }
3108
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
3109
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
3618
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { children: [
3619
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3110
3620
  "button",
3111
3621
  {
3112
3622
  "data-testid": `download-pack-button-${packId}`,
@@ -3117,12 +3627,12 @@ var DownloadPackButton = ({
3117
3627
  children: buttonLabel
3118
3628
  }
3119
3629
  ),
3120
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3630
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3121
3631
  ] });
3122
3632
  };
3123
3633
 
3124
3634
  // src/components/SamplePackCTACard.tsx
3125
- var import_jsx_runtime16 = require("react/jsx-runtime");
3635
+ var import_jsx_runtime18 = require("react/jsx-runtime");
3126
3636
  var SamplePackCTACard = ({
3127
3637
  host,
3128
3638
  pack,
@@ -3130,7 +3640,7 @@ var SamplePackCTACard = ({
3130
3640
  onDownloadComplete
3131
3641
  }) => {
3132
3642
  if (status === "checking") {
3133
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3643
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3134
3644
  "div",
3135
3645
  {
3136
3646
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -3141,16 +3651,16 @@ var SamplePackCTACard = ({
3141
3651
  }
3142
3652
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3143
3653
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3144
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3654
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3145
3655
  "div",
3146
3656
  {
3147
3657
  "data-testid": `sample-pack-cta-${pack.packId}`,
3148
3658
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3149
3659
  children: [
3150
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3151
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3152
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3153
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3660
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3661
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3662
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3663
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3154
3664
  DownloadPackButton,
3155
3665
  {
3156
3666
  host,
@@ -3167,7 +3677,7 @@ var SamplePackCTACard = ({
3167
3677
  };
3168
3678
 
3169
3679
  // src/components/WaveformView.tsx
3170
- var import_react14 = require("react");
3680
+ var import_react16 = require("react");
3171
3681
 
3172
3682
  // src/components/waveform.ts
3173
3683
  function computePeaks(audioBuffer, bins, targetSamples) {
@@ -3230,7 +3740,7 @@ function drawWaveform(canvas, peaks, options = {}) {
3230
3740
  }
3231
3741
 
3232
3742
  // src/components/WaveformView.tsx
3233
- var import_jsx_runtime17 = require("react/jsx-runtime");
3743
+ var import_jsx_runtime19 = require("react/jsx-runtime");
3234
3744
  var WaveformView = ({
3235
3745
  host,
3236
3746
  filePath,
@@ -3239,9 +3749,9 @@ var WaveformView = ({
3239
3749
  fillStyle,
3240
3750
  targetSamples
3241
3751
  }) => {
3242
- const canvasRef = (0, import_react14.useRef)(null);
3243
- const [peaks, setPeaks] = (0, import_react14.useState)(null);
3244
- (0, import_react14.useEffect)(() => {
3752
+ const canvasRef = (0, import_react16.useRef)(null);
3753
+ const [peaks, setPeaks] = (0, import_react16.useState)(null);
3754
+ (0, import_react16.useEffect)(() => {
3245
3755
  let cancelled = false;
3246
3756
  let audioContext = null;
3247
3757
  (async () => {
@@ -3267,7 +3777,7 @@ var WaveformView = ({
3267
3777
  cancelled = true;
3268
3778
  };
3269
3779
  }, [host, filePath, bins, targetSamples]);
3270
- (0, import_react14.useEffect)(() => {
3780
+ (0, import_react16.useEffect)(() => {
3271
3781
  if (!peaks) return;
3272
3782
  const canvas = canvasRef.current;
3273
3783
  if (!canvas) return;
@@ -3278,7 +3788,7 @@ var WaveformView = ({
3278
3788
  observer.observe(canvas);
3279
3789
  return () => observer.disconnect();
3280
3790
  }, [peaks, fillStyle]);
3281
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3791
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
3282
3792
  "canvas",
3283
3793
  {
3284
3794
  ref: canvasRef,
@@ -3289,8 +3799,8 @@ var WaveformView = ({
3289
3799
  };
3290
3800
 
3291
3801
  // src/components/ScrollingWaveform.tsx
3292
- var import_react15 = require("react");
3293
- var import_jsx_runtime18 = require("react/jsx-runtime");
3802
+ var import_react17 = require("react");
3803
+ var import_jsx_runtime20 = require("react/jsx-runtime");
3294
3804
  var ScrollingWaveform = ({
3295
3805
  getPeakDb,
3296
3806
  active,
@@ -3298,11 +3808,11 @@ var ScrollingWaveform = ({
3298
3808
  className,
3299
3809
  fillStyle
3300
3810
  }) => {
3301
- const canvasRef = (0, import_react15.useRef)(null);
3302
- const ringRef = (0, import_react15.useRef)(new Float32Array(columns));
3303
- const writeIdxRef = (0, import_react15.useRef)(0);
3304
- const rafRef = (0, import_react15.useRef)(null);
3305
- (0, import_react15.useEffect)(() => {
3811
+ const canvasRef = (0, import_react17.useRef)(null);
3812
+ const ringRef = (0, import_react17.useRef)(new Float32Array(columns));
3813
+ const writeIdxRef = (0, import_react17.useRef)(0);
3814
+ const rafRef = (0, import_react17.useRef)(null);
3815
+ (0, import_react17.useEffect)(() => {
3306
3816
  if (ringRef.current.length !== columns) {
3307
3817
  const next = new Float32Array(columns);
3308
3818
  const prev = ringRef.current;
@@ -3314,7 +3824,7 @@ var ScrollingWaveform = ({
3314
3824
  writeIdxRef.current = writeIdxRef.current % columns;
3315
3825
  }
3316
3826
  }, [columns]);
3317
- (0, import_react15.useEffect)(() => {
3827
+ (0, import_react17.useEffect)(() => {
3318
3828
  if (!active) {
3319
3829
  if (rafRef.current !== null) {
3320
3830
  cancelAnimationFrame(rafRef.current);
@@ -3366,7 +3876,7 @@ var ScrollingWaveform = ({
3366
3876
  }
3367
3877
  };
3368
3878
  }, [active, getPeakDb, fillStyle]);
3369
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3879
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
3370
3880
  "canvas",
3371
3881
  {
3372
3882
  ref: canvasRef,
@@ -3377,8 +3887,8 @@ var ScrollingWaveform = ({
3377
3887
  };
3378
3888
 
3379
3889
  // src/components/OffsetScrubber.tsx
3380
- var import_react16 = require("react");
3381
- var import_jsx_runtime19 = require("react/jsx-runtime");
3890
+ var import_react18 = require("react");
3891
+ var import_jsx_runtime21 = require("react/jsx-runtime");
3382
3892
  var SLIDER_HEIGHT_PX = 28;
3383
3893
  var TICK_HEIGHT_PX = 14;
3384
3894
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -3391,40 +3901,40 @@ function OffsetScrubber({
3391
3901
  onChange,
3392
3902
  disabled = false
3393
3903
  }) {
3394
- const trackRef = (0, import_react16.useRef)(null);
3395
- const [draftOffset, setDraftOffset] = (0, import_react16.useState)(offsetSamples);
3396
- const [isDragging, setIsDragging] = (0, import_react16.useState)(false);
3397
- (0, import_react16.useEffect)(() => {
3904
+ const trackRef = (0, import_react18.useRef)(null);
3905
+ const [draftOffset, setDraftOffset] = (0, import_react18.useState)(offsetSamples);
3906
+ const [isDragging, setIsDragging] = (0, import_react18.useState)(false);
3907
+ (0, import_react18.useEffect)(() => {
3398
3908
  if (!isDragging) setDraftOffset(offsetSamples);
3399
3909
  }, [offsetSamples, isDragging]);
3400
3910
  const sampleRate = cuePoints?.sample_rate ?? 44100;
3401
3911
  const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3402
- const beatsForRange = (0, import_react16.useMemo)(() => {
3912
+ const beatsForRange = (0, import_react18.useMemo)(() => {
3403
3913
  return Math.round(60 / projectBpm * sampleRate);
3404
3914
  }, [projectBpm, sampleRate]);
3405
3915
  const rangeSamples = beatsForRange * meter;
3406
- const sampleToFraction = (0, import_react16.useCallback)(
3916
+ const sampleToFraction = (0, import_react18.useCallback)(
3407
3917
  (sample) => {
3408
3918
  const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3409
3919
  return (clamped + rangeSamples) / (2 * rangeSamples);
3410
3920
  },
3411
3921
  [rangeSamples]
3412
3922
  );
3413
- const fractionToSample = (0, import_react16.useCallback)(
3923
+ const fractionToSample = (0, import_react18.useCallback)(
3414
3924
  (fraction) => {
3415
3925
  const clamped = Math.max(0, Math.min(1, fraction));
3416
3926
  return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3417
3927
  },
3418
3928
  [rangeSamples]
3419
3929
  );
3420
- const snapTargets = (0, import_react16.useMemo)(() => {
3930
+ const snapTargets = (0, import_react18.useMemo)(() => {
3421
3931
  if (!cuePoints || cuePoints.beats.length === 0) return [];
3422
3932
  const downbeat = cuePoints.beats[0];
3423
3933
  const positives = cuePoints.beats.map((b) => b - downbeat);
3424
3934
  const negatives = positives.slice(1).map((p) => -p);
3425
3935
  return [...negatives, ...positives].sort((a, b) => a - b);
3426
3936
  }, [cuePoints]);
3427
- const snapToBeat = (0, import_react16.useCallback)(
3937
+ const snapToBeat = (0, import_react18.useCallback)(
3428
3938
  (sample) => {
3429
3939
  if (snapTargets.length === 0) return sample;
3430
3940
  let best = snapTargets[0];
@@ -3440,7 +3950,7 @@ function OffsetScrubber({
3440
3950
  },
3441
3951
  [snapTargets]
3442
3952
  );
3443
- const handlePointerDown = (0, import_react16.useCallback)(
3953
+ const handlePointerDown = (0, import_react18.useCallback)(
3444
3954
  (e) => {
3445
3955
  if (disabled || !cuePoints) return;
3446
3956
  e.preventDefault();
@@ -3474,7 +3984,7 @@ function OffsetScrubber({
3474
3984
  },
3475
3985
  [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3476
3986
  );
3477
- const handleResetToZero = (0, import_react16.useCallback)(() => {
3987
+ const handleResetToZero = (0, import_react18.useCallback)(() => {
3478
3988
  if (disabled) return;
3479
3989
  setDraftOffset(0);
3480
3990
  onChange(0);
@@ -3482,7 +3992,7 @@ function OffsetScrubber({
3482
3992
  const thumbFraction = sampleToFraction(draftOffset);
3483
3993
  const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3484
3994
  const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3485
- const ticks = (0, import_react16.useMemo)(() => {
3995
+ const ticks = (0, import_react18.useMemo)(() => {
3486
3996
  if (!cuePoints) return [];
3487
3997
  const downbeat = cuePoints.beats[0] ?? 0;
3488
3998
  return cuePoints.beats.map((b, i) => {
@@ -3493,9 +4003,9 @@ function OffsetScrubber({
3493
4003
  });
3494
4004
  }, [cuePoints, sampleToFraction]);
3495
4005
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3496
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3497
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3498
- /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
4006
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4007
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4008
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
3499
4009
  "div",
3500
4010
  {
3501
4011
  ref: trackRef,
@@ -3511,7 +4021,7 @@ function OffsetScrubber({
3511
4021
  "aria-valuenow": draftOffset,
3512
4022
  "aria-disabled": isDisabled,
3513
4023
  children: [
3514
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4024
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3515
4025
  "div",
3516
4026
  {
3517
4027
  "aria-hidden": "true",
@@ -3519,7 +4029,7 @@ function OffsetScrubber({
3519
4029
  style: { left: "50%" }
3520
4030
  }
3521
4031
  ),
3522
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4032
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3523
4033
  "div",
3524
4034
  {
3525
4035
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -3534,7 +4044,7 @@ function OffsetScrubber({
3534
4044
  },
3535
4045
  t.i
3536
4046
  )),
3537
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4047
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3538
4048
  "div",
3539
4049
  {
3540
4050
  "data-testid": "offset-scrubber-thumb",
@@ -3551,7 +4061,7 @@ function OffsetScrubber({
3551
4061
  ]
3552
4062
  }
3553
4063
  ),
3554
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4064
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3555
4065
  "span",
3556
4066
  {
3557
4067
  "data-testid": "offset-scrubber-readout",
@@ -3559,7 +4069,7 @@ function OffsetScrubber({
3559
4069
  children: formatOffset(draftOffset, sampleRate)
3560
4070
  }
3561
4071
  ),
3562
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4072
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3563
4073
  "button",
3564
4074
  {
3565
4075
  type: "button",
@@ -3571,7 +4081,7 @@ function OffsetScrubber({
3571
4081
  children: "\u2316"
3572
4082
  }
3573
4083
  ),
3574
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4084
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3575
4085
  "span",
3576
4086
  {
3577
4087
  "data-testid": "offset-bpm-mismatch",
@@ -3643,13 +4153,13 @@ function synthesizeCuePoints({
3643
4153
  }
3644
4154
 
3645
4155
  // src/hooks/useSceneState.ts
3646
- var import_react17 = require("react");
4156
+ var import_react19 = require("react");
3647
4157
  function useSceneState(activeSceneId, initialValue) {
3648
- const [stateMap, setStateMap] = (0, import_react17.useState)(() => /* @__PURE__ */ new Map());
3649
- const activeSceneIdRef = (0, import_react17.useRef)(activeSceneId);
4158
+ const [stateMap, setStateMap] = (0, import_react19.useState)(() => /* @__PURE__ */ new Map());
4159
+ const activeSceneIdRef = (0, import_react19.useRef)(activeSceneId);
3650
4160
  activeSceneIdRef.current = activeSceneId;
3651
4161
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
3652
- const setForCurrentScene = (0, import_react17.useCallback)((value) => {
4162
+ const setForCurrentScene = (0, import_react19.useCallback)((value) => {
3653
4163
  const sid = activeSceneIdRef.current;
3654
4164
  if (sid === null) return;
3655
4165
  setStateMap((prev) => {
@@ -3660,7 +4170,7 @@ function useSceneState(activeSceneId, initialValue) {
3660
4170
  return newMap;
3661
4171
  });
3662
4172
  }, [initialValue]);
3663
- const setForScene = (0, import_react17.useCallback)((sceneId, value) => {
4173
+ const setForScene = (0, import_react19.useCallback)((sceneId, value) => {
3664
4174
  setStateMap((prev) => {
3665
4175
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
3666
4176
  const next = typeof value === "function" ? value(current) : value;
@@ -3673,10 +4183,10 @@ function useSceneState(activeSceneId, initialValue) {
3673
4183
  }
3674
4184
 
3675
4185
  // src/hooks/useAnySolo.ts
3676
- var import_react18 = require("react");
4186
+ var import_react20 = require("react");
3677
4187
  function useAnySolo(host) {
3678
- const [anySolo, setAnySolo] = (0, import_react18.useState)(false);
3679
- (0, import_react18.useEffect)(() => {
4188
+ const [anySolo, setAnySolo] = (0, import_react20.useState)(false);
4189
+ (0, import_react20.useEffect)(() => {
3680
4190
  let active = true;
3681
4191
  const refresh = () => {
3682
4192
  host.isAnySoloActive().then((v) => {
@@ -3695,7 +4205,7 @@ function useAnySolo(host) {
3695
4205
  }
3696
4206
 
3697
4207
  // src/hooks/useSoundHistory.ts
3698
- var import_react19 = require("react");
4208
+ var import_react21 = require("react");
3699
4209
  var EMPTY = { entries: [], cursor: -1 };
3700
4210
  function sameDescriptor(a, b) {
3701
4211
  if (a === b) return true;
@@ -3707,14 +4217,14 @@ function sameDescriptor(a, b) {
3707
4217
  }
3708
4218
  function useSoundHistory(applySound, opts = {}) {
3709
4219
  const max = Math.max(2, opts.max ?? 24);
3710
- const applyRef = (0, import_react19.useRef)(applySound);
4220
+ const applyRef = (0, import_react21.useRef)(applySound);
3711
4221
  applyRef.current = applySound;
3712
- const onChangeRef = (0, import_react19.useRef)(opts.onChange);
4222
+ const onChangeRef = (0, import_react21.useRef)(opts.onChange);
3713
4223
  onChangeRef.current = opts.onChange;
3714
- const dataRef = (0, import_react19.useRef)({});
3715
- const [, setVersion] = (0, import_react19.useState)(0);
3716
- const bump = (0, import_react19.useCallback)(() => setVersion((v) => v + 1), []);
3717
- const commit = (0, import_react19.useCallback)(
4224
+ const dataRef = (0, import_react21.useRef)({});
4225
+ const [, setVersion] = (0, import_react21.useState)(0);
4226
+ const bump = (0, import_react21.useCallback)(() => setVersion((v) => v + 1), []);
4227
+ const commit = (0, import_react21.useCallback)(
3718
4228
  (trackId, next, notify) => {
3719
4229
  dataRef.current = { ...dataRef.current, [trackId]: next };
3720
4230
  bump();
@@ -3722,7 +4232,7 @@ function useSoundHistory(applySound, opts = {}) {
3722
4232
  },
3723
4233
  [bump]
3724
4234
  );
3725
- const record = (0, import_react19.useCallback)(
4235
+ const record = (0, import_react21.useCallback)(
3726
4236
  (trackId, descriptor, label) => {
3727
4237
  const h = dataRef.current[trackId];
3728
4238
  const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
@@ -3737,7 +4247,7 @@ function useSoundHistory(applySound, opts = {}) {
3737
4247
  },
3738
4248
  [max, commit]
3739
4249
  );
3740
- const restoreTo = (0, import_react19.useCallback)(
4250
+ const restoreTo = (0, import_react21.useCallback)(
3741
4251
  async (trackId, index) => {
3742
4252
  const h = dataRef.current[trackId];
3743
4253
  if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
@@ -3747,7 +4257,7 @@ function useSoundHistory(applySound, opts = {}) {
3747
4257
  },
3748
4258
  [commit]
3749
4259
  );
3750
- const undo = (0, import_react19.useCallback)(
4260
+ const undo = (0, import_react21.useCallback)(
3751
4261
  (trackId) => {
3752
4262
  const h = dataRef.current[trackId];
3753
4263
  if (!h || h.cursor <= 0) return Promise.resolve(false);
@@ -3755,7 +4265,7 @@ function useSoundHistory(applySound, opts = {}) {
3755
4265
  },
3756
4266
  [restoreTo]
3757
4267
  );
3758
- const toggleFavorite = (0, import_react19.useCallback)(
4268
+ const toggleFavorite = (0, import_react21.useCallback)(
3759
4269
  (trackId, index) => {
3760
4270
  const h = dataRef.current[trackId];
3761
4271
  if (!h || index < 0 || index >= h.entries.length) return;
@@ -3764,7 +4274,7 @@ function useSoundHistory(applySound, opts = {}) {
3764
4274
  },
3765
4275
  [commit]
3766
4276
  );
3767
- const restore = (0, import_react19.useCallback)(
4277
+ const restore = (0, import_react21.useCallback)(
3768
4278
  (trackId, state) => {
3769
4279
  const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3770
4280
  const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
@@ -3773,15 +4283,15 @@ function useSoundHistory(applySound, opts = {}) {
3773
4283
  },
3774
4284
  [commit]
3775
4285
  );
3776
- const list = (0, import_react19.useCallback)(
4286
+ const list = (0, import_react21.useCallback)(
3777
4287
  (trackId) => dataRef.current[trackId] ?? EMPTY,
3778
4288
  []
3779
4289
  );
3780
- const canUndo = (0, import_react19.useCallback)((trackId) => {
4290
+ const canUndo = (0, import_react21.useCallback)((trackId) => {
3781
4291
  const h = dataRef.current[trackId];
3782
4292
  return !!h && h.cursor > 0;
3783
4293
  }, []);
3784
- const clear = (0, import_react19.useCallback)(
4294
+ const clear = (0, import_react21.useCallback)(
3785
4295
  (trackId) => {
3786
4296
  if (dataRef.current[trackId]) {
3787
4297
  const next = { ...dataRef.current };
@@ -3793,18 +4303,18 @@ function useSoundHistory(applySound, opts = {}) {
3793
4303
  },
3794
4304
  [bump]
3795
4305
  );
3796
- const reset = (0, import_react19.useCallback)(() => {
4306
+ const reset = (0, import_react21.useCallback)(() => {
3797
4307
  dataRef.current = {};
3798
4308
  bump();
3799
4309
  }, [bump]);
3800
- return (0, import_react19.useMemo)(
4310
+ return (0, import_react21.useMemo)(
3801
4311
  () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3802
4312
  [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3803
4313
  );
3804
4314
  }
3805
4315
 
3806
4316
  // src/hooks/useTrackReorder.ts
3807
- var import_react20 = require("react");
4317
+ var import_react22 = require("react");
3808
4318
  function moveItem(arr, from, to) {
3809
4319
  const next = arr.slice();
3810
4320
  if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
@@ -3821,12 +4331,12 @@ function useTrackReorder({
3821
4331
  getId,
3822
4332
  onError
3823
4333
  }) {
3824
- const [draggingIndex, setDraggingIndex] = (0, import_react20.useState)(null);
3825
- const [dragOverIndex, setDragOverIndex] = (0, import_react20.useState)(null);
3826
- const fromRef = (0, import_react20.useRef)(null);
3827
- const itemsRef = (0, import_react20.useRef)(items);
4334
+ const [draggingIndex, setDraggingIndex] = (0, import_react22.useState)(null);
4335
+ const [dragOverIndex, setDragOverIndex] = (0, import_react22.useState)(null);
4336
+ const fromRef = (0, import_react22.useRef)(null);
4337
+ const itemsRef = (0, import_react22.useRef)(items);
3828
4338
  itemsRef.current = items;
3829
- const dragPropsFor = (0, import_react20.useCallback)(
4339
+ const dragPropsFor = (0, import_react22.useCallback)(
3830
4340
  (index) => ({
3831
4341
  handleProps: {
3832
4342
  draggable: true,
@@ -3888,7 +4398,7 @@ function useTrackReorder({
3888
4398
  }
3889
4399
 
3890
4400
  // src/constants/sdk-version.ts
3891
- var PLUGIN_SDK_VERSION = "2.25.0";
4401
+ var PLUGIN_SDK_VERSION = "2.28.0";
3892
4402
 
3893
4403
  // src/utils/format-concurrent-tracks.ts
3894
4404
  function formatConcurrentTracks(ctx) {
@@ -4049,6 +4559,8 @@ function pickTopKWeighted(scored, options = {}) {
4049
4559
  FX_DISPLAY_LABELS,
4050
4560
  FX_ENGINE_PLUGIN_NAMES,
4051
4561
  FX_PRESET_CONFIGS,
4562
+ FadeModal,
4563
+ FadeTrackRow,
4052
4564
  FxToggleBar,
4053
4565
  GUTTER_W,
4054
4566
  ImportTrackModal,
@@ -4067,6 +4579,7 @@ function pickTopKWeighted(scored, options = {}) {
4067
4579
  SamplePackCTACard,
4068
4580
  ScrollingWaveform,
4069
4581
  SorceryProgressBar,
4582
+ TEXTURAL_ROLES,
4070
4583
  TrackDrawer,
4071
4584
  TrackMeterStrip,
4072
4585
  TrackRow,
@@ -4074,17 +4587,21 @@ function pickTopKWeighted(scored, options = {}) {
4074
4587
  WaveformView,
4075
4588
  analyzeWavPeak,
4076
4589
  asCrossfadeMeta,
4590
+ asFadeMeta,
4077
4591
  buildCrossfadeInpaintPrompt,
4078
4592
  buildCrossfadeVolumeCurves,
4593
+ buildFadeVolumeCurve,
4079
4594
  calculateTimeBasedTarget,
4080
4595
  cellToPx,
4081
4596
  centerScrollTop,
4082
4597
  computePeaks,
4083
4598
  dbToSlider,
4599
+ defaultFadeGesture,
4084
4600
  drawWaveform,
4085
4601
  formatConcurrentTracks,
4086
4602
  moveItem,
4087
4603
  parseCrossfadePairs,
4604
+ parseFades,
4088
4605
  pickTopKWeighted,
4089
4606
  pitchToName,
4090
4607
  pxToCell,