@signalsandsorcery/plugin-sdk 2.26.1 → 2.34.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
@@ -30,6 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AUDIO_EFFECTS: () => AUDIO_EFFECTS,
34
+ AUDIO_EFFECT_LABEL: () => AUDIO_EFFECT_LABEL,
33
35
  ConfirmDialog: () => ConfirmDialog,
34
36
  CrossfadeModal: () => CrossfadeModal,
35
37
  CrossfadeTrackRow: () => CrossfadeTrackRow,
@@ -47,6 +49,8 @@ __export(index_exports, {
47
49
  FX_DISPLAY_LABELS: () => FX_DISPLAY_LABELS,
48
50
  FX_ENGINE_PLUGIN_NAMES: () => FX_ENGINE_PLUGIN_NAMES,
49
51
  FX_PRESET_CONFIGS: () => FX_PRESET_CONFIGS,
52
+ FadeModal: () => FadeModal,
53
+ FadeTrackRow: () => FadeTrackRow,
50
54
  FxToggleBar: () => FxToggleBar,
51
55
  GUTTER_W: () => GUTTER_W,
52
56
  ImportTrackModal: () => ImportTrackModal,
@@ -65,30 +69,50 @@ __export(index_exports, {
65
69
  SamplePackCTACard: () => SamplePackCTACard,
66
70
  ScrollingWaveform: () => ScrollingWaveform,
67
71
  SorceryProgressBar: () => SorceryProgressBar,
72
+ TEXTURAL_ROLES: () => TEXTURAL_ROLES,
73
+ TRANSITION_DESIGNER_DRAFT_KEY: () => TRANSITION_DESIGNER_DRAFT_KEY,
68
74
  TrackDrawer: () => TrackDrawer,
69
75
  TrackMeterStrip: () => TrackMeterStrip,
70
76
  TrackRow: () => TrackRow,
77
+ TransitionDesigner: () => TransitionDesigner,
71
78
  VolumeSlider: () => VolumeSlider,
72
79
  WaveformView: () => WaveformView,
73
80
  analyzeWavPeak: () => analyzeWavPeak,
81
+ asAudioEffect: () => asAudioEffect,
74
82
  asCrossfadeMeta: () => asCrossfadeMeta,
83
+ asFadeMeta: () => asFadeMeta,
84
+ asTransitionDesignerDraft: () => asTransitionDesignerDraft,
75
85
  buildCrossfadeInpaintPrompt: () => buildCrossfadeInpaintPrompt,
76
86
  buildCrossfadeVolumeCurves: () => buildCrossfadeVolumeCurves,
87
+ buildFadeVolumeCurve: () => buildFadeVolumeCurve,
88
+ buildRowSlots: () => buildRowSlots,
77
89
  calculateTimeBasedTarget: () => calculateTimeBasedTarget,
78
90
  cellToPx: () => cellToPx,
79
91
  centerScrollTop: () => centerScrollTop,
80
92
  computePeaks: () => computePeaks,
93
+ dbIdsFromKeys: () => dbIdsFromKeys,
81
94
  dbToSlider: () => dbToSlider,
95
+ defaultFadeGesture: () => defaultFadeGesture,
82
96
  drawWaveform: () => drawWaveform,
83
97
  formatConcurrentTracks: () => formatConcurrentTracks,
98
+ hashString: () => hashString,
84
99
  moveItem: () => moveItem,
100
+ normalizeSlots: () => normalizeSlots,
101
+ padPair: () => padPair,
102
+ padSlots: () => padSlots,
85
103
  parseCrossfadePairs: () => parseCrossfadePairs,
104
+ parseFades: () => parseFades,
86
105
  pickTopKWeighted: () => pickTopKWeighted,
87
106
  pitchToName: () => pitchToName,
88
107
  pxToCell: () => pxToCell,
108
+ reconcileSlots: () => reconcileSlots,
89
109
  resizeNoteDuration: () => resizeNoteDuration,
110
+ rowKey: () => rowKey,
111
+ rowType: () => rowType,
90
112
  scorePromptMatch: () => scorePromptMatch,
91
113
  sliderToDb: () => sliderToDb,
114
+ slotsEqual: () => slotsEqual,
115
+ soundIdentity: () => soundIdentity,
92
116
  synthesizeCuePoints: () => synthesizeCuePoints,
93
117
  tokenizePrompt: () => tokenizePrompt,
94
118
  transposeNotes: () => transposeNotes,
@@ -1448,6 +1472,7 @@ var LevelMeter = ({
1448
1472
 
1449
1473
  // src/hooks/useTrackLevels.ts
1450
1474
  var import_react5 = require("react");
1475
+ var meterDiagRLast = /* @__PURE__ */ new Map();
1451
1476
  var POLL_INTERVAL_MS = 33;
1452
1477
  var HIDDEN_RECHECK_MS = 250;
1453
1478
  var METER_FLOOR_DB = -120;
@@ -1579,6 +1604,11 @@ function useTrackMeter(handle, trackId) {
1579
1604
  }
1580
1605
  const update = () => {
1581
1606
  const level = handle.getLevel(trackId);
1607
+ const dNow = Date.now();
1608
+ if ((meterDiagRLast.get(trackId) ?? 0) < dNow - 3e3) {
1609
+ meterDiagRLast.set(trackId, dNow);
1610
+ console.log(`[MeterDiagR] lookup trackId=${trackId} \u2192 ${level === null ? "MISS (no level for this id)" : "hit"}`);
1611
+ }
1582
1612
  const now = performance.now();
1583
1613
  const dtSec = lastTickRef.current ? Math.max(0, (now - lastTickRef.current) / 1e3) : 0;
1584
1614
  lastTickRef.current = now;
@@ -2280,8 +2310,7 @@ function TrackRow({
2280
2310
  {
2281
2311
  "data-testid": "sdk-mute-button",
2282
2312
  onClick: onMuteToggle,
2283
- disabled: isGenerating,
2284
- className: `px-1.5 py-0.5 text-xs font-bold rounded transition-colors ${isGenerating ? "bg-sas-panel text-sas-muted/50 cursor-not-allowed" : isMuted ? "bg-red-600 text-white" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2313
+ className: `px-1.5 py-0.5 text-xs font-bold rounded transition-colors ${isMuted ? "bg-red-600 text-white" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2285
2314
  title: isMuted ? "Unmute track" : "Mute track",
2286
2315
  children: "M"
2287
2316
  }
@@ -2532,6 +2561,18 @@ function CrossfadeTrackRow({
2532
2561
  }
2533
2562
 
2534
2563
  // src/crossfade-meta.ts
2564
+ function hashString(s) {
2565
+ let h = 5381;
2566
+ for (let i = 0; i < s.length; i++) h = (h << 5) + h ^ s.charCodeAt(i) | 0;
2567
+ return (h >>> 0).toString(36);
2568
+ }
2569
+ function soundIdentity(snap) {
2570
+ if (!snap) return "";
2571
+ if (snap.kind === "preset") return `p:${hashString(snap.state)}`;
2572
+ if (snap.kind === "sample") return `s:${snap.samplePath}`;
2573
+ if (snap.kind === "instrument") return `i:${snap.instrumentId ?? ""}:${hashString(JSON.stringify(snap.zones))}`;
2574
+ return "";
2575
+ }
2535
2576
  var EQUAL_POWER_GAIN = 0.707;
2536
2577
  function asCrossfadeMeta(val) {
2537
2578
  if (!val || typeof val !== "object") return null;
@@ -2675,9 +2716,452 @@ function buildCrossfadeInpaintPrompt(input) {
2675
2716
  return lines.join("\n");
2676
2717
  }
2677
2718
 
2678
- // src/components/ImportTrackModal.tsx
2679
- var import_react11 = require("react");
2719
+ // src/fade-meta.ts
2720
+ function asFadeMeta(val) {
2721
+ if (!val || typeof val !== "object") return null;
2722
+ const m = val;
2723
+ if (m.direction !== "in" && m.direction !== "out") return null;
2724
+ if (m.gesture !== "volume" && m.gesture !== "build") return null;
2725
+ const effect = m.effect === "stutter" || m.effect === "chopped" || m.effect === "delay" || m.effect === "fade" ? m.effect : void 0;
2726
+ return {
2727
+ direction: m.direction,
2728
+ gesture: m.gesture,
2729
+ effect,
2730
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2731
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2732
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2733
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2734
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2735
+ };
2736
+ }
2737
+ function parseFades(sceneData) {
2738
+ const out = [];
2739
+ for (const [key, val] of Object.entries(sceneData)) {
2740
+ const match = /^track:(.+):fade$/.exec(key);
2741
+ if (!match) continue;
2742
+ const meta = asFadeMeta(val);
2743
+ if (!meta) continue;
2744
+ out.push({ dbId: match[1], meta });
2745
+ }
2746
+ return out;
2747
+ }
2748
+ function buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture, steps = 32) {
2749
+ const durationSeconds = bars * 4 * 60 / Math.max(1, bpm);
2750
+ if (gesture === "build") {
2751
+ return [
2752
+ { time: 0, db: 0 },
2753
+ { time: Math.round(durationSeconds * 1e3) / 1e3, db: 0 }
2754
+ ];
2755
+ }
2756
+ const s = Math.min(0.98, Math.max(0.02, sliderPos));
2757
+ const round = (n) => Math.round(n * 1e3) / 1e3;
2758
+ const points = [];
2759
+ for (let i = 0; i <= steps; i++) {
2760
+ const x = i / steps;
2761
+ const time = round(x * durationSeconds);
2762
+ const theta = x <= s ? x / s * (Math.PI / 4) : Math.PI / 4 + (x - s) / (1 - s) * (Math.PI / 4);
2763
+ const gain = direction === "out" ? Math.cos(theta) : Math.sin(theta);
2764
+ points.push({ time, db: Math.round(gainToDb(gain) * 100) / 100 });
2765
+ }
2766
+ return points;
2767
+ }
2768
+ var TEXTURAL_ROLES = /* @__PURE__ */ new Set([
2769
+ "pads",
2770
+ "pad",
2771
+ "strings",
2772
+ "atmospheres",
2773
+ "atmosphere",
2774
+ "atmos",
2775
+ "drones",
2776
+ "drone",
2777
+ "soundscapes",
2778
+ "soundscape"
2779
+ ]);
2780
+ function defaultFadeGesture(role) {
2781
+ if (!role) return "build";
2782
+ const norm = role.toLowerCase().replace(/[\s_-]+/g, " ").trim();
2783
+ if (TEXTURAL_ROLES.has(norm)) return "volume";
2784
+ for (const token of norm.split(" ")) {
2785
+ if (TEXTURAL_ROLES.has(token)) return "volume";
2786
+ }
2787
+ return "build";
2788
+ }
2789
+
2790
+ // src/components/FadeTrackRow.tsx
2791
+ var import_react11 = __toESM(require("react"));
2680
2792
  var import_jsx_runtime13 = require("react/jsx-runtime");
2793
+ function FadeCaption({
2794
+ layer,
2795
+ direction,
2796
+ gesture
2797
+ }) {
2798
+ const tag = direction === "in" ? "Fade in" : "Fade out";
2799
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2800
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2801
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2802
+ layer.soundLabel && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2803
+ "\xB7 ",
2804
+ layer.soundLabel
2805
+ ] }),
2806
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", title: `Fade gesture: ${gesture}`, children: [
2807
+ "\xB7 ",
2808
+ gesture
2809
+ ] })
2810
+ ] });
2811
+ }
2812
+ function FadeTrackRow({
2813
+ layer,
2814
+ direction,
2815
+ gesture,
2816
+ effect,
2817
+ sliderPos = 0.5,
2818
+ onMuteToggle,
2819
+ onSoloToggle,
2820
+ onVolumeChange,
2821
+ onPanChange,
2822
+ onDelete,
2823
+ onSliderChange,
2824
+ levels,
2825
+ accentColor = "#9333EA"
2826
+ }) {
2827
+ const [confirmDelete, setConfirmDelete] = import_react11.default.useState(false);
2828
+ const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2829
+ const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2830
+ const verb = effect && effect !== "fade" ? effect.charAt(0).toUpperCase() + effect.slice(1) : "Fade";
2831
+ const badge = direction === "in" ? `\u2197 ${verb} in` : `\u2198 ${verb} out`;
2832
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2833
+ "div",
2834
+ {
2835
+ "data-testid": "fade-track-row",
2836
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2837
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2838
+ children: [
2839
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2840
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2841
+ "span",
2842
+ {
2843
+ "data-testid": "fade-direction-badge",
2844
+ className: "text-[10px] font-bold uppercase tracking-wide",
2845
+ style: { color: accentColor },
2846
+ children: badge
2847
+ }
2848
+ ),
2849
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2850
+ "button",
2851
+ {
2852
+ "data-testid": "fade-delete-button",
2853
+ onClick: () => setConfirmDelete(true),
2854
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2855
+ title: "Delete fade",
2856
+ "aria-label": "Delete fade",
2857
+ children: "x"
2858
+ }
2859
+ )
2860
+ ] }),
2861
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2862
+ TrackRow,
2863
+ {
2864
+ track: { id: layer.trackId, name: "", role: layer.role },
2865
+ runtimeState: layer.runtimeState,
2866
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2867
+ drawerOpen: false,
2868
+ drawerTab: "fx",
2869
+ levels,
2870
+ accentColor,
2871
+ contentSlot: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(FadeCaption, { layer, direction, gesture }),
2872
+ onMuteToggle,
2873
+ onSoloToggle,
2874
+ onVolumeChange,
2875
+ onPanChange
2876
+ }
2877
+ ),
2878
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "fade-slider-row", children: [
2879
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2880
+ "span",
2881
+ {
2882
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2883
+ title: leftLabel,
2884
+ children: leftLabel
2885
+ }
2886
+ ),
2887
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2888
+ "input",
2889
+ {
2890
+ type: "range",
2891
+ "data-testid": "fade-slider",
2892
+ min: 0,
2893
+ max: 1,
2894
+ step: 0.01,
2895
+ value: sliderPos,
2896
+ disabled: !onSliderChange,
2897
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2898
+ style: { accentColor },
2899
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2900
+ "aria-label": "Fade position"
2901
+ }
2902
+ ),
2903
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2904
+ "span",
2905
+ {
2906
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2907
+ title: rightLabel,
2908
+ children: rightLabel
2909
+ }
2910
+ )
2911
+ ] }),
2912
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2913
+ ConfirmDialog,
2914
+ {
2915
+ open: confirmDelete,
2916
+ title: "Delete fade?",
2917
+ 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." }),
2918
+ confirmLabel: "Delete",
2919
+ onConfirm: () => {
2920
+ setConfirmDelete(false);
2921
+ onDelete();
2922
+ },
2923
+ onCancel: () => setConfirmDelete(false),
2924
+ testIdPrefix: "fade-delete-confirm"
2925
+ }
2926
+ )
2927
+ ]
2928
+ }
2929
+ );
2930
+ }
2931
+
2932
+ // src/components/FadeModal.tsx
2933
+ var import_react12 = require("react");
2934
+ var import_jsx_runtime14 = require("react/jsx-runtime");
2935
+ function shortId(dbId) {
2936
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
2937
+ }
2938
+ var normRole = (r) => (r ?? "").toLowerCase().trim();
2939
+ function computeOrphans(from, to, excludeSet) {
2940
+ const bucket = (list) => {
2941
+ const m = /* @__PURE__ */ new Map();
2942
+ for (const t of list) {
2943
+ const k = normRole(t.role);
2944
+ const arr = m.get(k);
2945
+ if (arr) arr.push(t);
2946
+ else m.set(k, [t]);
2947
+ }
2948
+ return m;
2949
+ };
2950
+ const fromByRole = bucket(from);
2951
+ const toByRole = bucket(to);
2952
+ const roles = /* @__PURE__ */ new Set([...fromByRole.keys(), ...toByRole.keys()]);
2953
+ const fadeOut = [];
2954
+ const fadeIn = [];
2955
+ for (const role of roles) {
2956
+ const f = fromByRole.get(role) ?? [];
2957
+ const t = toByRole.get(role) ?? [];
2958
+ const shared = Math.min(f.length, t.length);
2959
+ fadeOut.push(...f.slice(shared));
2960
+ fadeIn.push(...t.slice(shared));
2961
+ }
2962
+ return {
2963
+ fadeOut: fadeOut.filter((x) => !excludeSet.has(x.dbId)),
2964
+ fadeIn: fadeIn.filter((x) => !excludeSet.has(x.dbId))
2965
+ };
2966
+ }
2967
+ function OrphanRow({
2968
+ track,
2969
+ gesture,
2970
+ selected,
2971
+ disabled,
2972
+ onSelect,
2973
+ testId
2974
+ }) {
2975
+ const primary = track.prompt?.trim() || track.name;
2976
+ const meta = [track.role, shortId(track.dbId), gesture].filter(Boolean).join(" \xB7 ");
2977
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
2978
+ "button",
2979
+ {
2980
+ type: "button",
2981
+ role: "radio",
2982
+ "aria-checked": selected,
2983
+ "data-testid": testId,
2984
+ "data-value": track.dbId,
2985
+ onClick: onSelect,
2986
+ disabled,
2987
+ 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"}`,
2988
+ children: [
2989
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
2990
+ meta && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
2991
+ ]
2992
+ }
2993
+ );
2994
+ }
2995
+ function FadeModal({
2996
+ host,
2997
+ open,
2998
+ fromSceneId,
2999
+ toSceneId,
3000
+ fromSceneName,
3001
+ toSceneName,
3002
+ excludeSourceDbIds,
3003
+ onClose,
3004
+ onCreate,
3005
+ testIdPrefix = "fade-modal"
3006
+ }) {
3007
+ const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
3008
+ const [selectedDbId, setSelectedDbId] = (0, import_react12.useState)("");
3009
+ const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
3010
+ const [error, setError] = (0, import_react12.useState)(null);
3011
+ const [fromName, setFromName] = (0, import_react12.useState)(null);
3012
+ const [toName, setToName] = (0, import_react12.useState)(null);
3013
+ const cancelRef = (0, import_react12.useRef)(null);
3014
+ const refresh = (0, import_react12.useCallback)(async () => {
3015
+ if (!host.listSceneFamilyTracks) {
3016
+ setLoad({ status: "error", message: "This host does not support fades." });
3017
+ return;
3018
+ }
3019
+ setLoad({ status: "loading" });
3020
+ try {
3021
+ const [from, to, fName, tName] = await Promise.all([
3022
+ host.listSceneFamilyTracks(fromSceneId),
3023
+ host.listSceneFamilyTracks(toSceneId),
3024
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3025
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
3026
+ ]);
3027
+ setFromName(fName);
3028
+ setToName(tName);
3029
+ setLoad({ status: "ready", from, to });
3030
+ } catch (err) {
3031
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
3032
+ }
3033
+ }, [host, fromSceneId, toSceneId]);
3034
+ (0, import_react12.useEffect)(() => {
3035
+ if (open) {
3036
+ setError(null);
3037
+ setIsCreating(false);
3038
+ setSelectedDbId("");
3039
+ void refresh();
3040
+ }
3041
+ }, [open, refresh]);
3042
+ const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3043
+ const { fadeOut, fadeIn } = (0, import_react12.useMemo)(
3044
+ () => load.status === "ready" ? computeOrphans(load.from, load.to, excludeSet) : { fadeOut: [], fadeIn: [] },
3045
+ [load, excludeSet]
3046
+ );
3047
+ const allOrphans = (0, import_react12.useMemo)(
3048
+ () => [
3049
+ ...fadeOut.map((t) => ({ track: t, direction: "out" })),
3050
+ ...fadeIn.map((t) => ({ track: t, direction: "in" }))
3051
+ ],
3052
+ [fadeOut, fadeIn]
3053
+ );
3054
+ (0, import_react12.useEffect)(() => {
3055
+ if (!allOrphans.some((o) => o.track.dbId === selectedDbId)) {
3056
+ setSelectedDbId(allOrphans[0]?.track.dbId ?? "");
3057
+ }
3058
+ }, [allOrphans, selectedDbId]);
3059
+ const selected = allOrphans.find((o) => o.track.dbId === selectedDbId) ?? null;
3060
+ const canCreate = !isCreating && !!selected;
3061
+ const handleClose = (0, import_react12.useCallback)(() => {
3062
+ if (!isCreating) onClose();
3063
+ }, [isCreating, onClose]);
3064
+ const handleCreate = (0, import_react12.useCallback)(async () => {
3065
+ if (!selected) return;
3066
+ setIsCreating(true);
3067
+ setError(null);
3068
+ try {
3069
+ await onCreate(
3070
+ { dbId: selected.track.dbId, name: selected.track.name, role: selected.track.role },
3071
+ selected.direction,
3072
+ defaultFadeGesture(selected.track.role)
3073
+ );
3074
+ onClose();
3075
+ } catch (err) {
3076
+ setError(err instanceof Error ? err.message : "Failed to create fade.");
3077
+ setIsCreating(false);
3078
+ }
3079
+ }, [selected, onCreate, onClose]);
3080
+ const fromLabel = fromName ?? fromSceneName ?? null;
3081
+ const toLabel = toName ?? toSceneName ?? null;
3082
+ if (!open) return null;
3083
+ const renderSection = (heading, list, section) => {
3084
+ if (list.length === 0) return null;
3085
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "block", children: [
3086
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: heading }),
3087
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3088
+ "div",
3089
+ {
3090
+ role: "radiogroup",
3091
+ "aria-label": heading,
3092
+ "data-testid": `${testIdPrefix}-${section === "out" ? "fade-out" : "fade-in"}-list`,
3093
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3094
+ children: list.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3095
+ OrphanRow,
3096
+ {
3097
+ track: t,
3098
+ gesture: defaultFadeGesture(t.role),
3099
+ selected: t.dbId === selectedDbId,
3100
+ disabled: isCreating,
3101
+ onSelect: () => setSelectedDbId(t.dbId),
3102
+ testId: `${testIdPrefix}-option-${t.dbId}`
3103
+ },
3104
+ t.dbId
3105
+ ))
3106
+ }
3107
+ )
3108
+ ] });
3109
+ };
3110
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3111
+ "div",
3112
+ {
3113
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
3114
+ onClick: (e) => e.stopPropagation(),
3115
+ "data-testid": `${testIdPrefix}-box`,
3116
+ children: [
3117
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add fade" }),
3118
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3119
+ "Tracks with no counterpart between",
3120
+ " ",
3121
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3122
+ " and",
3123
+ " ",
3124
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3125
+ " can gracefully fade out (leaving) or fade in (entering) across this transition."
3126
+ ] }),
3127
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3128
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3129
+ 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: [
3130
+ renderSection(`Fade out${fromLabel ? ` (from ${fromLabel})` : ""}`, fadeOut, "out"),
3131
+ renderSection(`Fade in${toLabel ? ` (to ${toLabel})` : ""}`, fadeIn, "in")
3132
+ ] })),
3133
+ error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3134
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3135
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3136
+ "button",
3137
+ {
3138
+ ref: cancelRef,
3139
+ "data-testid": `${testIdPrefix}-cancel`,
3140
+ onClick: onClose,
3141
+ disabled: isCreating,
3142
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
3143
+ children: "Cancel"
3144
+ }
3145
+ ),
3146
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3147
+ "button",
3148
+ {
3149
+ "data-testid": `${testIdPrefix}-confirm`,
3150
+ onClick: handleCreate,
3151
+ disabled: !canCreate,
3152
+ 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"}`,
3153
+ children: isCreating ? "Generating fade\u2026" : "Create fade"
3154
+ }
3155
+ )
3156
+ ] })
3157
+ ]
3158
+ }
3159
+ ) });
3160
+ }
3161
+
3162
+ // src/components/ImportTrackModal.tsx
3163
+ var import_react13 = require("react");
3164
+ var import_jsx_runtime15 = require("react/jsx-runtime");
2681
3165
  function ImportTrackModal({
2682
3166
  host,
2683
3167
  open,
@@ -2689,10 +3173,10 @@ function ImportTrackModal({
2689
3173
  onPick,
2690
3174
  onPortTrack
2691
3175
  }) {
2692
- const [load, setLoad] = (0, import_react11.useState)({ status: "loading" });
2693
- const [selectedSceneId, setSelectedSceneId] = (0, import_react11.useState)(null);
2694
- const [importingTrackId, setImportingTrackId] = (0, import_react11.useState)(null);
2695
- const refresh = (0, import_react11.useCallback)(async () => {
3176
+ const [load, setLoad] = (0, import_react13.useState)({ status: "loading" });
3177
+ const [selectedSceneId, setSelectedSceneId] = (0, import_react13.useState)(null);
3178
+ const [importingTrackId, setImportingTrackId] = (0, import_react13.useState)(null);
3179
+ const refresh = (0, import_react13.useCallback)(async () => {
2696
3180
  if (!host.listImportableTracks) {
2697
3181
  setLoad({ status: "error", message: "This host does not support importing tracks." });
2698
3182
  return;
@@ -2708,14 +3192,14 @@ function ImportTrackModal({
2708
3192
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2709
3193
  }
2710
3194
  }, [host, mode, onPortTrack]);
2711
- (0, import_react11.useEffect)(() => {
3195
+ (0, import_react13.useEffect)(() => {
2712
3196
  if (open) {
2713
3197
  setSelectedSceneId(null);
2714
3198
  setImportingTrackId(null);
2715
3199
  void refresh();
2716
3200
  }
2717
3201
  }, [open, refresh]);
2718
- const handleImport = (0, import_react11.useCallback)(
3202
+ const handleImport = (0, import_react13.useCallback)(
2719
3203
  async (track, sourceSceneId, sceneName, isSameScene) => {
2720
3204
  if (isSameScene && onPortTrack) {
2721
3205
  if (!track.importable) return;
@@ -2756,16 +3240,16 @@ function ImportTrackModal({
2756
3240
  if (!open) return null;
2757
3241
  const scenes = load.status === "ready" ? load.scenes : [];
2758
3242
  const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2759
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3243
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2760
3244
  "div",
2761
3245
  {
2762
3246
  className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2763
3247
  onClick: (e) => e.stopPropagation(),
2764
3248
  "data-testid": `${testIdPrefix}-modal`,
2765
3249
  children: [
2766
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2767
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2", children: [
2768
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3250
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
3251
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center gap-2", children: [
3252
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2769
3253
  "button",
2770
3254
  {
2771
3255
  className: "text-sas-muted hover:text-sas-accent text-xs",
@@ -2774,9 +3258,9 @@ function ImportTrackModal({
2774
3258
  children: "\u2190"
2775
3259
  }
2776
3260
  ),
2777
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
3261
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2778
3262
  ] }),
2779
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3263
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2780
3264
  "button",
2781
3265
  {
2782
3266
  className: "text-sas-muted hover:text-sas-accent text-sm",
@@ -2786,30 +3270,30 @@ function ImportTrackModal({
2786
3270
  }
2787
3271
  )
2788
3272
  ] }),
2789
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
2790
- 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" }),
2791
- 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 }),
2792
- 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." }),
2793
- 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)(
3273
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
3274
+ 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" }),
3275
+ 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 }),
3276
+ 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." }),
3277
+ 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)(
2794
3278
  "button",
2795
3279
  {
2796
3280
  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",
2797
3281
  onClick: () => setSelectedSceneId(scene.sceneId),
2798
3282
  "data-testid": `${testIdPrefix}-scene`,
2799
3283
  children: [
2800
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "truncate", children: scene.sceneName }),
2801
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3284
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "truncate", children: scene.sceneName }),
3285
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2802
3286
  scene.tracks.length,
2803
3287
  " \u2192"
2804
3288
  ] })
2805
3289
  ]
2806
3290
  }
2807
3291
  ) }, scene.sceneId)) }),
2808
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
3292
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2809
3293
  const busy = importingTrackId === track.trackId;
2810
3294
  const gated = mode === "track" && !track.importable;
2811
3295
  const disabled = gated || busy;
2812
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3296
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2813
3297
  "button",
2814
3298
  {
2815
3299
  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"}`,
@@ -2819,14 +3303,14 @@ function ImportTrackModal({
2819
3303
  "data-testid": `${testIdPrefix}-track`,
2820
3304
  "data-importable": mode === "sound" || track.importable ? "true" : "false",
2821
3305
  children: [
2822
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "truncate", children: [
3306
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "truncate", children: [
2823
3307
  track.name,
2824
- track.role ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3308
+ track.role ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2825
3309
  " \xB7 ",
2826
3310
  track.role
2827
3311
  ] }) : null
2828
3312
  ] }),
2829
- 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
3313
+ 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
2830
3314
  ]
2831
3315
  }
2832
3316
  ) }, track.dbId);
@@ -2838,8 +3322,38 @@ function ImportTrackModal({
2838
3322
  }
2839
3323
 
2840
3324
  // src/components/CrossfadeModal.tsx
2841
- var import_react12 = require("react");
2842
- var import_jsx_runtime14 = require("react/jsx-runtime");
3325
+ var import_react14 = require("react");
3326
+ var import_jsx_runtime16 = require("react/jsx-runtime");
3327
+ function shortId2(dbId) {
3328
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3329
+ }
3330
+ function CandidateRow({
3331
+ track,
3332
+ selected,
3333
+ disabled,
3334
+ onSelect,
3335
+ testId
3336
+ }) {
3337
+ const primary = track.prompt?.trim() || track.name;
3338
+ const meta = [track.role, shortId2(track.dbId)].filter(Boolean).join(" \xB7 ");
3339
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3340
+ "button",
3341
+ {
3342
+ type: "button",
3343
+ role: "radio",
3344
+ "aria-checked": selected,
3345
+ "data-testid": testId,
3346
+ "data-value": track.dbId,
3347
+ onClick: onSelect,
3348
+ disabled,
3349
+ 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"}`,
3350
+ children: [
3351
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
3352
+ meta && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
3353
+ ]
3354
+ }
3355
+ );
3356
+ }
2843
3357
  function CrossfadeModal({
2844
3358
  host,
2845
3359
  open,
@@ -2852,15 +3366,15 @@ function CrossfadeModal({
2852
3366
  onCreate,
2853
3367
  testIdPrefix = "crossfade-modal"
2854
3368
  }) {
2855
- const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
2856
- const [originDbId, setOriginDbId] = (0, import_react12.useState)("");
2857
- const [targetDbId, setTargetDbId] = (0, import_react12.useState)("");
2858
- const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2859
- const [error, setError] = (0, import_react12.useState)(null);
2860
- const [fromName, setFromName] = (0, import_react12.useState)(null);
2861
- const [toName, setToName] = (0, import_react12.useState)(null);
2862
- const cancelRef = (0, import_react12.useRef)(null);
2863
- const refresh = (0, import_react12.useCallback)(async () => {
3369
+ const [load, setLoad] = (0, import_react14.useState)({ status: "loading" });
3370
+ const [originDbId, setOriginDbId] = (0, import_react14.useState)("");
3371
+ const [targetDbId, setTargetDbId] = (0, import_react14.useState)("");
3372
+ const [isCreating, setIsCreating] = (0, import_react14.useState)(false);
3373
+ const [error, setError] = (0, import_react14.useState)(null);
3374
+ const [fromName, setFromName] = (0, import_react14.useState)(null);
3375
+ const [toName, setToName] = (0, import_react14.useState)(null);
3376
+ const cancelRef = (0, import_react14.useRef)(null);
3377
+ const refresh = (0, import_react14.useCallback)(async () => {
2864
3378
  if (!host.listSceneFamilyTracks) {
2865
3379
  setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2866
3380
  return;
@@ -2880,7 +3394,7 @@ function CrossfadeModal({
2880
3394
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2881
3395
  }
2882
3396
  }, [host, fromSceneId, toSceneId]);
2883
- (0, import_react12.useEffect)(() => {
3397
+ (0, import_react14.useEffect)(() => {
2884
3398
  if (open) {
2885
3399
  setError(null);
2886
3400
  setIsCreating(false);
@@ -2889,21 +3403,21 @@ function CrossfadeModal({
2889
3403
  void refresh();
2890
3404
  }
2891
3405
  }, [open, refresh]);
2892
- const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2893
- const originCandidates = (0, import_react12.useMemo)(
3406
+ const excludeSet = (0, import_react14.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3407
+ const originCandidates = (0, import_react14.useMemo)(
2894
3408
  () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
2895
3409
  [load, excludeSet]
2896
3410
  );
2897
- const targetCandidates = (0, import_react12.useMemo)(
3411
+ const targetCandidates = (0, import_react14.useMemo)(
2898
3412
  () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
2899
3413
  [load, excludeSet]
2900
3414
  );
2901
- (0, import_react12.useEffect)(() => {
3415
+ (0, import_react14.useEffect)(() => {
2902
3416
  if (!originCandidates.some((t) => t.dbId === originDbId)) {
2903
3417
  setOriginDbId(originCandidates[0]?.dbId ?? "");
2904
3418
  }
2905
3419
  }, [originCandidates, originDbId]);
2906
- (0, import_react12.useEffect)(() => {
3420
+ (0, import_react14.useEffect)(() => {
2907
3421
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2908
3422
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2909
3423
  }
@@ -2911,10 +3425,10 @@ function CrossfadeModal({
2911
3425
  const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2912
3426
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2913
3427
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2914
- const handleClose = (0, import_react12.useCallback)(() => {
3428
+ const handleClose = (0, import_react14.useCallback)(() => {
2915
3429
  if (!isCreating) onClose();
2916
3430
  }, [isCreating, onClose]);
2917
- const handleCreate = (0, import_react12.useCallback)(async () => {
3431
+ const handleCreate = (0, import_react14.useCallback)(async () => {
2918
3432
  if (!originTrack || !targetTrack) return;
2919
3433
  setIsCreating(true);
2920
3434
  setError(null);
@@ -2932,26 +3446,26 @@ function CrossfadeModal({
2932
3446
  const fromLabel = fromName ?? fromSceneName ?? null;
2933
3447
  const toLabel = toName ?? toSceneName ?? null;
2934
3448
  if (!open) return null;
2935
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3449
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2936
3450
  "div",
2937
3451
  {
2938
3452
  className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2939
3453
  onClick: (e) => e.stopPropagation(),
2940
3454
  "data-testid": `${testIdPrefix}-box`,
2941
3455
  children: [
2942
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2943
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3456
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
3457
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2944
3458
  "Bridge a track from",
2945
3459
  " ",
2946
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3460
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2947
3461
  " into one from",
2948
3462
  " ",
2949
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3463
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2950
3464
  ". Both layers share one generated part; each keeps its own preset."
2951
3465
  ] }),
2952
- load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2953
- load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2954
- load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3466
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3467
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3468
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2955
3469
  "div",
2956
3470
  {
2957
3471
  className: "text-xs text-sas-muted py-4 text-center",
@@ -2962,55 +3476,67 @@ function CrossfadeModal({
2962
3476
  ". Add one (or free one from another crossfade) first."
2963
3477
  ]
2964
3478
  }
2965
- ) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2966
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2967
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3479
+ ) : /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
3480
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3481
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2968
3482
  "Origin ",
2969
3483
  fromLabel ? `(${fromLabel})` : "(top)"
2970
3484
  ] }),
2971
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2972
- "select",
3485
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3486
+ "div",
2973
3487
  {
2974
- "data-testid": `${testIdPrefix}-origin-select`,
2975
- value: originDbId,
2976
- onChange: (e) => setOriginDbId(e.target.value),
2977
- disabled: isCreating,
2978
- className: "sas-input w-full mt-0.5 text-xs",
2979
- children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2980
- t.name,
2981
- t.role ? ` \xB7 ${t.role}` : ""
2982
- ] }, t.dbId))
3488
+ role: "radiogroup",
3489
+ "aria-label": "Origin track",
3490
+ "data-testid": `${testIdPrefix}-origin-list`,
3491
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3492
+ children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3493
+ CandidateRow,
3494
+ {
3495
+ track: t,
3496
+ selected: t.dbId === originDbId,
3497
+ disabled: isCreating,
3498
+ onSelect: () => setOriginDbId(t.dbId),
3499
+ testId: `${testIdPrefix}-origin-option-${t.dbId}`
3500
+ },
3501
+ t.dbId
3502
+ ))
2983
3503
  }
2984
3504
  )
2985
3505
  ] }),
2986
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2987
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3506
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3507
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2988
3508
  "Target ",
2989
3509
  toLabel ? `(${toLabel})` : "(bottom)"
2990
3510
  ] }),
2991
- 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: [
3511
+ 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: [
2992
3512
  "No available tracks in ",
2993
3513
  toLabel ?? "the target scene",
2994
3514
  " to crossfade into."
2995
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2996
- "select",
3515
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3516
+ "div",
2997
3517
  {
2998
- "data-testid": `${testIdPrefix}-target-select`,
2999
- value: targetDbId,
3000
- onChange: (e) => setTargetDbId(e.target.value),
3001
- disabled: isCreating,
3002
- className: "sas-input w-full mt-0.5 text-xs",
3003
- children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
3004
- t.name,
3005
- t.role ? ` \xB7 ${t.role}` : ""
3006
- ] }, t.dbId))
3518
+ role: "radiogroup",
3519
+ "aria-label": "Target track",
3520
+ "data-testid": `${testIdPrefix}-target-list`,
3521
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3522
+ children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3523
+ CandidateRow,
3524
+ {
3525
+ track: t,
3526
+ selected: t.dbId === targetDbId,
3527
+ disabled: isCreating,
3528
+ onSelect: () => setTargetDbId(t.dbId),
3529
+ testId: `${testIdPrefix}-target-option-${t.dbId}`
3530
+ },
3531
+ t.dbId
3532
+ ))
3007
3533
  }
3008
3534
  )
3009
3535
  ] })
3010
3536
  ] })),
3011
- error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3012
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3013
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3537
+ error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3538
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3539
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3014
3540
  "button",
3015
3541
  {
3016
3542
  ref: cancelRef,
@@ -3021,7 +3547,7 @@ function CrossfadeModal({
3021
3547
  children: "Cancel"
3022
3548
  }
3023
3549
  ),
3024
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3550
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3025
3551
  "button",
3026
3552
  {
3027
3553
  "data-testid": `${testIdPrefix}-confirm`,
@@ -3037,9 +3563,701 @@ function CrossfadeModal({
3037
3563
  ) });
3038
3564
  }
3039
3565
 
3566
+ // src/components/TransitionDesigner.tsx
3567
+ var import_react16 = require("react");
3568
+
3569
+ // src/hooks/useTrackReorder.ts
3570
+ var import_react15 = require("react");
3571
+ function moveItem(arr, from, to) {
3572
+ const next = arr.slice();
3573
+ if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3574
+ return next;
3575
+ }
3576
+ const [moved] = next.splice(from, 1);
3577
+ next.splice(to, 0, moved);
3578
+ return next;
3579
+ }
3580
+ function useTrackReorder({
3581
+ host,
3582
+ items,
3583
+ setItems,
3584
+ getId,
3585
+ onError
3586
+ }) {
3587
+ const [draggingIndex, setDraggingIndex] = (0, import_react15.useState)(null);
3588
+ const [dragOverIndex, setDragOverIndex] = (0, import_react15.useState)(null);
3589
+ const fromRef = (0, import_react15.useRef)(null);
3590
+ const itemsRef = (0, import_react15.useRef)(items);
3591
+ itemsRef.current = items;
3592
+ const dragPropsFor = (0, import_react15.useCallback)(
3593
+ (index) => ({
3594
+ handleProps: {
3595
+ draggable: true,
3596
+ onDragStart: (e) => {
3597
+ fromRef.current = index;
3598
+ setDraggingIndex(index);
3599
+ if (e.dataTransfer) {
3600
+ e.dataTransfer.effectAllowed = "move";
3601
+ try {
3602
+ e.dataTransfer.setData("text/plain", String(index));
3603
+ } catch {
3604
+ }
3605
+ }
3606
+ },
3607
+ onDragEnd: () => {
3608
+ fromRef.current = null;
3609
+ setDraggingIndex(null);
3610
+ setDragOverIndex(null);
3611
+ }
3612
+ },
3613
+ rowProps: {
3614
+ onDragEnter: (e) => {
3615
+ if (fromRef.current === null) return;
3616
+ e.preventDefault();
3617
+ setDragOverIndex(index);
3618
+ },
3619
+ onDragOver: (e) => {
3620
+ if (fromRef.current === null) return;
3621
+ e.preventDefault();
3622
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3623
+ setDragOverIndex((cur) => cur === index ? cur : index);
3624
+ },
3625
+ onDragLeave: () => {
3626
+ setDragOverIndex((cur) => cur === index ? null : cur);
3627
+ },
3628
+ onDrop: (e) => {
3629
+ e.preventDefault();
3630
+ const from = fromRef.current;
3631
+ fromRef.current = null;
3632
+ setDraggingIndex(null);
3633
+ setDragOverIndex(null);
3634
+ if (from === null || from === index) return;
3635
+ const prev = itemsRef.current;
3636
+ const next = moveItem(prev, from, index);
3637
+ setItems(next);
3638
+ const ids = next.map(getId);
3639
+ Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3640
+ setItems(prev);
3641
+ onError?.(err);
3642
+ });
3643
+ }
3644
+ },
3645
+ isDragging: draggingIndex === index,
3646
+ isDragTarget: dragOverIndex === index && draggingIndex !== index
3647
+ }),
3648
+ [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3649
+ );
3650
+ return { dragPropsFor, draggingIndex, dragOverIndex };
3651
+ }
3652
+
3653
+ // src/transition-designer-meta.ts
3654
+ var TRANSITION_DESIGNER_DRAFT_KEY = "transitionDesigner:draft";
3655
+ var AUDIO_EFFECTS = ["fade", "stutter", "chopped", "delay"];
3656
+ var AUDIO_EFFECT_LABEL = {
3657
+ fade: "Fade",
3658
+ stutter: "Stutter",
3659
+ chopped: "Chopped",
3660
+ delay: "Delay"
3661
+ };
3662
+ function asAudioEffect(v) {
3663
+ return v === "fade" || v === "stutter" || v === "chopped" || v === "delay" ? v : null;
3664
+ }
3665
+ function rowType(hasOrigin, hasTarget) {
3666
+ if (hasOrigin && hasTarget) return "crossfade";
3667
+ if (hasOrigin) return "fade-out";
3668
+ if (hasTarget) return "fade-in";
3669
+ return null;
3670
+ }
3671
+ function asTransitionDesignerDraft(val) {
3672
+ if (!val || typeof val !== "object") return null;
3673
+ const d = val;
3674
+ const clean = (a) => Array.isArray(a) ? a.filter((x) => x === null || typeof x === "string") : [];
3675
+ const cleanEffects = (e) => {
3676
+ const out = {};
3677
+ if (e && typeof e === "object") {
3678
+ for (const [k, v] of Object.entries(e)) {
3679
+ const eff = asAudioEffect(v);
3680
+ if (eff) out[k] = eff;
3681
+ }
3682
+ }
3683
+ return out;
3684
+ };
3685
+ return {
3686
+ originOrder: clean(d.originOrder),
3687
+ targetOrder: clean(d.targetOrder),
3688
+ rowEffects: cleanEffects(d.rowEffects)
3689
+ };
3690
+ }
3691
+ function reconcileSlots(saved, poolIds) {
3692
+ const pool = new Set(poolIds);
3693
+ const seen = /* @__PURE__ */ new Set();
3694
+ const out = [];
3695
+ for (const slot of saved ?? []) {
3696
+ if (slot === null) {
3697
+ out.push(null);
3698
+ continue;
3699
+ }
3700
+ if (pool.has(slot) && !seen.has(slot)) {
3701
+ out.push(slot);
3702
+ seen.add(slot);
3703
+ }
3704
+ }
3705
+ for (const id of poolIds) {
3706
+ if (!seen.has(id)) {
3707
+ out.push(id);
3708
+ seen.add(id);
3709
+ }
3710
+ }
3711
+ return out;
3712
+ }
3713
+ function buildRowSlots(originSlots, targetSlots) {
3714
+ const n = Math.max(originSlots.length, targetSlots.length);
3715
+ const rows = [];
3716
+ for (let i = 0; i < n; i++) {
3717
+ const originId = originSlots[i] ?? null;
3718
+ const targetId = targetSlots[i] ?? null;
3719
+ rows.push({ originId, targetId, type: rowType(originId !== null, targetId !== null) });
3720
+ }
3721
+ return rows;
3722
+ }
3723
+ function normalizeSlots(originSlots, targetSlots) {
3724
+ const rows = buildRowSlots(originSlots, targetSlots).filter(
3725
+ (r) => r.originId !== null || r.targetId !== null
3726
+ );
3727
+ const trimTrailing = (a) => {
3728
+ let end = a.length;
3729
+ while (end > 0 && a[end - 1] === null) end--;
3730
+ return a.slice(0, end);
3731
+ };
3732
+ return {
3733
+ originOrder: trimTrailing(rows.map((r) => r.originId)),
3734
+ targetOrder: trimTrailing(rows.map((r) => r.targetId))
3735
+ };
3736
+ }
3737
+ function padSlots(slots, n) {
3738
+ if (slots.length >= n) return slots.slice();
3739
+ return [...slots, ...new Array(n - slots.length).fill(null)];
3740
+ }
3741
+ function padPair(originSlots, targetSlots) {
3742
+ const n = Math.max(originSlots.length, targetSlots.length);
3743
+ return [padSlots(originSlots, n), padSlots(targetSlots, n)];
3744
+ }
3745
+ function slotsEqual(a, b) {
3746
+ if (a.length !== b.length) return false;
3747
+ for (let i = 0; i < a.length; i++) {
3748
+ if (a[i] !== b[i]) return false;
3749
+ }
3750
+ return true;
3751
+ }
3752
+ function rowKey(row) {
3753
+ if (row.type === "crossfade") return `xf:${row.originId}|${row.targetId}`;
3754
+ if (row.type === "fade-out") return `fo:${row.originId}`;
3755
+ if (row.type === "fade-in") return `fi:${row.targetId}`;
3756
+ return null;
3757
+ }
3758
+ function dbIdsFromKeys(keys) {
3759
+ const out = /* @__PURE__ */ new Set();
3760
+ for (const k of keys) {
3761
+ const body = k.slice(3);
3762
+ if (k.startsWith("xf:")) {
3763
+ const sep = body.indexOf("|");
3764
+ out.add(body.slice(0, sep));
3765
+ out.add(body.slice(sep + 1));
3766
+ } else {
3767
+ out.add(body);
3768
+ }
3769
+ }
3770
+ return out;
3771
+ }
3772
+
3773
+ // src/components/TransitionDesigner.tsx
3774
+ var import_jsx_runtime17 = require("react/jsx-runtime");
3775
+ var CROSSFADE_ESTIMATE_MS = 15e3;
3776
+ var FADE_ESTIMATE_MS = 11e3;
3777
+ var CREATE_ALL_CONCURRENCY = 5;
3778
+ var TYPE_LABEL = {
3779
+ crossfade: "Crossfade",
3780
+ "fade-out": "Fade out",
3781
+ "fade-in": "Fade in"
3782
+ };
3783
+ function shortId3(dbId) {
3784
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3785
+ }
3786
+ function TransitionDesigner({
3787
+ host,
3788
+ fromSceneId,
3789
+ toSceneId,
3790
+ transitionSceneId,
3791
+ excludeSourceDbIds,
3792
+ onCreateCrossfade,
3793
+ onCreateFade,
3794
+ onCreateAudioTransition,
3795
+ familyLabel,
3796
+ testIdPrefix = "transition-designer"
3797
+ }) {
3798
+ const [load, setLoad] = (0, import_react16.useState)({ status: "loading" });
3799
+ const [fromName, setFromName] = (0, import_react16.useState)(null);
3800
+ const [toName, setToName] = (0, import_react16.useState)(null);
3801
+ const [originSlots, setOriginSlots] = (0, import_react16.useState)([]);
3802
+ const [targetSlots, setTargetSlots] = (0, import_react16.useState)([]);
3803
+ const [creatingKeys, setCreatingKeys] = (0, import_react16.useState)(() => /* @__PURE__ */ new Set());
3804
+ const [rowErrors, setRowErrors] = (0, import_react16.useState)({});
3805
+ const [rowEffects, setRowEffects] = (0, import_react16.useState)({});
3806
+ const rowEffectsRef = (0, import_react16.useRef)(rowEffects);
3807
+ rowEffectsRef.current = rowEffects;
3808
+ const audioEffectsEnabled = !!onCreateAudioTransition;
3809
+ const excludeRef = (0, import_react16.useRef)(excludeSourceDbIds);
3810
+ excludeRef.current = excludeSourceDbIds;
3811
+ const originSlotsRef = (0, import_react16.useRef)(originSlots);
3812
+ originSlotsRef.current = originSlots;
3813
+ const targetSlotsRef = (0, import_react16.useRef)(targetSlots);
3814
+ targetSlotsRef.current = targetSlots;
3815
+ const creatingKeysRef = (0, import_react16.useRef)(creatingKeys);
3816
+ creatingKeysRef.current = creatingKeys;
3817
+ const dragRef = (0, import_react16.useRef)(null);
3818
+ const [dragging, setDragging] = (0, import_react16.useState)(null);
3819
+ const [dragOver, setDragOver] = (0, import_react16.useState)(null);
3820
+ const excludeSet = (0, import_react16.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3821
+ const originPool = (0, import_react16.useMemo)(
3822
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
3823
+ [load, excludeSet]
3824
+ );
3825
+ const targetPool = (0, import_react16.useMemo)(
3826
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
3827
+ [load, excludeSet]
3828
+ );
3829
+ const originById = (0, import_react16.useMemo)(() => new Map(originPool.map((t) => [t.dbId, t])), [originPool]);
3830
+ const targetById = (0, import_react16.useMemo)(() => new Map(targetPool.map((t) => [t.dbId, t])), [targetPool]);
3831
+ const originByIdRef = (0, import_react16.useRef)(originById);
3832
+ originByIdRef.current = originById;
3833
+ const targetByIdRef = (0, import_react16.useRef)(targetById);
3834
+ targetByIdRef.current = targetById;
3835
+ const refresh = (0, import_react16.useCallback)(async () => {
3836
+ if (!host.listSceneFamilyTracks) {
3837
+ setLoad({ status: "error", message: "This host does not support transition tracks." });
3838
+ return;
3839
+ }
3840
+ setLoad({ status: "loading" });
3841
+ try {
3842
+ const [origin, target, fName, tName, draftRaw] = await Promise.all([
3843
+ host.listSceneFamilyTracks(fromSceneId),
3844
+ host.listSceneFamilyTracks(toSceneId),
3845
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3846
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null),
3847
+ host.getSceneData ? host.getSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY) : Promise.resolve(null)
3848
+ ]);
3849
+ const draft = asTransitionDesignerDraft(draftRaw);
3850
+ const exSet = new Set(excludeRef.current ?? []);
3851
+ const originIds = origin.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3852
+ const targetIds = target.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3853
+ const [po, pt] = padPair(
3854
+ reconcileSlots(draft?.originOrder, originIds),
3855
+ reconcileSlots(draft?.targetOrder, targetIds)
3856
+ );
3857
+ setOriginSlots(po);
3858
+ setTargetSlots(pt);
3859
+ setRowEffects(draft?.rowEffects ?? {});
3860
+ setFromName(fName);
3861
+ setToName(tName);
3862
+ setLoad({ status: "ready", origin, target });
3863
+ } catch (err) {
3864
+ setLoad({
3865
+ status: "error",
3866
+ message: err instanceof Error ? err.message : "Failed to load tracks."
3867
+ });
3868
+ }
3869
+ }, [host, fromSceneId, toSceneId, transitionSceneId]);
3870
+ (0, import_react16.useEffect)(() => {
3871
+ void refresh();
3872
+ }, [refresh]);
3873
+ (0, import_react16.useEffect)(() => {
3874
+ if (load.status !== "ready") return;
3875
+ const [po, pt] = padPair(
3876
+ reconcileSlots(originSlotsRef.current, originPool.map((t) => t.dbId)),
3877
+ reconcileSlots(targetSlotsRef.current, targetPool.map((t) => t.dbId))
3878
+ );
3879
+ if (!slotsEqual(po, originSlotsRef.current)) setOriginSlots(po);
3880
+ if (!slotsEqual(pt, targetSlotsRef.current)) setTargetSlots(pt);
3881
+ }, [originPool, targetPool, load.status]);
3882
+ const mutate = (0, import_react16.useCallback)(
3883
+ (nextOrigin, nextTarget) => {
3884
+ const norm = normalizeSlots(nextOrigin, nextTarget);
3885
+ const [po, pt] = padPair(norm.originOrder, norm.targetOrder);
3886
+ setOriginSlots(po);
3887
+ setTargetSlots(pt);
3888
+ if (host.setSceneData) {
3889
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: rowEffectsRef.current }).catch(() => {
3890
+ });
3891
+ }
3892
+ },
3893
+ [host, transitionSceneId]
3894
+ );
3895
+ const setRowEffect = (0, import_react16.useCallback)(
3896
+ (sourceDbId, effect) => {
3897
+ setRowEffects((prev) => {
3898
+ const next = { ...prev, [sourceDbId]: effect };
3899
+ if (host.setSceneData) {
3900
+ const norm = normalizeSlots(originSlotsRef.current, targetSlotsRef.current);
3901
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: next }).catch(() => {
3902
+ });
3903
+ }
3904
+ return next;
3905
+ });
3906
+ },
3907
+ [host, transitionSceneId]
3908
+ );
3909
+ const insertGapAbove = (0, import_react16.useCallback)(
3910
+ (col, index) => {
3911
+ const slots = col === "origin" ? originSlots : targetSlots;
3912
+ const next = [...slots.slice(0, index), null, ...slots.slice(index)];
3913
+ if (col === "origin") mutate(next, targetSlots);
3914
+ else mutate(originSlots, next);
3915
+ },
3916
+ [originSlots, targetSlots, mutate]
3917
+ );
3918
+ const removeGap = (0, import_react16.useCallback)(
3919
+ (col, index) => {
3920
+ const slots = col === "origin" ? originSlots : targetSlots;
3921
+ const next = slots.filter((_, i) => i !== index);
3922
+ if (col === "origin") mutate(next, targetSlots);
3923
+ else mutate(originSlots, next);
3924
+ },
3925
+ [originSlots, targetSlots, mutate]
3926
+ );
3927
+ const handleDrop = (0, import_react16.useCallback)(
3928
+ (col, to) => {
3929
+ const from = dragRef.current;
3930
+ dragRef.current = null;
3931
+ setDragging(null);
3932
+ setDragOver(null);
3933
+ if (!from || from.col !== col || from.index === to) return;
3934
+ if (col === "origin") mutate(moveItem(originSlots, from.index, to), targetSlots);
3935
+ else mutate(originSlots, moveItem(targetSlots, from.index, to));
3936
+ },
3937
+ [originSlots, targetSlots, mutate]
3938
+ );
3939
+ const rows = (0, import_react16.useMemo)(() => buildRowSlots(originSlots, targetSlots), [originSlots, targetSlots]);
3940
+ const creatingDbIds = (0, import_react16.useMemo)(() => dbIdsFromKeys(creatingKeys), [creatingKeys]);
3941
+ const eligibleCount = (0, import_react16.useMemo)(
3942
+ () => rows.filter((r) => {
3943
+ const k = rowKey(r);
3944
+ return k !== null && !creatingKeys.has(k);
3945
+ }).length,
3946
+ [rows, creatingKeys]
3947
+ );
3948
+ const createRow = (0, import_react16.useCallback)(
3949
+ async (row) => {
3950
+ const key = rowKey(row);
3951
+ if (!key || !row.type || creatingKeysRef.current.has(key)) return;
3952
+ setCreatingKeys((prev) => new Set(prev).add(key));
3953
+ setRowErrors((prev) => {
3954
+ if (!(key in prev)) return prev;
3955
+ const next = { ...prev };
3956
+ delete next[key];
3957
+ return next;
3958
+ });
3959
+ try {
3960
+ if (row.type === "crossfade") {
3961
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3962
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3963
+ if (!o || !t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3964
+ await onCreateCrossfade(
3965
+ { dbId: o.dbId, name: o.name, role: o.role },
3966
+ { dbId: t.dbId, name: t.name, role: t.role }
3967
+ );
3968
+ } else if (row.type === "fade-out") {
3969
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3970
+ if (!o) throw new Error("Track is no longer available \u2014 refresh and retry.");
3971
+ const eff = rowEffectsRef.current[o.dbId] ?? "fade";
3972
+ if (eff !== "fade" && onCreateAudioTransition) {
3973
+ await onCreateAudioTransition({ dbId: o.dbId, name: o.name, role: o.role }, "out", eff);
3974
+ } else {
3975
+ await onCreateFade({ dbId: o.dbId, name: o.name, role: o.role }, "out", defaultFadeGesture(o.role));
3976
+ }
3977
+ } else {
3978
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3979
+ if (!t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3980
+ const eff = rowEffectsRef.current[t.dbId] ?? "fade";
3981
+ if (eff !== "fade" && onCreateAudioTransition) {
3982
+ await onCreateAudioTransition({ dbId: t.dbId, name: t.name, role: t.role }, "in", eff);
3983
+ } else {
3984
+ await onCreateFade({ dbId: t.dbId, name: t.name, role: t.role }, "in", defaultFadeGesture(t.role));
3985
+ }
3986
+ }
3987
+ } catch (err) {
3988
+ setRowErrors((prev) => ({
3989
+ ...prev,
3990
+ [key]: err instanceof Error ? err.message : "Failed to create transition."
3991
+ }));
3992
+ } finally {
3993
+ setCreatingKeys((prev) => {
3994
+ const next = new Set(prev);
3995
+ next.delete(key);
3996
+ return next;
3997
+ });
3998
+ }
3999
+ },
4000
+ [onCreateCrossfade, onCreateFade, onCreateAudioTransition]
4001
+ );
4002
+ const createAll = (0, import_react16.useCallback)(async () => {
4003
+ const eligible = buildRowSlots(originSlotsRef.current, targetSlotsRef.current).filter((r) => {
4004
+ const k = rowKey(r);
4005
+ return k !== null && !creatingKeysRef.current.has(k);
4006
+ });
4007
+ if (eligible.length === 0) return;
4008
+ let cursor = 0;
4009
+ const worker = async () => {
4010
+ while (cursor < eligible.length) {
4011
+ const row = eligible[cursor];
4012
+ cursor += 1;
4013
+ await createRow(row);
4014
+ }
4015
+ };
4016
+ await Promise.all(
4017
+ Array.from({ length: Math.min(CREATE_ALL_CONCURRENCY, eligible.length) }, () => worker())
4018
+ );
4019
+ }, [createRow]);
4020
+ const fromLabel = fromName ?? "origin";
4021
+ const toLabel = toName ?? "target";
4022
+ const cellDragProps = (col, index, locked) => ({
4023
+ draggable: !locked,
4024
+ onDragStart: (e) => {
4025
+ if (locked) return;
4026
+ dragRef.current = { col, index };
4027
+ setDragging({ col, index });
4028
+ if (e.dataTransfer) {
4029
+ e.dataTransfer.effectAllowed = "move";
4030
+ try {
4031
+ e.dataTransfer.setData("text/plain", String(index));
4032
+ } catch {
4033
+ }
4034
+ }
4035
+ },
4036
+ onDragEnd: () => {
4037
+ dragRef.current = null;
4038
+ setDragging(null);
4039
+ setDragOver(null);
4040
+ },
4041
+ onDragEnter: (e) => {
4042
+ const d = dragRef.current;
4043
+ if (!d || d.col !== col) return;
4044
+ e.preventDefault();
4045
+ setDragOver({ col, index });
4046
+ },
4047
+ onDragOver: (e) => {
4048
+ const d = dragRef.current;
4049
+ if (!d || d.col !== col) return;
4050
+ e.preventDefault();
4051
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
4052
+ },
4053
+ onDragLeave: () => {
4054
+ setDragOver((cur) => cur && cur.col === col && cur.index === index ? null : cur);
4055
+ },
4056
+ onDrop: (e) => {
4057
+ e.preventDefault();
4058
+ handleDrop(col, index);
4059
+ }
4060
+ });
4061
+ const renderCell = (col, index, slotId) => {
4062
+ const byId = col === "origin" ? originById : targetById;
4063
+ const track = slotId ? byId.get(slotId) : void 0;
4064
+ const locked = slotId !== null && creatingDbIds.has(slotId);
4065
+ const isDragging = dragging?.col === col && dragging.index === index;
4066
+ const isDragTarget = dragOver?.col === col && dragOver.index === index && !isDragging;
4067
+ const base = "group relative rounded-sm border px-2 py-1.5 text-left transition-colors select-none";
4068
+ const tone = isDragTarget ? "border-sas-accent bg-sas-accent/10" : "border-sas-border bg-sas-panel";
4069
+ if (slotId === null) {
4070
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4071
+ "div",
4072
+ {
4073
+ ...cellDragProps(col, index, false),
4074
+ "data-testid": `${testIdPrefix}-${col}-gap-${index}`,
4075
+ className: `${base} ${tone} border-dashed flex items-center justify-between ${isDragging ? "opacity-40" : "opacity-70"}`,
4076
+ children: [
4077
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "\u2014 gap \u2014" }),
4078
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4079
+ "button",
4080
+ {
4081
+ type: "button",
4082
+ "data-testid": `${testIdPrefix}-${col}-remove-gap-${index}`,
4083
+ onClick: () => removeGap(col, index),
4084
+ title: "Remove gap",
4085
+ className: "text-[10px] text-sas-muted hover:text-sas-danger",
4086
+ children: "\u2715"
4087
+ }
4088
+ )
4089
+ ]
4090
+ }
4091
+ );
4092
+ }
4093
+ const primary = track ? track.prompt?.trim() || track.name : slotId;
4094
+ const meta = track ? [track.role, shortId3(track.dbId)].filter(Boolean).join(" \xB7 ") : "missing";
4095
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4096
+ "div",
4097
+ {
4098
+ ...cellDragProps(col, index, locked),
4099
+ "data-testid": `${testIdPrefix}-${col}-cell-${slotId}`,
4100
+ "data-value": slotId,
4101
+ className: `${base} ${tone} ${isDragging ? "opacity-40" : ""} ${locked ? "opacity-60" : "cursor-grab active:cursor-grabbing"}`,
4102
+ title: track ? track.dbId : "Track no longer available",
4103
+ children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-start gap-1", children: [
4104
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-muted/60 text-xs leading-tight pt-0.5", "aria-hidden": true, children: "\u283F" }),
4105
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "min-w-0 flex-1", children: [
4106
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-text truncate", children: primary }),
4107
+ meta && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", children: meta })
4108
+ ] }),
4109
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4110
+ "button",
4111
+ {
4112
+ type: "button",
4113
+ "data-testid": `${testIdPrefix}-${col}-insert-gap-${index}`,
4114
+ onClick: () => insertGapAbove(col, index),
4115
+ disabled: locked,
4116
+ title: "Insert a gap above (make this a fade)",
4117
+ className: "text-[10px] text-sas-muted opacity-0 group-hover:opacity-100 hover:text-sas-accent disabled:opacity-30",
4118
+ children: "+gap"
4119
+ }
4120
+ )
4121
+ ] })
4122
+ }
4123
+ );
4124
+ };
4125
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "space-y-2", "data-testid": `${testIdPrefix}-box`, children: [
4126
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center justify-between gap-3 pb-1 border-b border-sas-border", children: [
4127
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("p", { className: "text-[11px] text-sas-muted leading-snug min-w-0", children: [
4128
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-text", children: fromLabel }),
4129
+ " \u2192",
4130
+ " ",
4131
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-text", children: toLabel }),
4132
+ familyLabel ? ` \xB7 ${familyLabel}` : "",
4133
+ " \xB7 line up a track on each side to crossfade; leave one blank (or insert a gap) to fade."
4134
+ ] }),
4135
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center gap-2 shrink-0", children: [
4136
+ creatingKeys.size > 0 && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] text-sas-accent whitespace-nowrap", "data-testid": `${testIdPrefix}-creating-count`, children: [
4137
+ creatingKeys.size,
4138
+ " creating\u2026"
4139
+ ] }),
4140
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4141
+ "button",
4142
+ {
4143
+ type: "button",
4144
+ "data-testid": `${testIdPrefix}-create-all`,
4145
+ onClick: createAll,
4146
+ disabled: eligibleCount === 0,
4147
+ title: "Create every staged transition at once (runs several concurrently)",
4148
+ className: `px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors whitespace-nowrap ${eligibleCount > 0 ? "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"}`,
4149
+ children: [
4150
+ "Create all",
4151
+ eligibleCount > 0 ? ` (${eligibleCount})` : ""
4152
+ ]
4153
+ }
4154
+ )
4155
+ ] })
4156
+ ] }),
4157
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "grid grid-cols-[1fr_auto_1fr] gap-2", children: [
4158
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate", children: [
4159
+ "Origin (",
4160
+ fromLabel,
4161
+ ")"
4162
+ ] }),
4163
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted text-center px-2", children: "Transition" }),
4164
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate text-right", children: [
4165
+ "Target (",
4166
+ toLabel,
4167
+ ")"
4168
+ ] })
4169
+ ] }),
4170
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-muted py-6 text-center", children: "Loading tracks\u2026" }),
4171
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-danger py-6 text-center", "data-testid": `${testIdPrefix}-error`, children: load.message }),
4172
+ load.status === "ready" && (rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "text-xs text-sas-muted py-6 text-center", "data-testid": `${testIdPrefix}-empty`, children: [
4173
+ "No tracks to arrange in this panel for either scene. Add tracks to ",
4174
+ fromLabel,
4175
+ " or ",
4176
+ toLabel,
4177
+ " ",
4178
+ "first (or free one by deleting an existing crossfade/fade)."
4179
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "space-y-2", children: rows.map((row, i) => {
4180
+ const key = rowKey(row);
4181
+ const isCreatingThis = key !== null && creatingKeys.has(key);
4182
+ const errMsg = key !== null ? rowErrors[key] : void 0;
4183
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4184
+ "div",
4185
+ {
4186
+ "data-testid": `${testIdPrefix}-row-${i}`,
4187
+ className: "grid grid-cols-[1fr_auto_1fr] gap-2 items-center",
4188
+ children: [
4189
+ renderCell("origin", i, row.originId),
4190
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "w-[160px] flex flex-col items-center gap-1", children: [
4191
+ !row.type ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] text-sas-muted/50", children: "\u2014" }) : row.type === "crossfade" ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4192
+ "span",
4193
+ {
4194
+ "data-testid": `${testIdPrefix}-type-${i}`,
4195
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-accent/50 text-sas-accent",
4196
+ children: TYPE_LABEL[row.type]
4197
+ }
4198
+ ) : audioEffectsEnabled ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center gap-1", "data-testid": `${testIdPrefix}-type-${i}`, children: [
4199
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4200
+ "select",
4201
+ {
4202
+ "data-testid": `${testIdPrefix}-effect-${i}`,
4203
+ value: rowEffects[row.originId ?? row.targetId] ?? "fade",
4204
+ onChange: (e) => {
4205
+ const id = row.originId ?? row.targetId;
4206
+ if (id) setRowEffect(id, e.target.value);
4207
+ },
4208
+ className: "text-[10px] bg-sas-panel border border-sas-border rounded-sm px-1 py-0.5 text-sas-text",
4209
+ children: AUDIO_EFFECTS.map((eff) => /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("option", { value: eff, children: AUDIO_EFFECT_LABEL[eff] }, eff))
4210
+ }
4211
+ ),
4212
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[9px] text-sas-muted", children: row.type === "fade-out" ? "out" : "in" })
4213
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4214
+ "span",
4215
+ {
4216
+ "data-testid": `${testIdPrefix}-type-${i}`,
4217
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-border text-sas-muted",
4218
+ children: TYPE_LABEL[row.type]
4219
+ }
4220
+ ),
4221
+ isCreatingThis ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "w-full", children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4222
+ SorceryProgressBar,
4223
+ {
4224
+ isLoading: true,
4225
+ heightClass: "h-5",
4226
+ statusText: "CREATING",
4227
+ estimatedDurationMs: row.type === "crossfade" ? CROSSFADE_ESTIMATE_MS : FADE_ESTIMATE_MS
4228
+ }
4229
+ ) }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4230
+ "button",
4231
+ {
4232
+ type: "button",
4233
+ "data-testid": `${testIdPrefix}-create-${i}`,
4234
+ onClick: () => createRow(row),
4235
+ disabled: !row.type,
4236
+ className: `w-full px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors ${row.type ? "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"}`,
4237
+ children: "Create"
4238
+ }
4239
+ ),
4240
+ errMsg && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4241
+ "span",
4242
+ {
4243
+ "data-testid": `${testIdPrefix}-row-error-${i}`,
4244
+ className: "text-[10px] text-sas-danger text-center leading-tight",
4245
+ children: errMsg
4246
+ }
4247
+ )
4248
+ ] }),
4249
+ renderCell("target", i, row.targetId)
4250
+ ]
4251
+ },
4252
+ i
4253
+ );
4254
+ }) }))
4255
+ ] });
4256
+ }
4257
+
3040
4258
  // src/components/DownloadPackButton.tsx
3041
- var import_react13 = require("react");
3042
- var import_jsx_runtime15 = require("react/jsx-runtime");
4259
+ var import_react17 = require("react");
4260
+ var import_jsx_runtime18 = require("react/jsx-runtime");
3043
4261
  function formatSize(bytes) {
3044
4262
  if (!bytes || bytes <= 0) return "";
3045
4263
  const gb = bytes / 1024 ** 3;
@@ -3055,10 +4273,10 @@ var DownloadPackButton = ({
3055
4273
  variant = "compact",
3056
4274
  onDownloadComplete
3057
4275
  }) => {
3058
- const [status, setStatus] = (0, import_react13.useState)("idle");
3059
- const [progress, setProgress] = (0, import_react13.useState)(0);
3060
- const [errorMessage, setErrorMessage] = (0, import_react13.useState)(null);
3061
- (0, import_react13.useEffect)(() => {
4276
+ const [status, setStatus] = (0, import_react17.useState)("idle");
4277
+ const [progress, setProgress] = (0, import_react17.useState)(0);
4278
+ const [errorMessage, setErrorMessage] = (0, import_react17.useState)(null);
4279
+ (0, import_react17.useEffect)(() => {
3062
4280
  const unsub = host.onSamplePackProgress(packId, (p) => {
3063
4281
  setStatus(p.status);
3064
4282
  setProgress(p.progress);
@@ -3073,7 +4291,7 @@ var DownloadPackButton = ({
3073
4291
  });
3074
4292
  return unsub;
3075
4293
  }, [host, packId, onDownloadComplete]);
3076
- const handleClick = (0, import_react13.useCallback)(async () => {
4294
+ const handleClick = (0, import_react17.useCallback)(async () => {
3077
4295
  if (status !== "idle" && status !== "error") return;
3078
4296
  try {
3079
4297
  setStatus("downloading");
@@ -3127,8 +4345,8 @@ var DownloadPackButton = ({
3127
4345
  } else {
3128
4346
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3129
4347
  }
3130
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
3131
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
4348
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { children: [
4349
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3132
4350
  "button",
3133
4351
  {
3134
4352
  "data-testid": `download-pack-button-${packId}`,
@@ -3139,12 +4357,12 @@ var DownloadPackButton = ({
3139
4357
  children: buttonLabel
3140
4358
  }
3141
4359
  ),
3142
- 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 })
4360
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3143
4361
  ] });
3144
4362
  };
3145
4363
 
3146
4364
  // src/components/SamplePackCTACard.tsx
3147
- var import_jsx_runtime16 = require("react/jsx-runtime");
4365
+ var import_jsx_runtime19 = require("react/jsx-runtime");
3148
4366
  var SamplePackCTACard = ({
3149
4367
  host,
3150
4368
  pack,
@@ -3152,7 +4370,7 @@ var SamplePackCTACard = ({
3152
4370
  onDownloadComplete
3153
4371
  }) => {
3154
4372
  if (status === "checking") {
3155
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4373
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
3156
4374
  "div",
3157
4375
  {
3158
4376
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -3163,16 +4381,16 @@ var SamplePackCTACard = ({
3163
4381
  }
3164
4382
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3165
4383
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3166
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
4384
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
3167
4385
  "div",
3168
4386
  {
3169
4387
  "data-testid": `sample-pack-cta-${pack.packId}`,
3170
4388
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3171
4389
  children: [
3172
- /* @__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" }),
3173
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3174
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3175
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4390
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
4391
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
4392
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
4393
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
3176
4394
  DownloadPackButton,
3177
4395
  {
3178
4396
  host,
@@ -3189,7 +4407,7 @@ var SamplePackCTACard = ({
3189
4407
  };
3190
4408
 
3191
4409
  // src/components/WaveformView.tsx
3192
- var import_react14 = require("react");
4410
+ var import_react18 = require("react");
3193
4411
 
3194
4412
  // src/components/waveform.ts
3195
4413
  function computePeaks(audioBuffer, bins, targetSamples) {
@@ -3252,7 +4470,7 @@ function drawWaveform(canvas, peaks, options = {}) {
3252
4470
  }
3253
4471
 
3254
4472
  // src/components/WaveformView.tsx
3255
- var import_jsx_runtime17 = require("react/jsx-runtime");
4473
+ var import_jsx_runtime20 = require("react/jsx-runtime");
3256
4474
  var WaveformView = ({
3257
4475
  host,
3258
4476
  filePath,
@@ -3261,9 +4479,9 @@ var WaveformView = ({
3261
4479
  fillStyle,
3262
4480
  targetSamples
3263
4481
  }) => {
3264
- const canvasRef = (0, import_react14.useRef)(null);
3265
- const [peaks, setPeaks] = (0, import_react14.useState)(null);
3266
- (0, import_react14.useEffect)(() => {
4482
+ const canvasRef = (0, import_react18.useRef)(null);
4483
+ const [peaks, setPeaks] = (0, import_react18.useState)(null);
4484
+ (0, import_react18.useEffect)(() => {
3267
4485
  let cancelled = false;
3268
4486
  let audioContext = null;
3269
4487
  (async () => {
@@ -3289,7 +4507,7 @@ var WaveformView = ({
3289
4507
  cancelled = true;
3290
4508
  };
3291
4509
  }, [host, filePath, bins, targetSamples]);
3292
- (0, import_react14.useEffect)(() => {
4510
+ (0, import_react18.useEffect)(() => {
3293
4511
  if (!peaks) return;
3294
4512
  const canvas = canvasRef.current;
3295
4513
  if (!canvas) return;
@@ -3300,7 +4518,7 @@ var WaveformView = ({
3300
4518
  observer.observe(canvas);
3301
4519
  return () => observer.disconnect();
3302
4520
  }, [peaks, fillStyle]);
3303
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4521
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
3304
4522
  "canvas",
3305
4523
  {
3306
4524
  ref: canvasRef,
@@ -3311,8 +4529,8 @@ var WaveformView = ({
3311
4529
  };
3312
4530
 
3313
4531
  // src/components/ScrollingWaveform.tsx
3314
- var import_react15 = require("react");
3315
- var import_jsx_runtime18 = require("react/jsx-runtime");
4532
+ var import_react19 = require("react");
4533
+ var import_jsx_runtime21 = require("react/jsx-runtime");
3316
4534
  var ScrollingWaveform = ({
3317
4535
  getPeakDb,
3318
4536
  active,
@@ -3320,11 +4538,11 @@ var ScrollingWaveform = ({
3320
4538
  className,
3321
4539
  fillStyle
3322
4540
  }) => {
3323
- const canvasRef = (0, import_react15.useRef)(null);
3324
- const ringRef = (0, import_react15.useRef)(new Float32Array(columns));
3325
- const writeIdxRef = (0, import_react15.useRef)(0);
3326
- const rafRef = (0, import_react15.useRef)(null);
3327
- (0, import_react15.useEffect)(() => {
4541
+ const canvasRef = (0, import_react19.useRef)(null);
4542
+ const ringRef = (0, import_react19.useRef)(new Float32Array(columns));
4543
+ const writeIdxRef = (0, import_react19.useRef)(0);
4544
+ const rafRef = (0, import_react19.useRef)(null);
4545
+ (0, import_react19.useEffect)(() => {
3328
4546
  if (ringRef.current.length !== columns) {
3329
4547
  const next = new Float32Array(columns);
3330
4548
  const prev = ringRef.current;
@@ -3336,7 +4554,7 @@ var ScrollingWaveform = ({
3336
4554
  writeIdxRef.current = writeIdxRef.current % columns;
3337
4555
  }
3338
4556
  }, [columns]);
3339
- (0, import_react15.useEffect)(() => {
4557
+ (0, import_react19.useEffect)(() => {
3340
4558
  if (!active) {
3341
4559
  if (rafRef.current !== null) {
3342
4560
  cancelAnimationFrame(rafRef.current);
@@ -3388,7 +4606,7 @@ var ScrollingWaveform = ({
3388
4606
  }
3389
4607
  };
3390
4608
  }, [active, getPeakDb, fillStyle]);
3391
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
4609
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3392
4610
  "canvas",
3393
4611
  {
3394
4612
  ref: canvasRef,
@@ -3399,8 +4617,8 @@ var ScrollingWaveform = ({
3399
4617
  };
3400
4618
 
3401
4619
  // src/components/OffsetScrubber.tsx
3402
- var import_react16 = require("react");
3403
- var import_jsx_runtime19 = require("react/jsx-runtime");
4620
+ var import_react20 = require("react");
4621
+ var import_jsx_runtime22 = require("react/jsx-runtime");
3404
4622
  var SLIDER_HEIGHT_PX = 28;
3405
4623
  var TICK_HEIGHT_PX = 14;
3406
4624
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -3413,40 +4631,40 @@ function OffsetScrubber({
3413
4631
  onChange,
3414
4632
  disabled = false
3415
4633
  }) {
3416
- const trackRef = (0, import_react16.useRef)(null);
3417
- const [draftOffset, setDraftOffset] = (0, import_react16.useState)(offsetSamples);
3418
- const [isDragging, setIsDragging] = (0, import_react16.useState)(false);
3419
- (0, import_react16.useEffect)(() => {
4634
+ const trackRef = (0, import_react20.useRef)(null);
4635
+ const [draftOffset, setDraftOffset] = (0, import_react20.useState)(offsetSamples);
4636
+ const [isDragging, setIsDragging] = (0, import_react20.useState)(false);
4637
+ (0, import_react20.useEffect)(() => {
3420
4638
  if (!isDragging) setDraftOffset(offsetSamples);
3421
4639
  }, [offsetSamples, isDragging]);
3422
4640
  const sampleRate = cuePoints?.sample_rate ?? 44100;
3423
4641
  const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3424
- const beatsForRange = (0, import_react16.useMemo)(() => {
4642
+ const beatsForRange = (0, import_react20.useMemo)(() => {
3425
4643
  return Math.round(60 / projectBpm * sampleRate);
3426
4644
  }, [projectBpm, sampleRate]);
3427
4645
  const rangeSamples = beatsForRange * meter;
3428
- const sampleToFraction = (0, import_react16.useCallback)(
4646
+ const sampleToFraction = (0, import_react20.useCallback)(
3429
4647
  (sample) => {
3430
4648
  const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3431
4649
  return (clamped + rangeSamples) / (2 * rangeSamples);
3432
4650
  },
3433
4651
  [rangeSamples]
3434
4652
  );
3435
- const fractionToSample = (0, import_react16.useCallback)(
4653
+ const fractionToSample = (0, import_react20.useCallback)(
3436
4654
  (fraction) => {
3437
4655
  const clamped = Math.max(0, Math.min(1, fraction));
3438
4656
  return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3439
4657
  },
3440
4658
  [rangeSamples]
3441
4659
  );
3442
- const snapTargets = (0, import_react16.useMemo)(() => {
4660
+ const snapTargets = (0, import_react20.useMemo)(() => {
3443
4661
  if (!cuePoints || cuePoints.beats.length === 0) return [];
3444
4662
  const downbeat = cuePoints.beats[0];
3445
4663
  const positives = cuePoints.beats.map((b) => b - downbeat);
3446
4664
  const negatives = positives.slice(1).map((p) => -p);
3447
4665
  return [...negatives, ...positives].sort((a, b) => a - b);
3448
4666
  }, [cuePoints]);
3449
- const snapToBeat = (0, import_react16.useCallback)(
4667
+ const snapToBeat = (0, import_react20.useCallback)(
3450
4668
  (sample) => {
3451
4669
  if (snapTargets.length === 0) return sample;
3452
4670
  let best = snapTargets[0];
@@ -3462,7 +4680,7 @@ function OffsetScrubber({
3462
4680
  },
3463
4681
  [snapTargets]
3464
4682
  );
3465
- const handlePointerDown = (0, import_react16.useCallback)(
4683
+ const handlePointerDown = (0, import_react20.useCallback)(
3466
4684
  (e) => {
3467
4685
  if (disabled || !cuePoints) return;
3468
4686
  e.preventDefault();
@@ -3496,7 +4714,7 @@ function OffsetScrubber({
3496
4714
  },
3497
4715
  [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3498
4716
  );
3499
- const handleResetToZero = (0, import_react16.useCallback)(() => {
4717
+ const handleResetToZero = (0, import_react20.useCallback)(() => {
3500
4718
  if (disabled) return;
3501
4719
  setDraftOffset(0);
3502
4720
  onChange(0);
@@ -3504,7 +4722,7 @@ function OffsetScrubber({
3504
4722
  const thumbFraction = sampleToFraction(draftOffset);
3505
4723
  const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3506
4724
  const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3507
- const ticks = (0, import_react16.useMemo)(() => {
4725
+ const ticks = (0, import_react20.useMemo)(() => {
3508
4726
  if (!cuePoints) return [];
3509
4727
  const downbeat = cuePoints.beats[0] ?? 0;
3510
4728
  return cuePoints.beats.map((b, i) => {
@@ -3515,9 +4733,9 @@ function OffsetScrubber({
3515
4733
  });
3516
4734
  }, [cuePoints, sampleToFraction]);
3517
4735
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3518
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3519
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3520
- /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
4736
+ return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4737
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4738
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
3521
4739
  "div",
3522
4740
  {
3523
4741
  ref: trackRef,
@@ -3533,7 +4751,7 @@ function OffsetScrubber({
3533
4751
  "aria-valuenow": draftOffset,
3534
4752
  "aria-disabled": isDisabled,
3535
4753
  children: [
3536
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4754
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3537
4755
  "div",
3538
4756
  {
3539
4757
  "aria-hidden": "true",
@@ -3541,7 +4759,7 @@ function OffsetScrubber({
3541
4759
  style: { left: "50%" }
3542
4760
  }
3543
4761
  ),
3544
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4762
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3545
4763
  "div",
3546
4764
  {
3547
4765
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -3556,7 +4774,7 @@ function OffsetScrubber({
3556
4774
  },
3557
4775
  t.i
3558
4776
  )),
3559
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4777
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3560
4778
  "div",
3561
4779
  {
3562
4780
  "data-testid": "offset-scrubber-thumb",
@@ -3573,7 +4791,7 @@ function OffsetScrubber({
3573
4791
  ]
3574
4792
  }
3575
4793
  ),
3576
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4794
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3577
4795
  "span",
3578
4796
  {
3579
4797
  "data-testid": "offset-scrubber-readout",
@@ -3581,7 +4799,7 @@ function OffsetScrubber({
3581
4799
  children: formatOffset(draftOffset, sampleRate)
3582
4800
  }
3583
4801
  ),
3584
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4802
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3585
4803
  "button",
3586
4804
  {
3587
4805
  type: "button",
@@ -3593,7 +4811,7 @@ function OffsetScrubber({
3593
4811
  children: "\u2316"
3594
4812
  }
3595
4813
  ),
3596
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4814
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
3597
4815
  "span",
3598
4816
  {
3599
4817
  "data-testid": "offset-bpm-mismatch",
@@ -3665,13 +4883,13 @@ function synthesizeCuePoints({
3665
4883
  }
3666
4884
 
3667
4885
  // src/hooks/useSceneState.ts
3668
- var import_react17 = require("react");
4886
+ var import_react21 = require("react");
3669
4887
  function useSceneState(activeSceneId, initialValue) {
3670
- const [stateMap, setStateMap] = (0, import_react17.useState)(() => /* @__PURE__ */ new Map());
3671
- const activeSceneIdRef = (0, import_react17.useRef)(activeSceneId);
4888
+ const [stateMap, setStateMap] = (0, import_react21.useState)(() => /* @__PURE__ */ new Map());
4889
+ const activeSceneIdRef = (0, import_react21.useRef)(activeSceneId);
3672
4890
  activeSceneIdRef.current = activeSceneId;
3673
4891
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
3674
- const setForCurrentScene = (0, import_react17.useCallback)((value) => {
4892
+ const setForCurrentScene = (0, import_react21.useCallback)((value) => {
3675
4893
  const sid = activeSceneIdRef.current;
3676
4894
  if (sid === null) return;
3677
4895
  setStateMap((prev) => {
@@ -3682,7 +4900,7 @@ function useSceneState(activeSceneId, initialValue) {
3682
4900
  return newMap;
3683
4901
  });
3684
4902
  }, [initialValue]);
3685
- const setForScene = (0, import_react17.useCallback)((sceneId, value) => {
4903
+ const setForScene = (0, import_react21.useCallback)((sceneId, value) => {
3686
4904
  setStateMap((prev) => {
3687
4905
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
3688
4906
  const next = typeof value === "function" ? value(current) : value;
@@ -3695,10 +4913,10 @@ function useSceneState(activeSceneId, initialValue) {
3695
4913
  }
3696
4914
 
3697
4915
  // src/hooks/useAnySolo.ts
3698
- var import_react18 = require("react");
4916
+ var import_react22 = require("react");
3699
4917
  function useAnySolo(host) {
3700
- const [anySolo, setAnySolo] = (0, import_react18.useState)(false);
3701
- (0, import_react18.useEffect)(() => {
4918
+ const [anySolo, setAnySolo] = (0, import_react22.useState)(false);
4919
+ (0, import_react22.useEffect)(() => {
3702
4920
  let active = true;
3703
4921
  const refresh = () => {
3704
4922
  host.isAnySoloActive().then((v) => {
@@ -3717,7 +4935,7 @@ function useAnySolo(host) {
3717
4935
  }
3718
4936
 
3719
4937
  // src/hooks/useSoundHistory.ts
3720
- var import_react19 = require("react");
4938
+ var import_react23 = require("react");
3721
4939
  var EMPTY = { entries: [], cursor: -1 };
3722
4940
  function sameDescriptor(a, b) {
3723
4941
  if (a === b) return true;
@@ -3729,14 +4947,14 @@ function sameDescriptor(a, b) {
3729
4947
  }
3730
4948
  function useSoundHistory(applySound, opts = {}) {
3731
4949
  const max = Math.max(2, opts.max ?? 24);
3732
- const applyRef = (0, import_react19.useRef)(applySound);
4950
+ const applyRef = (0, import_react23.useRef)(applySound);
3733
4951
  applyRef.current = applySound;
3734
- const onChangeRef = (0, import_react19.useRef)(opts.onChange);
4952
+ const onChangeRef = (0, import_react23.useRef)(opts.onChange);
3735
4953
  onChangeRef.current = opts.onChange;
3736
- const dataRef = (0, import_react19.useRef)({});
3737
- const [, setVersion] = (0, import_react19.useState)(0);
3738
- const bump = (0, import_react19.useCallback)(() => setVersion((v) => v + 1), []);
3739
- const commit = (0, import_react19.useCallback)(
4954
+ const dataRef = (0, import_react23.useRef)({});
4955
+ const [, setVersion] = (0, import_react23.useState)(0);
4956
+ const bump = (0, import_react23.useCallback)(() => setVersion((v) => v + 1), []);
4957
+ const commit = (0, import_react23.useCallback)(
3740
4958
  (trackId, next, notify) => {
3741
4959
  dataRef.current = { ...dataRef.current, [trackId]: next };
3742
4960
  bump();
@@ -3744,7 +4962,7 @@ function useSoundHistory(applySound, opts = {}) {
3744
4962
  },
3745
4963
  [bump]
3746
4964
  );
3747
- const record = (0, import_react19.useCallback)(
4965
+ const record = (0, import_react23.useCallback)(
3748
4966
  (trackId, descriptor, label) => {
3749
4967
  const h = dataRef.current[trackId];
3750
4968
  const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
@@ -3759,7 +4977,7 @@ function useSoundHistory(applySound, opts = {}) {
3759
4977
  },
3760
4978
  [max, commit]
3761
4979
  );
3762
- const restoreTo = (0, import_react19.useCallback)(
4980
+ const restoreTo = (0, import_react23.useCallback)(
3763
4981
  async (trackId, index) => {
3764
4982
  const h = dataRef.current[trackId];
3765
4983
  if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
@@ -3769,7 +4987,7 @@ function useSoundHistory(applySound, opts = {}) {
3769
4987
  },
3770
4988
  [commit]
3771
4989
  );
3772
- const undo = (0, import_react19.useCallback)(
4990
+ const undo = (0, import_react23.useCallback)(
3773
4991
  (trackId) => {
3774
4992
  const h = dataRef.current[trackId];
3775
4993
  if (!h || h.cursor <= 0) return Promise.resolve(false);
@@ -3777,7 +4995,7 @@ function useSoundHistory(applySound, opts = {}) {
3777
4995
  },
3778
4996
  [restoreTo]
3779
4997
  );
3780
- const toggleFavorite = (0, import_react19.useCallback)(
4998
+ const toggleFavorite = (0, import_react23.useCallback)(
3781
4999
  (trackId, index) => {
3782
5000
  const h = dataRef.current[trackId];
3783
5001
  if (!h || index < 0 || index >= h.entries.length) return;
@@ -3786,7 +5004,7 @@ function useSoundHistory(applySound, opts = {}) {
3786
5004
  },
3787
5005
  [commit]
3788
5006
  );
3789
- const restore = (0, import_react19.useCallback)(
5007
+ const restore = (0, import_react23.useCallback)(
3790
5008
  (trackId, state) => {
3791
5009
  const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3792
5010
  const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
@@ -3795,15 +5013,15 @@ function useSoundHistory(applySound, opts = {}) {
3795
5013
  },
3796
5014
  [commit]
3797
5015
  );
3798
- const list = (0, import_react19.useCallback)(
5016
+ const list = (0, import_react23.useCallback)(
3799
5017
  (trackId) => dataRef.current[trackId] ?? EMPTY,
3800
5018
  []
3801
5019
  );
3802
- const canUndo = (0, import_react19.useCallback)((trackId) => {
5020
+ const canUndo = (0, import_react23.useCallback)((trackId) => {
3803
5021
  const h = dataRef.current[trackId];
3804
5022
  return !!h && h.cursor > 0;
3805
5023
  }, []);
3806
- const clear = (0, import_react19.useCallback)(
5024
+ const clear = (0, import_react23.useCallback)(
3807
5025
  (trackId) => {
3808
5026
  if (dataRef.current[trackId]) {
3809
5027
  const next = { ...dataRef.current };
@@ -3815,102 +5033,18 @@ function useSoundHistory(applySound, opts = {}) {
3815
5033
  },
3816
5034
  [bump]
3817
5035
  );
3818
- const reset = (0, import_react19.useCallback)(() => {
5036
+ const reset = (0, import_react23.useCallback)(() => {
3819
5037
  dataRef.current = {};
3820
5038
  bump();
3821
5039
  }, [bump]);
3822
- return (0, import_react19.useMemo)(
5040
+ return (0, import_react23.useMemo)(
3823
5041
  () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3824
5042
  [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3825
5043
  );
3826
5044
  }
3827
5045
 
3828
- // src/hooks/useTrackReorder.ts
3829
- var import_react20 = require("react");
3830
- function moveItem(arr, from, to) {
3831
- const next = arr.slice();
3832
- if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3833
- return next;
3834
- }
3835
- const [moved] = next.splice(from, 1);
3836
- next.splice(to, 0, moved);
3837
- return next;
3838
- }
3839
- function useTrackReorder({
3840
- host,
3841
- items,
3842
- setItems,
3843
- getId,
3844
- onError
3845
- }) {
3846
- const [draggingIndex, setDraggingIndex] = (0, import_react20.useState)(null);
3847
- const [dragOverIndex, setDragOverIndex] = (0, import_react20.useState)(null);
3848
- const fromRef = (0, import_react20.useRef)(null);
3849
- const itemsRef = (0, import_react20.useRef)(items);
3850
- itemsRef.current = items;
3851
- const dragPropsFor = (0, import_react20.useCallback)(
3852
- (index) => ({
3853
- handleProps: {
3854
- draggable: true,
3855
- onDragStart: (e) => {
3856
- fromRef.current = index;
3857
- setDraggingIndex(index);
3858
- if (e.dataTransfer) {
3859
- e.dataTransfer.effectAllowed = "move";
3860
- try {
3861
- e.dataTransfer.setData("text/plain", String(index));
3862
- } catch {
3863
- }
3864
- }
3865
- },
3866
- onDragEnd: () => {
3867
- fromRef.current = null;
3868
- setDraggingIndex(null);
3869
- setDragOverIndex(null);
3870
- }
3871
- },
3872
- rowProps: {
3873
- onDragEnter: (e) => {
3874
- if (fromRef.current === null) return;
3875
- e.preventDefault();
3876
- setDragOverIndex(index);
3877
- },
3878
- onDragOver: (e) => {
3879
- if (fromRef.current === null) return;
3880
- e.preventDefault();
3881
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3882
- setDragOverIndex((cur) => cur === index ? cur : index);
3883
- },
3884
- onDragLeave: () => {
3885
- setDragOverIndex((cur) => cur === index ? null : cur);
3886
- },
3887
- onDrop: (e) => {
3888
- e.preventDefault();
3889
- const from = fromRef.current;
3890
- fromRef.current = null;
3891
- setDraggingIndex(null);
3892
- setDragOverIndex(null);
3893
- if (from === null || from === index) return;
3894
- const prev = itemsRef.current;
3895
- const next = moveItem(prev, from, index);
3896
- setItems(next);
3897
- const ids = next.map(getId);
3898
- Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3899
- setItems(prev);
3900
- onError?.(err);
3901
- });
3902
- }
3903
- },
3904
- isDragging: draggingIndex === index,
3905
- isDragTarget: dragOverIndex === index && draggingIndex !== index
3906
- }),
3907
- [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3908
- );
3909
- return { dragPropsFor, draggingIndex, dragOverIndex };
3910
- }
3911
-
3912
5046
  // src/constants/sdk-version.ts
3913
- var PLUGIN_SDK_VERSION = "2.26.0";
5047
+ var PLUGIN_SDK_VERSION = "2.34.0";
3914
5048
 
3915
5049
  // src/utils/format-concurrent-tracks.ts
3916
5050
  function formatConcurrentTracks(ctx) {
@@ -4054,6 +5188,8 @@ function pickTopKWeighted(scored, options = {}) {
4054
5188
  }
4055
5189
  // Annotate the CommonJS export names for ESM import in node:
4056
5190
  0 && (module.exports = {
5191
+ AUDIO_EFFECTS,
5192
+ AUDIO_EFFECT_LABEL,
4057
5193
  ConfirmDialog,
4058
5194
  CrossfadeModal,
4059
5195
  CrossfadeTrackRow,
@@ -4071,6 +5207,8 @@ function pickTopKWeighted(scored, options = {}) {
4071
5207
  FX_DISPLAY_LABELS,
4072
5208
  FX_ENGINE_PLUGIN_NAMES,
4073
5209
  FX_PRESET_CONFIGS,
5210
+ FadeModal,
5211
+ FadeTrackRow,
4074
5212
  FxToggleBar,
4075
5213
  GUTTER_W,
4076
5214
  ImportTrackModal,
@@ -4089,30 +5227,50 @@ function pickTopKWeighted(scored, options = {}) {
4089
5227
  SamplePackCTACard,
4090
5228
  ScrollingWaveform,
4091
5229
  SorceryProgressBar,
5230
+ TEXTURAL_ROLES,
5231
+ TRANSITION_DESIGNER_DRAFT_KEY,
4092
5232
  TrackDrawer,
4093
5233
  TrackMeterStrip,
4094
5234
  TrackRow,
5235
+ TransitionDesigner,
4095
5236
  VolumeSlider,
4096
5237
  WaveformView,
4097
5238
  analyzeWavPeak,
5239
+ asAudioEffect,
4098
5240
  asCrossfadeMeta,
5241
+ asFadeMeta,
5242
+ asTransitionDesignerDraft,
4099
5243
  buildCrossfadeInpaintPrompt,
4100
5244
  buildCrossfadeVolumeCurves,
5245
+ buildFadeVolumeCurve,
5246
+ buildRowSlots,
4101
5247
  calculateTimeBasedTarget,
4102
5248
  cellToPx,
4103
5249
  centerScrollTop,
4104
5250
  computePeaks,
5251
+ dbIdsFromKeys,
4105
5252
  dbToSlider,
5253
+ defaultFadeGesture,
4106
5254
  drawWaveform,
4107
5255
  formatConcurrentTracks,
5256
+ hashString,
4108
5257
  moveItem,
5258
+ normalizeSlots,
5259
+ padPair,
5260
+ padSlots,
4109
5261
  parseCrossfadePairs,
5262
+ parseFades,
4110
5263
  pickTopKWeighted,
4111
5264
  pitchToName,
4112
5265
  pxToCell,
5266
+ reconcileSlots,
4113
5267
  resizeNoteDuration,
5268
+ rowKey,
5269
+ rowType,
4114
5270
  scorePromptMatch,
4115
5271
  sliderToDb,
5272
+ slotsEqual,
5273
+ soundIdentity,
4116
5274
  synthesizeCuePoints,
4117
5275
  tokenizePrompt,
4118
5276
  transposeNotes,