@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.mjs CHANGED
@@ -1343,6 +1343,7 @@ var LevelMeter = ({
1343
1343
 
1344
1344
  // src/hooks/useTrackLevels.ts
1345
1345
  import { useEffect as useEffect2, useRef as useRef3, useState as useState3 } from "react";
1346
+ var meterDiagRLast = /* @__PURE__ */ new Map();
1346
1347
  var POLL_INTERVAL_MS = 33;
1347
1348
  var HIDDEN_RECHECK_MS = 250;
1348
1349
  var METER_FLOOR_DB = -120;
@@ -1474,6 +1475,11 @@ function useTrackMeter(handle, trackId) {
1474
1475
  }
1475
1476
  const update = () => {
1476
1477
  const level = handle.getLevel(trackId);
1478
+ const dNow = Date.now();
1479
+ if ((meterDiagRLast.get(trackId) ?? 0) < dNow - 3e3) {
1480
+ meterDiagRLast.set(trackId, dNow);
1481
+ console.log(`[MeterDiagR] lookup trackId=${trackId} \u2192 ${level === null ? "MISS (no level for this id)" : "hit"}`);
1482
+ }
1477
1483
  const now = performance.now();
1478
1484
  const dtSec = lastTickRef.current ? Math.max(0, (now - lastTickRef.current) / 1e3) : 0;
1479
1485
  lastTickRef.current = now;
@@ -2175,8 +2181,7 @@ function TrackRow({
2175
2181
  {
2176
2182
  "data-testid": "sdk-mute-button",
2177
2183
  onClick: onMuteToggle,
2178
- disabled: isGenerating,
2179
- 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"}`,
2184
+ 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"}`,
2180
2185
  title: isMuted ? "Unmute track" : "Mute track",
2181
2186
  children: "M"
2182
2187
  }
@@ -2427,6 +2432,18 @@ function CrossfadeTrackRow({
2427
2432
  }
2428
2433
 
2429
2434
  // src/crossfade-meta.ts
2435
+ function hashString(s) {
2436
+ let h = 5381;
2437
+ for (let i = 0; i < s.length; i++) h = (h << 5) + h ^ s.charCodeAt(i) | 0;
2438
+ return (h >>> 0).toString(36);
2439
+ }
2440
+ function soundIdentity(snap) {
2441
+ if (!snap) return "";
2442
+ if (snap.kind === "preset") return `p:${hashString(snap.state)}`;
2443
+ if (snap.kind === "sample") return `s:${snap.samplePath}`;
2444
+ if (snap.kind === "instrument") return `i:${snap.instrumentId ?? ""}:${hashString(JSON.stringify(snap.zones))}`;
2445
+ return "";
2446
+ }
2430
2447
  var EQUAL_POWER_GAIN = 0.707;
2431
2448
  function asCrossfadeMeta(val) {
2432
2449
  if (!val || typeof val !== "object") return null;
@@ -2570,9 +2587,452 @@ function buildCrossfadeInpaintPrompt(input) {
2570
2587
  return lines.join("\n");
2571
2588
  }
2572
2589
 
2590
+ // src/fade-meta.ts
2591
+ function asFadeMeta(val) {
2592
+ if (!val || typeof val !== "object") return null;
2593
+ const m = val;
2594
+ if (m.direction !== "in" && m.direction !== "out") return null;
2595
+ if (m.gesture !== "volume" && m.gesture !== "build") return null;
2596
+ const effect = m.effect === "stutter" || m.effect === "chopped" || m.effect === "delay" || m.effect === "fade" ? m.effect : void 0;
2597
+ return {
2598
+ direction: m.direction,
2599
+ gesture: m.gesture,
2600
+ effect,
2601
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2602
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2603
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2604
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2605
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2606
+ };
2607
+ }
2608
+ function parseFades(sceneData) {
2609
+ const out = [];
2610
+ for (const [key, val] of Object.entries(sceneData)) {
2611
+ const match = /^track:(.+):fade$/.exec(key);
2612
+ if (!match) continue;
2613
+ const meta = asFadeMeta(val);
2614
+ if (!meta) continue;
2615
+ out.push({ dbId: match[1], meta });
2616
+ }
2617
+ return out;
2618
+ }
2619
+ function buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture, steps = 32) {
2620
+ const durationSeconds = bars * 4 * 60 / Math.max(1, bpm);
2621
+ if (gesture === "build") {
2622
+ return [
2623
+ { time: 0, db: 0 },
2624
+ { time: Math.round(durationSeconds * 1e3) / 1e3, db: 0 }
2625
+ ];
2626
+ }
2627
+ const s = Math.min(0.98, Math.max(0.02, sliderPos));
2628
+ const round = (n) => Math.round(n * 1e3) / 1e3;
2629
+ const points = [];
2630
+ for (let i = 0; i <= steps; i++) {
2631
+ const x = i / steps;
2632
+ const time = round(x * durationSeconds);
2633
+ const theta = x <= s ? x / s * (Math.PI / 4) : Math.PI / 4 + (x - s) / (1 - s) * (Math.PI / 4);
2634
+ const gain = direction === "out" ? Math.cos(theta) : Math.sin(theta);
2635
+ points.push({ time, db: Math.round(gainToDb(gain) * 100) / 100 });
2636
+ }
2637
+ return points;
2638
+ }
2639
+ var TEXTURAL_ROLES = /* @__PURE__ */ new Set([
2640
+ "pads",
2641
+ "pad",
2642
+ "strings",
2643
+ "atmospheres",
2644
+ "atmosphere",
2645
+ "atmos",
2646
+ "drones",
2647
+ "drone",
2648
+ "soundscapes",
2649
+ "soundscape"
2650
+ ]);
2651
+ function defaultFadeGesture(role) {
2652
+ if (!role) return "build";
2653
+ const norm = role.toLowerCase().replace(/[\s_-]+/g, " ").trim();
2654
+ if (TEXTURAL_ROLES.has(norm)) return "volume";
2655
+ for (const token of norm.split(" ")) {
2656
+ if (TEXTURAL_ROLES.has(token)) return "volume";
2657
+ }
2658
+ return "build";
2659
+ }
2660
+
2661
+ // src/components/FadeTrackRow.tsx
2662
+ import React10 from "react";
2663
+ import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
2664
+ function FadeCaption({
2665
+ layer,
2666
+ direction,
2667
+ gesture
2668
+ }) {
2669
+ const tag = direction === "in" ? "Fade in" : "Fade out";
2670
+ return /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2671
+ /* @__PURE__ */ jsx13("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2672
+ /* @__PURE__ */ jsx13("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2673
+ layer.soundLabel && /* @__PURE__ */ jsxs9("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2674
+ "\xB7 ",
2675
+ layer.soundLabel
2676
+ ] }),
2677
+ /* @__PURE__ */ jsxs9("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", title: `Fade gesture: ${gesture}`, children: [
2678
+ "\xB7 ",
2679
+ gesture
2680
+ ] })
2681
+ ] });
2682
+ }
2683
+ function FadeTrackRow({
2684
+ layer,
2685
+ direction,
2686
+ gesture,
2687
+ effect,
2688
+ sliderPos = 0.5,
2689
+ onMuteToggle,
2690
+ onSoloToggle,
2691
+ onVolumeChange,
2692
+ onPanChange,
2693
+ onDelete,
2694
+ onSliderChange,
2695
+ levels,
2696
+ accentColor = "#9333EA"
2697
+ }) {
2698
+ const [confirmDelete, setConfirmDelete] = React10.useState(false);
2699
+ const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2700
+ const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2701
+ const verb = effect && effect !== "fade" ? effect.charAt(0).toUpperCase() + effect.slice(1) : "Fade";
2702
+ const badge = direction === "in" ? `\u2197 ${verb} in` : `\u2198 ${verb} out`;
2703
+ return /* @__PURE__ */ jsxs9(
2704
+ "div",
2705
+ {
2706
+ "data-testid": "fade-track-row",
2707
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2708
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2709
+ children: [
2710
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2711
+ /* @__PURE__ */ jsx13(
2712
+ "span",
2713
+ {
2714
+ "data-testid": "fade-direction-badge",
2715
+ className: "text-[10px] font-bold uppercase tracking-wide",
2716
+ style: { color: accentColor },
2717
+ children: badge
2718
+ }
2719
+ ),
2720
+ /* @__PURE__ */ jsx13(
2721
+ "button",
2722
+ {
2723
+ "data-testid": "fade-delete-button",
2724
+ onClick: () => setConfirmDelete(true),
2725
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2726
+ title: "Delete fade",
2727
+ "aria-label": "Delete fade",
2728
+ children: "x"
2729
+ }
2730
+ )
2731
+ ] }),
2732
+ /* @__PURE__ */ jsx13(
2733
+ TrackRow,
2734
+ {
2735
+ track: { id: layer.trackId, name: "", role: layer.role },
2736
+ runtimeState: layer.runtimeState,
2737
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2738
+ drawerOpen: false,
2739
+ drawerTab: "fx",
2740
+ levels,
2741
+ accentColor,
2742
+ contentSlot: /* @__PURE__ */ jsx13(FadeCaption, { layer, direction, gesture }),
2743
+ onMuteToggle,
2744
+ onSoloToggle,
2745
+ onVolumeChange,
2746
+ onPanChange
2747
+ }
2748
+ ),
2749
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "fade-slider-row", children: [
2750
+ /* @__PURE__ */ jsx13(
2751
+ "span",
2752
+ {
2753
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2754
+ title: leftLabel,
2755
+ children: leftLabel
2756
+ }
2757
+ ),
2758
+ /* @__PURE__ */ jsx13(
2759
+ "input",
2760
+ {
2761
+ type: "range",
2762
+ "data-testid": "fade-slider",
2763
+ min: 0,
2764
+ max: 1,
2765
+ step: 0.01,
2766
+ value: sliderPos,
2767
+ disabled: !onSliderChange,
2768
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2769
+ style: { accentColor },
2770
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2771
+ "aria-label": "Fade position"
2772
+ }
2773
+ ),
2774
+ /* @__PURE__ */ jsx13(
2775
+ "span",
2776
+ {
2777
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2778
+ title: rightLabel,
2779
+ children: rightLabel
2780
+ }
2781
+ )
2782
+ ] }),
2783
+ /* @__PURE__ */ jsx13(
2784
+ ConfirmDialog,
2785
+ {
2786
+ open: confirmDelete,
2787
+ title: "Delete fade?",
2788
+ message: /* @__PURE__ */ jsx13(Fragment3, { children: "This fade track will be permanently removed from this scene. This cannot be undone." }),
2789
+ confirmLabel: "Delete",
2790
+ onConfirm: () => {
2791
+ setConfirmDelete(false);
2792
+ onDelete();
2793
+ },
2794
+ onCancel: () => setConfirmDelete(false),
2795
+ testIdPrefix: "fade-delete-confirm"
2796
+ }
2797
+ )
2798
+ ]
2799
+ }
2800
+ );
2801
+ }
2802
+
2803
+ // src/components/FadeModal.tsx
2804
+ import { useCallback as useCallback4, useEffect as useEffect6, useMemo as useMemo3, useRef as useRef7, useState as useState7 } from "react";
2805
+ import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
2806
+ function shortId(dbId) {
2807
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
2808
+ }
2809
+ var normRole = (r) => (r ?? "").toLowerCase().trim();
2810
+ function computeOrphans(from, to, excludeSet) {
2811
+ const bucket = (list) => {
2812
+ const m = /* @__PURE__ */ new Map();
2813
+ for (const t of list) {
2814
+ const k = normRole(t.role);
2815
+ const arr = m.get(k);
2816
+ if (arr) arr.push(t);
2817
+ else m.set(k, [t]);
2818
+ }
2819
+ return m;
2820
+ };
2821
+ const fromByRole = bucket(from);
2822
+ const toByRole = bucket(to);
2823
+ const roles = /* @__PURE__ */ new Set([...fromByRole.keys(), ...toByRole.keys()]);
2824
+ const fadeOut = [];
2825
+ const fadeIn = [];
2826
+ for (const role of roles) {
2827
+ const f = fromByRole.get(role) ?? [];
2828
+ const t = toByRole.get(role) ?? [];
2829
+ const shared = Math.min(f.length, t.length);
2830
+ fadeOut.push(...f.slice(shared));
2831
+ fadeIn.push(...t.slice(shared));
2832
+ }
2833
+ return {
2834
+ fadeOut: fadeOut.filter((x) => !excludeSet.has(x.dbId)),
2835
+ fadeIn: fadeIn.filter((x) => !excludeSet.has(x.dbId))
2836
+ };
2837
+ }
2838
+ function OrphanRow({
2839
+ track,
2840
+ gesture,
2841
+ selected,
2842
+ disabled,
2843
+ onSelect,
2844
+ testId
2845
+ }) {
2846
+ const primary = track.prompt?.trim() || track.name;
2847
+ const meta = [track.role, shortId(track.dbId), gesture].filter(Boolean).join(" \xB7 ");
2848
+ return /* @__PURE__ */ jsxs10(
2849
+ "button",
2850
+ {
2851
+ type: "button",
2852
+ role: "radio",
2853
+ "aria-checked": selected,
2854
+ "data-testid": testId,
2855
+ "data-value": track.dbId,
2856
+ onClick: onSelect,
2857
+ disabled,
2858
+ 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"}`,
2859
+ children: [
2860
+ /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
2861
+ meta && /* @__PURE__ */ jsx14("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
2862
+ ]
2863
+ }
2864
+ );
2865
+ }
2866
+ function FadeModal({
2867
+ host,
2868
+ open,
2869
+ fromSceneId,
2870
+ toSceneId,
2871
+ fromSceneName,
2872
+ toSceneName,
2873
+ excludeSourceDbIds,
2874
+ onClose,
2875
+ onCreate,
2876
+ testIdPrefix = "fade-modal"
2877
+ }) {
2878
+ const [load, setLoad] = useState7({ status: "loading" });
2879
+ const [selectedDbId, setSelectedDbId] = useState7("");
2880
+ const [isCreating, setIsCreating] = useState7(false);
2881
+ const [error, setError] = useState7(null);
2882
+ const [fromName, setFromName] = useState7(null);
2883
+ const [toName, setToName] = useState7(null);
2884
+ const cancelRef = useRef7(null);
2885
+ const refresh = useCallback4(async () => {
2886
+ if (!host.listSceneFamilyTracks) {
2887
+ setLoad({ status: "error", message: "This host does not support fades." });
2888
+ return;
2889
+ }
2890
+ setLoad({ status: "loading" });
2891
+ try {
2892
+ const [from, to, fName, tName] = await Promise.all([
2893
+ host.listSceneFamilyTracks(fromSceneId),
2894
+ host.listSceneFamilyTracks(toSceneId),
2895
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
2896
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2897
+ ]);
2898
+ setFromName(fName);
2899
+ setToName(tName);
2900
+ setLoad({ status: "ready", from, to });
2901
+ } catch (err) {
2902
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2903
+ }
2904
+ }, [host, fromSceneId, toSceneId]);
2905
+ useEffect6(() => {
2906
+ if (open) {
2907
+ setError(null);
2908
+ setIsCreating(false);
2909
+ setSelectedDbId("");
2910
+ void refresh();
2911
+ }
2912
+ }, [open, refresh]);
2913
+ const excludeSet = useMemo3(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2914
+ const { fadeOut, fadeIn } = useMemo3(
2915
+ () => load.status === "ready" ? computeOrphans(load.from, load.to, excludeSet) : { fadeOut: [], fadeIn: [] },
2916
+ [load, excludeSet]
2917
+ );
2918
+ const allOrphans = useMemo3(
2919
+ () => [
2920
+ ...fadeOut.map((t) => ({ track: t, direction: "out" })),
2921
+ ...fadeIn.map((t) => ({ track: t, direction: "in" }))
2922
+ ],
2923
+ [fadeOut, fadeIn]
2924
+ );
2925
+ useEffect6(() => {
2926
+ if (!allOrphans.some((o) => o.track.dbId === selectedDbId)) {
2927
+ setSelectedDbId(allOrphans[0]?.track.dbId ?? "");
2928
+ }
2929
+ }, [allOrphans, selectedDbId]);
2930
+ const selected = allOrphans.find((o) => o.track.dbId === selectedDbId) ?? null;
2931
+ const canCreate = !isCreating && !!selected;
2932
+ const handleClose = useCallback4(() => {
2933
+ if (!isCreating) onClose();
2934
+ }, [isCreating, onClose]);
2935
+ const handleCreate = useCallback4(async () => {
2936
+ if (!selected) return;
2937
+ setIsCreating(true);
2938
+ setError(null);
2939
+ try {
2940
+ await onCreate(
2941
+ { dbId: selected.track.dbId, name: selected.track.name, role: selected.track.role },
2942
+ selected.direction,
2943
+ defaultFadeGesture(selected.track.role)
2944
+ );
2945
+ onClose();
2946
+ } catch (err) {
2947
+ setError(err instanceof Error ? err.message : "Failed to create fade.");
2948
+ setIsCreating(false);
2949
+ }
2950
+ }, [selected, onCreate, onClose]);
2951
+ const fromLabel = fromName ?? fromSceneName ?? null;
2952
+ const toLabel = toName ?? toSceneName ?? null;
2953
+ if (!open) return null;
2954
+ const renderSection = (heading, list, section) => {
2955
+ if (list.length === 0) return null;
2956
+ return /* @__PURE__ */ jsxs10("div", { className: "block", children: [
2957
+ /* @__PURE__ */ jsx14("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: heading }),
2958
+ /* @__PURE__ */ jsx14(
2959
+ "div",
2960
+ {
2961
+ role: "radiogroup",
2962
+ "aria-label": heading,
2963
+ "data-testid": `${testIdPrefix}-${section === "out" ? "fade-out" : "fade-in"}-list`,
2964
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
2965
+ children: list.map((t) => /* @__PURE__ */ jsx14(
2966
+ OrphanRow,
2967
+ {
2968
+ track: t,
2969
+ gesture: defaultFadeGesture(t.role),
2970
+ selected: t.dbId === selectedDbId,
2971
+ disabled: isCreating,
2972
+ onSelect: () => setSelectedDbId(t.dbId),
2973
+ testId: `${testIdPrefix}-option-${t.dbId}`
2974
+ },
2975
+ t.dbId
2976
+ ))
2977
+ }
2978
+ )
2979
+ ] });
2980
+ };
2981
+ return /* @__PURE__ */ jsx14(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs10(
2982
+ "div",
2983
+ {
2984
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2985
+ onClick: (e) => e.stopPropagation(),
2986
+ "data-testid": `${testIdPrefix}-box`,
2987
+ children: [
2988
+ /* @__PURE__ */ jsx14("h3", { className: "text-sm font-bold text-sas-text", children: "Add fade" }),
2989
+ /* @__PURE__ */ jsxs10("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2990
+ "Tracks with no counterpart between",
2991
+ " ",
2992
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2993
+ " and",
2994
+ " ",
2995
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2996
+ " can gracefully fade out (leaving) or fade in (entering) across this transition."
2997
+ ] }),
2998
+ load.status === "loading" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2999
+ load.status === "error" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3000
+ load.status === "ready" && (allOrphans.length === 0 ? /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", "data-testid": `${testIdPrefix}-empty`, children: "Every track has a counterpart in the other scene \u2014 nothing to fade. Use \u201C+ Crossfade\u201D to bridge matching tracks." }) : /* @__PURE__ */ jsxs10(Fragment4, { children: [
3001
+ renderSection(`Fade out${fromLabel ? ` (from ${fromLabel})` : ""}`, fadeOut, "out"),
3002
+ renderSection(`Fade in${toLabel ? ` (to ${toLabel})` : ""}`, fadeIn, "in")
3003
+ ] })),
3004
+ error && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3005
+ /* @__PURE__ */ jsxs10("div", { className: "flex justify-end gap-2 pt-1", children: [
3006
+ /* @__PURE__ */ jsx14(
3007
+ "button",
3008
+ {
3009
+ ref: cancelRef,
3010
+ "data-testid": `${testIdPrefix}-cancel`,
3011
+ onClick: onClose,
3012
+ disabled: isCreating,
3013
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
3014
+ children: "Cancel"
3015
+ }
3016
+ ),
3017
+ /* @__PURE__ */ jsx14(
3018
+ "button",
3019
+ {
3020
+ "data-testid": `${testIdPrefix}-confirm`,
3021
+ onClick: handleCreate,
3022
+ disabled: !canCreate,
3023
+ 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"}`,
3024
+ children: isCreating ? "Generating fade\u2026" : "Create fade"
3025
+ }
3026
+ )
3027
+ ] })
3028
+ ]
3029
+ }
3030
+ ) });
3031
+ }
3032
+
2573
3033
  // src/components/ImportTrackModal.tsx
2574
- import { useCallback as useCallback4, useEffect as useEffect6, useState as useState7 } from "react";
2575
- import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
3034
+ import { useCallback as useCallback5, useEffect as useEffect7, useState as useState8 } from "react";
3035
+ import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
2576
3036
  function ImportTrackModal({
2577
3037
  host,
2578
3038
  open,
@@ -2584,10 +3044,10 @@ function ImportTrackModal({
2584
3044
  onPick,
2585
3045
  onPortTrack
2586
3046
  }) {
2587
- const [load, setLoad] = useState7({ status: "loading" });
2588
- const [selectedSceneId, setSelectedSceneId] = useState7(null);
2589
- const [importingTrackId, setImportingTrackId] = useState7(null);
2590
- const refresh = useCallback4(async () => {
3047
+ const [load, setLoad] = useState8({ status: "loading" });
3048
+ const [selectedSceneId, setSelectedSceneId] = useState8(null);
3049
+ const [importingTrackId, setImportingTrackId] = useState8(null);
3050
+ const refresh = useCallback5(async () => {
2591
3051
  if (!host.listImportableTracks) {
2592
3052
  setLoad({ status: "error", message: "This host does not support importing tracks." });
2593
3053
  return;
@@ -2603,14 +3063,14 @@ function ImportTrackModal({
2603
3063
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2604
3064
  }
2605
3065
  }, [host, mode, onPortTrack]);
2606
- useEffect6(() => {
3066
+ useEffect7(() => {
2607
3067
  if (open) {
2608
3068
  setSelectedSceneId(null);
2609
3069
  setImportingTrackId(null);
2610
3070
  void refresh();
2611
3071
  }
2612
3072
  }, [open, refresh]);
2613
- const handleImport = useCallback4(
3073
+ const handleImport = useCallback5(
2614
3074
  async (track, sourceSceneId, sceneName, isSameScene) => {
2615
3075
  if (isSameScene && onPortTrack) {
2616
3076
  if (!track.importable) return;
@@ -2651,16 +3111,16 @@ function ImportTrackModal({
2651
3111
  if (!open) return null;
2652
3112
  const scenes = load.status === "ready" ? load.scenes : [];
2653
3113
  const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2654
- return /* @__PURE__ */ jsx13(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ jsxs9(
3114
+ return /* @__PURE__ */ jsx15(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ jsxs11(
2655
3115
  "div",
2656
3116
  {
2657
3117
  className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2658
3118
  onClick: (e) => e.stopPropagation(),
2659
3119
  "data-testid": `${testIdPrefix}-modal`,
2660
3120
  children: [
2661
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2662
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2", children: [
2663
- selectedScene && /* @__PURE__ */ jsx13(
3121
+ /* @__PURE__ */ jsxs11("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
3122
+ /* @__PURE__ */ jsxs11("div", { className: "flex items-center gap-2", children: [
3123
+ selectedScene && /* @__PURE__ */ jsx15(
2664
3124
  "button",
2665
3125
  {
2666
3126
  className: "text-sas-muted hover:text-sas-accent text-xs",
@@ -2669,9 +3129,9 @@ function ImportTrackModal({
2669
3129
  children: "\u2190"
2670
3130
  }
2671
3131
  ),
2672
- /* @__PURE__ */ jsx13("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
3132
+ /* @__PURE__ */ jsx15("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2673
3133
  ] }),
2674
- /* @__PURE__ */ jsx13(
3134
+ /* @__PURE__ */ jsx15(
2675
3135
  "button",
2676
3136
  {
2677
3137
  className: "text-sas-muted hover:text-sas-accent text-sm",
@@ -2681,30 +3141,30 @@ function ImportTrackModal({
2681
3141
  }
2682
3142
  )
2683
3143
  ] }),
2684
- /* @__PURE__ */ jsxs9("div", { className: "overflow-y-auto p-2 flex-1", children: [
2685
- load.status === "loading" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
2686
- load.status === "error" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
2687
- load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
2688
- load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
3144
+ /* @__PURE__ */ jsxs11("div", { className: "overflow-y-auto p-2 flex-1", children: [
3145
+ load.status === "loading" && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
3146
+ load.status === "error" && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
3147
+ load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ jsx15("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
3148
+ load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ jsx15("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ jsx15("li", { children: /* @__PURE__ */ jsxs11(
2689
3149
  "button",
2690
3150
  {
2691
3151
  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",
2692
3152
  onClick: () => setSelectedSceneId(scene.sceneId),
2693
3153
  "data-testid": `${testIdPrefix}-scene`,
2694
3154
  children: [
2695
- /* @__PURE__ */ jsx13("span", { className: "truncate", children: scene.sceneName }),
2696
- /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
3155
+ /* @__PURE__ */ jsx15("span", { className: "truncate", children: scene.sceneName }),
3156
+ /* @__PURE__ */ jsxs11("span", { className: "text-sas-muted", children: [
2697
3157
  scene.tracks.length,
2698
3158
  " \u2192"
2699
3159
  ] })
2700
3160
  ]
2701
3161
  }
2702
3162
  ) }, scene.sceneId)) }),
2703
- selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
3163
+ selectedScene && /* @__PURE__ */ jsx15("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2704
3164
  const busy = importingTrackId === track.trackId;
2705
3165
  const gated = mode === "track" && !track.importable;
2706
3166
  const disabled = gated || busy;
2707
- return /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
3167
+ return /* @__PURE__ */ jsx15("li", { children: /* @__PURE__ */ jsxs11(
2708
3168
  "button",
2709
3169
  {
2710
3170
  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"}`,
@@ -2714,14 +3174,14 @@ function ImportTrackModal({
2714
3174
  "data-testid": `${testIdPrefix}-track`,
2715
3175
  "data-importable": mode === "sound" || track.importable ? "true" : "false",
2716
3176
  children: [
2717
- /* @__PURE__ */ jsxs9("span", { className: "truncate", children: [
3177
+ /* @__PURE__ */ jsxs11("span", { className: "truncate", children: [
2718
3178
  track.name,
2719
- track.role ? /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
3179
+ track.role ? /* @__PURE__ */ jsxs11("span", { className: "text-sas-muted", children: [
2720
3180
  " \xB7 ",
2721
3181
  track.role
2722
3182
  ] }) : null
2723
3183
  ] }),
2724
- busy ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2298" }) : null
3184
+ busy ? /* @__PURE__ */ jsx15("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ jsx15("span", { className: "text-sas-muted", children: "\u2298" }) : null
2725
3185
  ]
2726
3186
  }
2727
3187
  ) }, track.dbId);
@@ -2733,29 +3193,59 @@ function ImportTrackModal({
2733
3193
  }
2734
3194
 
2735
3195
  // src/components/CrossfadeModal.tsx
2736
- import { useCallback as useCallback5, useEffect as useEffect7, useMemo as useMemo3, useRef as useRef7, useState as useState8 } from "react";
2737
- import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
2738
- function CrossfadeModal({
2739
- host,
2740
- open,
2741
- fromSceneId,
2742
- toSceneId,
2743
- fromSceneName,
3196
+ import { useCallback as useCallback6, useEffect as useEffect8, useMemo as useMemo4, useRef as useRef8, useState as useState9 } from "react";
3197
+ import { Fragment as Fragment5, jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
3198
+ function shortId2(dbId) {
3199
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3200
+ }
3201
+ function CandidateRow({
3202
+ track,
3203
+ selected,
3204
+ disabled,
3205
+ onSelect,
3206
+ testId
3207
+ }) {
3208
+ const primary = track.prompt?.trim() || track.name;
3209
+ const meta = [track.role, shortId2(track.dbId)].filter(Boolean).join(" \xB7 ");
3210
+ return /* @__PURE__ */ jsxs12(
3211
+ "button",
3212
+ {
3213
+ type: "button",
3214
+ role: "radio",
3215
+ "aria-checked": selected,
3216
+ "data-testid": testId,
3217
+ "data-value": track.dbId,
3218
+ onClick: onSelect,
3219
+ disabled,
3220
+ 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"}`,
3221
+ children: [
3222
+ /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
3223
+ meta && /* @__PURE__ */ jsx16("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
3224
+ ]
3225
+ }
3226
+ );
3227
+ }
3228
+ function CrossfadeModal({
3229
+ host,
3230
+ open,
3231
+ fromSceneId,
3232
+ toSceneId,
3233
+ fromSceneName,
2744
3234
  toSceneName,
2745
3235
  excludeSourceDbIds,
2746
3236
  onClose,
2747
3237
  onCreate,
2748
3238
  testIdPrefix = "crossfade-modal"
2749
3239
  }) {
2750
- const [load, setLoad] = useState8({ status: "loading" });
2751
- const [originDbId, setOriginDbId] = useState8("");
2752
- const [targetDbId, setTargetDbId] = useState8("");
2753
- const [isCreating, setIsCreating] = useState8(false);
2754
- const [error, setError] = useState8(null);
2755
- const [fromName, setFromName] = useState8(null);
2756
- const [toName, setToName] = useState8(null);
2757
- const cancelRef = useRef7(null);
2758
- const refresh = useCallback5(async () => {
3240
+ const [load, setLoad] = useState9({ status: "loading" });
3241
+ const [originDbId, setOriginDbId] = useState9("");
3242
+ const [targetDbId, setTargetDbId] = useState9("");
3243
+ const [isCreating, setIsCreating] = useState9(false);
3244
+ const [error, setError] = useState9(null);
3245
+ const [fromName, setFromName] = useState9(null);
3246
+ const [toName, setToName] = useState9(null);
3247
+ const cancelRef = useRef8(null);
3248
+ const refresh = useCallback6(async () => {
2759
3249
  if (!host.listSceneFamilyTracks) {
2760
3250
  setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2761
3251
  return;
@@ -2775,7 +3265,7 @@ function CrossfadeModal({
2775
3265
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2776
3266
  }
2777
3267
  }, [host, fromSceneId, toSceneId]);
2778
- useEffect7(() => {
3268
+ useEffect8(() => {
2779
3269
  if (open) {
2780
3270
  setError(null);
2781
3271
  setIsCreating(false);
@@ -2784,21 +3274,21 @@ function CrossfadeModal({
2784
3274
  void refresh();
2785
3275
  }
2786
3276
  }, [open, refresh]);
2787
- const excludeSet = useMemo3(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2788
- const originCandidates = useMemo3(
3277
+ const excludeSet = useMemo4(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3278
+ const originCandidates = useMemo4(
2789
3279
  () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
2790
3280
  [load, excludeSet]
2791
3281
  );
2792
- const targetCandidates = useMemo3(
3282
+ const targetCandidates = useMemo4(
2793
3283
  () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
2794
3284
  [load, excludeSet]
2795
3285
  );
2796
- useEffect7(() => {
3286
+ useEffect8(() => {
2797
3287
  if (!originCandidates.some((t) => t.dbId === originDbId)) {
2798
3288
  setOriginDbId(originCandidates[0]?.dbId ?? "");
2799
3289
  }
2800
3290
  }, [originCandidates, originDbId]);
2801
- useEffect7(() => {
3291
+ useEffect8(() => {
2802
3292
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2803
3293
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2804
3294
  }
@@ -2806,10 +3296,10 @@ function CrossfadeModal({
2806
3296
  const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2807
3297
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2808
3298
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2809
- const handleClose = useCallback5(() => {
3299
+ const handleClose = useCallback6(() => {
2810
3300
  if (!isCreating) onClose();
2811
3301
  }, [isCreating, onClose]);
2812
- const handleCreate = useCallback5(async () => {
3302
+ const handleCreate = useCallback6(async () => {
2813
3303
  if (!originTrack || !targetTrack) return;
2814
3304
  setIsCreating(true);
2815
3305
  setError(null);
@@ -2827,26 +3317,26 @@ function CrossfadeModal({
2827
3317
  const fromLabel = fromName ?? fromSceneName ?? null;
2828
3318
  const toLabel = toName ?? toSceneName ?? null;
2829
3319
  if (!open) return null;
2830
- return /* @__PURE__ */ jsx14(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs10(
3320
+ return /* @__PURE__ */ jsx16(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs12(
2831
3321
  "div",
2832
3322
  {
2833
3323
  className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2834
3324
  onClick: (e) => e.stopPropagation(),
2835
3325
  "data-testid": `${testIdPrefix}-box`,
2836
3326
  children: [
2837
- /* @__PURE__ */ jsx14("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2838
- /* @__PURE__ */ jsxs10("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3327
+ /* @__PURE__ */ jsx16("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
3328
+ /* @__PURE__ */ jsxs12("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2839
3329
  "Bridge a track from",
2840
3330
  " ",
2841
- /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3331
+ /* @__PURE__ */ jsx16("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2842
3332
  " into one from",
2843
3333
  " ",
2844
- /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3334
+ /* @__PURE__ */ jsx16("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2845
3335
  ". Both layers share one generated part; each keeps its own preset."
2846
3336
  ] }),
2847
- load.status === "loading" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2848
- load.status === "error" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2849
- load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ jsxs10(
3337
+ load.status === "loading" && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3338
+ load.status === "error" && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3339
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ jsxs12(
2850
3340
  "div",
2851
3341
  {
2852
3342
  className: "text-xs text-sas-muted py-4 text-center",
@@ -2857,55 +3347,67 @@ function CrossfadeModal({
2857
3347
  ". Add one (or free one from another crossfade) first."
2858
3348
  ]
2859
3349
  }
2860
- ) : /* @__PURE__ */ jsxs10(Fragment3, { children: [
2861
- /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2862
- /* @__PURE__ */ jsxs10("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3350
+ ) : /* @__PURE__ */ jsxs12(Fragment5, { children: [
3351
+ /* @__PURE__ */ jsxs12("div", { className: "block", children: [
3352
+ /* @__PURE__ */ jsxs12("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2863
3353
  "Origin ",
2864
3354
  fromLabel ? `(${fromLabel})` : "(top)"
2865
3355
  ] }),
2866
- /* @__PURE__ */ jsx14(
2867
- "select",
3356
+ /* @__PURE__ */ jsx16(
3357
+ "div",
2868
3358
  {
2869
- "data-testid": `${testIdPrefix}-origin-select`,
2870
- value: originDbId,
2871
- onChange: (e) => setOriginDbId(e.target.value),
2872
- disabled: isCreating,
2873
- className: "sas-input w-full mt-0.5 text-xs",
2874
- children: originCandidates.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2875
- t.name,
2876
- t.role ? ` \xB7 ${t.role}` : ""
2877
- ] }, t.dbId))
3359
+ role: "radiogroup",
3360
+ "aria-label": "Origin track",
3361
+ "data-testid": `${testIdPrefix}-origin-list`,
3362
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3363
+ children: originCandidates.map((t) => /* @__PURE__ */ jsx16(
3364
+ CandidateRow,
3365
+ {
3366
+ track: t,
3367
+ selected: t.dbId === originDbId,
3368
+ disabled: isCreating,
3369
+ onSelect: () => setOriginDbId(t.dbId),
3370
+ testId: `${testIdPrefix}-origin-option-${t.dbId}`
3371
+ },
3372
+ t.dbId
3373
+ ))
2878
3374
  }
2879
3375
  )
2880
3376
  ] }),
2881
- /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2882
- /* @__PURE__ */ jsxs10("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3377
+ /* @__PURE__ */ jsxs12("div", { className: "block", children: [
3378
+ /* @__PURE__ */ jsxs12("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2883
3379
  "Target ",
2884
3380
  toLabel ? `(${toLabel})` : "(bottom)"
2885
3381
  ] }),
2886
- targetCandidates.length === 0 ? /* @__PURE__ */ jsxs10("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
3382
+ targetCandidates.length === 0 ? /* @__PURE__ */ jsxs12("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2887
3383
  "No available tracks in ",
2888
3384
  toLabel ?? "the target scene",
2889
3385
  " to crossfade into."
2890
- ] }) : /* @__PURE__ */ jsx14(
2891
- "select",
3386
+ ] }) : /* @__PURE__ */ jsx16(
3387
+ "div",
2892
3388
  {
2893
- "data-testid": `${testIdPrefix}-target-select`,
2894
- value: targetDbId,
2895
- onChange: (e) => setTargetDbId(e.target.value),
2896
- disabled: isCreating,
2897
- className: "sas-input w-full mt-0.5 text-xs",
2898
- children: targetCandidates.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2899
- t.name,
2900
- t.role ? ` \xB7 ${t.role}` : ""
2901
- ] }, t.dbId))
3389
+ role: "radiogroup",
3390
+ "aria-label": "Target track",
3391
+ "data-testid": `${testIdPrefix}-target-list`,
3392
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3393
+ children: targetCandidates.map((t) => /* @__PURE__ */ jsx16(
3394
+ CandidateRow,
3395
+ {
3396
+ track: t,
3397
+ selected: t.dbId === targetDbId,
3398
+ disabled: isCreating,
3399
+ onSelect: () => setTargetDbId(t.dbId),
3400
+ testId: `${testIdPrefix}-target-option-${t.dbId}`
3401
+ },
3402
+ t.dbId
3403
+ ))
2902
3404
  }
2903
3405
  )
2904
3406
  ] })
2905
3407
  ] })),
2906
- error && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
2907
- /* @__PURE__ */ jsxs10("div", { className: "flex justify-end gap-2 pt-1", children: [
2908
- /* @__PURE__ */ jsx14(
3408
+ error && /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3409
+ /* @__PURE__ */ jsxs12("div", { className: "flex justify-end gap-2 pt-1", children: [
3410
+ /* @__PURE__ */ jsx16(
2909
3411
  "button",
2910
3412
  {
2911
3413
  ref: cancelRef,
@@ -2916,7 +3418,7 @@ function CrossfadeModal({
2916
3418
  children: "Cancel"
2917
3419
  }
2918
3420
  ),
2919
- /* @__PURE__ */ jsx14(
3421
+ /* @__PURE__ */ jsx16(
2920
3422
  "button",
2921
3423
  {
2922
3424
  "data-testid": `${testIdPrefix}-confirm`,
@@ -2932,9 +3434,701 @@ function CrossfadeModal({
2932
3434
  ) });
2933
3435
  }
2934
3436
 
3437
+ // src/components/TransitionDesigner.tsx
3438
+ import { useCallback as useCallback8, useEffect as useEffect9, useMemo as useMemo5, useRef as useRef10, useState as useState11 } from "react";
3439
+
3440
+ // src/hooks/useTrackReorder.ts
3441
+ import { useCallback as useCallback7, useRef as useRef9, useState as useState10 } from "react";
3442
+ function moveItem(arr, from, to) {
3443
+ const next = arr.slice();
3444
+ if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3445
+ return next;
3446
+ }
3447
+ const [moved] = next.splice(from, 1);
3448
+ next.splice(to, 0, moved);
3449
+ return next;
3450
+ }
3451
+ function useTrackReorder({
3452
+ host,
3453
+ items,
3454
+ setItems,
3455
+ getId,
3456
+ onError
3457
+ }) {
3458
+ const [draggingIndex, setDraggingIndex] = useState10(null);
3459
+ const [dragOverIndex, setDragOverIndex] = useState10(null);
3460
+ const fromRef = useRef9(null);
3461
+ const itemsRef = useRef9(items);
3462
+ itemsRef.current = items;
3463
+ const dragPropsFor = useCallback7(
3464
+ (index) => ({
3465
+ handleProps: {
3466
+ draggable: true,
3467
+ onDragStart: (e) => {
3468
+ fromRef.current = index;
3469
+ setDraggingIndex(index);
3470
+ if (e.dataTransfer) {
3471
+ e.dataTransfer.effectAllowed = "move";
3472
+ try {
3473
+ e.dataTransfer.setData("text/plain", String(index));
3474
+ } catch {
3475
+ }
3476
+ }
3477
+ },
3478
+ onDragEnd: () => {
3479
+ fromRef.current = null;
3480
+ setDraggingIndex(null);
3481
+ setDragOverIndex(null);
3482
+ }
3483
+ },
3484
+ rowProps: {
3485
+ onDragEnter: (e) => {
3486
+ if (fromRef.current === null) return;
3487
+ e.preventDefault();
3488
+ setDragOverIndex(index);
3489
+ },
3490
+ onDragOver: (e) => {
3491
+ if (fromRef.current === null) return;
3492
+ e.preventDefault();
3493
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3494
+ setDragOverIndex((cur) => cur === index ? cur : index);
3495
+ },
3496
+ onDragLeave: () => {
3497
+ setDragOverIndex((cur) => cur === index ? null : cur);
3498
+ },
3499
+ onDrop: (e) => {
3500
+ e.preventDefault();
3501
+ const from = fromRef.current;
3502
+ fromRef.current = null;
3503
+ setDraggingIndex(null);
3504
+ setDragOverIndex(null);
3505
+ if (from === null || from === index) return;
3506
+ const prev = itemsRef.current;
3507
+ const next = moveItem(prev, from, index);
3508
+ setItems(next);
3509
+ const ids = next.map(getId);
3510
+ Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3511
+ setItems(prev);
3512
+ onError?.(err);
3513
+ });
3514
+ }
3515
+ },
3516
+ isDragging: draggingIndex === index,
3517
+ isDragTarget: dragOverIndex === index && draggingIndex !== index
3518
+ }),
3519
+ [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3520
+ );
3521
+ return { dragPropsFor, draggingIndex, dragOverIndex };
3522
+ }
3523
+
3524
+ // src/transition-designer-meta.ts
3525
+ var TRANSITION_DESIGNER_DRAFT_KEY = "transitionDesigner:draft";
3526
+ var AUDIO_EFFECTS = ["fade", "stutter", "chopped", "delay"];
3527
+ var AUDIO_EFFECT_LABEL = {
3528
+ fade: "Fade",
3529
+ stutter: "Stutter",
3530
+ chopped: "Chopped",
3531
+ delay: "Delay"
3532
+ };
3533
+ function asAudioEffect(v) {
3534
+ return v === "fade" || v === "stutter" || v === "chopped" || v === "delay" ? v : null;
3535
+ }
3536
+ function rowType(hasOrigin, hasTarget) {
3537
+ if (hasOrigin && hasTarget) return "crossfade";
3538
+ if (hasOrigin) return "fade-out";
3539
+ if (hasTarget) return "fade-in";
3540
+ return null;
3541
+ }
3542
+ function asTransitionDesignerDraft(val) {
3543
+ if (!val || typeof val !== "object") return null;
3544
+ const d = val;
3545
+ const clean = (a) => Array.isArray(a) ? a.filter((x) => x === null || typeof x === "string") : [];
3546
+ const cleanEffects = (e) => {
3547
+ const out = {};
3548
+ if (e && typeof e === "object") {
3549
+ for (const [k, v] of Object.entries(e)) {
3550
+ const eff = asAudioEffect(v);
3551
+ if (eff) out[k] = eff;
3552
+ }
3553
+ }
3554
+ return out;
3555
+ };
3556
+ return {
3557
+ originOrder: clean(d.originOrder),
3558
+ targetOrder: clean(d.targetOrder),
3559
+ rowEffects: cleanEffects(d.rowEffects)
3560
+ };
3561
+ }
3562
+ function reconcileSlots(saved, poolIds) {
3563
+ const pool = new Set(poolIds);
3564
+ const seen = /* @__PURE__ */ new Set();
3565
+ const out = [];
3566
+ for (const slot of saved ?? []) {
3567
+ if (slot === null) {
3568
+ out.push(null);
3569
+ continue;
3570
+ }
3571
+ if (pool.has(slot) && !seen.has(slot)) {
3572
+ out.push(slot);
3573
+ seen.add(slot);
3574
+ }
3575
+ }
3576
+ for (const id of poolIds) {
3577
+ if (!seen.has(id)) {
3578
+ out.push(id);
3579
+ seen.add(id);
3580
+ }
3581
+ }
3582
+ return out;
3583
+ }
3584
+ function buildRowSlots(originSlots, targetSlots) {
3585
+ const n = Math.max(originSlots.length, targetSlots.length);
3586
+ const rows = [];
3587
+ for (let i = 0; i < n; i++) {
3588
+ const originId = originSlots[i] ?? null;
3589
+ const targetId = targetSlots[i] ?? null;
3590
+ rows.push({ originId, targetId, type: rowType(originId !== null, targetId !== null) });
3591
+ }
3592
+ return rows;
3593
+ }
3594
+ function normalizeSlots(originSlots, targetSlots) {
3595
+ const rows = buildRowSlots(originSlots, targetSlots).filter(
3596
+ (r) => r.originId !== null || r.targetId !== null
3597
+ );
3598
+ const trimTrailing = (a) => {
3599
+ let end = a.length;
3600
+ while (end > 0 && a[end - 1] === null) end--;
3601
+ return a.slice(0, end);
3602
+ };
3603
+ return {
3604
+ originOrder: trimTrailing(rows.map((r) => r.originId)),
3605
+ targetOrder: trimTrailing(rows.map((r) => r.targetId))
3606
+ };
3607
+ }
3608
+ function padSlots(slots, n) {
3609
+ if (slots.length >= n) return slots.slice();
3610
+ return [...slots, ...new Array(n - slots.length).fill(null)];
3611
+ }
3612
+ function padPair(originSlots, targetSlots) {
3613
+ const n = Math.max(originSlots.length, targetSlots.length);
3614
+ return [padSlots(originSlots, n), padSlots(targetSlots, n)];
3615
+ }
3616
+ function slotsEqual(a, b) {
3617
+ if (a.length !== b.length) return false;
3618
+ for (let i = 0; i < a.length; i++) {
3619
+ if (a[i] !== b[i]) return false;
3620
+ }
3621
+ return true;
3622
+ }
3623
+ function rowKey(row) {
3624
+ if (row.type === "crossfade") return `xf:${row.originId}|${row.targetId}`;
3625
+ if (row.type === "fade-out") return `fo:${row.originId}`;
3626
+ if (row.type === "fade-in") return `fi:${row.targetId}`;
3627
+ return null;
3628
+ }
3629
+ function dbIdsFromKeys(keys) {
3630
+ const out = /* @__PURE__ */ new Set();
3631
+ for (const k of keys) {
3632
+ const body = k.slice(3);
3633
+ if (k.startsWith("xf:")) {
3634
+ const sep = body.indexOf("|");
3635
+ out.add(body.slice(0, sep));
3636
+ out.add(body.slice(sep + 1));
3637
+ } else {
3638
+ out.add(body);
3639
+ }
3640
+ }
3641
+ return out;
3642
+ }
3643
+
3644
+ // src/components/TransitionDesigner.tsx
3645
+ import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
3646
+ var CROSSFADE_ESTIMATE_MS = 15e3;
3647
+ var FADE_ESTIMATE_MS = 11e3;
3648
+ var CREATE_ALL_CONCURRENCY = 5;
3649
+ var TYPE_LABEL = {
3650
+ crossfade: "Crossfade",
3651
+ "fade-out": "Fade out",
3652
+ "fade-in": "Fade in"
3653
+ };
3654
+ function shortId3(dbId) {
3655
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3656
+ }
3657
+ function TransitionDesigner({
3658
+ host,
3659
+ fromSceneId,
3660
+ toSceneId,
3661
+ transitionSceneId,
3662
+ excludeSourceDbIds,
3663
+ onCreateCrossfade,
3664
+ onCreateFade,
3665
+ onCreateAudioTransition,
3666
+ familyLabel,
3667
+ testIdPrefix = "transition-designer"
3668
+ }) {
3669
+ const [load, setLoad] = useState11({ status: "loading" });
3670
+ const [fromName, setFromName] = useState11(null);
3671
+ const [toName, setToName] = useState11(null);
3672
+ const [originSlots, setOriginSlots] = useState11([]);
3673
+ const [targetSlots, setTargetSlots] = useState11([]);
3674
+ const [creatingKeys, setCreatingKeys] = useState11(() => /* @__PURE__ */ new Set());
3675
+ const [rowErrors, setRowErrors] = useState11({});
3676
+ const [rowEffects, setRowEffects] = useState11({});
3677
+ const rowEffectsRef = useRef10(rowEffects);
3678
+ rowEffectsRef.current = rowEffects;
3679
+ const audioEffectsEnabled = !!onCreateAudioTransition;
3680
+ const excludeRef = useRef10(excludeSourceDbIds);
3681
+ excludeRef.current = excludeSourceDbIds;
3682
+ const originSlotsRef = useRef10(originSlots);
3683
+ originSlotsRef.current = originSlots;
3684
+ const targetSlotsRef = useRef10(targetSlots);
3685
+ targetSlotsRef.current = targetSlots;
3686
+ const creatingKeysRef = useRef10(creatingKeys);
3687
+ creatingKeysRef.current = creatingKeys;
3688
+ const dragRef = useRef10(null);
3689
+ const [dragging, setDragging] = useState11(null);
3690
+ const [dragOver, setDragOver] = useState11(null);
3691
+ const excludeSet = useMemo5(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3692
+ const originPool = useMemo5(
3693
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
3694
+ [load, excludeSet]
3695
+ );
3696
+ const targetPool = useMemo5(
3697
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
3698
+ [load, excludeSet]
3699
+ );
3700
+ const originById = useMemo5(() => new Map(originPool.map((t) => [t.dbId, t])), [originPool]);
3701
+ const targetById = useMemo5(() => new Map(targetPool.map((t) => [t.dbId, t])), [targetPool]);
3702
+ const originByIdRef = useRef10(originById);
3703
+ originByIdRef.current = originById;
3704
+ const targetByIdRef = useRef10(targetById);
3705
+ targetByIdRef.current = targetById;
3706
+ const refresh = useCallback8(async () => {
3707
+ if (!host.listSceneFamilyTracks) {
3708
+ setLoad({ status: "error", message: "This host does not support transition tracks." });
3709
+ return;
3710
+ }
3711
+ setLoad({ status: "loading" });
3712
+ try {
3713
+ const [origin, target, fName, tName, draftRaw] = await Promise.all([
3714
+ host.listSceneFamilyTracks(fromSceneId),
3715
+ host.listSceneFamilyTracks(toSceneId),
3716
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3717
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null),
3718
+ host.getSceneData ? host.getSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY) : Promise.resolve(null)
3719
+ ]);
3720
+ const draft = asTransitionDesignerDraft(draftRaw);
3721
+ const exSet = new Set(excludeRef.current ?? []);
3722
+ const originIds = origin.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3723
+ const targetIds = target.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3724
+ const [po, pt] = padPair(
3725
+ reconcileSlots(draft?.originOrder, originIds),
3726
+ reconcileSlots(draft?.targetOrder, targetIds)
3727
+ );
3728
+ setOriginSlots(po);
3729
+ setTargetSlots(pt);
3730
+ setRowEffects(draft?.rowEffects ?? {});
3731
+ setFromName(fName);
3732
+ setToName(tName);
3733
+ setLoad({ status: "ready", origin, target });
3734
+ } catch (err) {
3735
+ setLoad({
3736
+ status: "error",
3737
+ message: err instanceof Error ? err.message : "Failed to load tracks."
3738
+ });
3739
+ }
3740
+ }, [host, fromSceneId, toSceneId, transitionSceneId]);
3741
+ useEffect9(() => {
3742
+ void refresh();
3743
+ }, [refresh]);
3744
+ useEffect9(() => {
3745
+ if (load.status !== "ready") return;
3746
+ const [po, pt] = padPair(
3747
+ reconcileSlots(originSlotsRef.current, originPool.map((t) => t.dbId)),
3748
+ reconcileSlots(targetSlotsRef.current, targetPool.map((t) => t.dbId))
3749
+ );
3750
+ if (!slotsEqual(po, originSlotsRef.current)) setOriginSlots(po);
3751
+ if (!slotsEqual(pt, targetSlotsRef.current)) setTargetSlots(pt);
3752
+ }, [originPool, targetPool, load.status]);
3753
+ const mutate = useCallback8(
3754
+ (nextOrigin, nextTarget) => {
3755
+ const norm = normalizeSlots(nextOrigin, nextTarget);
3756
+ const [po, pt] = padPair(norm.originOrder, norm.targetOrder);
3757
+ setOriginSlots(po);
3758
+ setTargetSlots(pt);
3759
+ if (host.setSceneData) {
3760
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: rowEffectsRef.current }).catch(() => {
3761
+ });
3762
+ }
3763
+ },
3764
+ [host, transitionSceneId]
3765
+ );
3766
+ const setRowEffect = useCallback8(
3767
+ (sourceDbId, effect) => {
3768
+ setRowEffects((prev) => {
3769
+ const next = { ...prev, [sourceDbId]: effect };
3770
+ if (host.setSceneData) {
3771
+ const norm = normalizeSlots(originSlotsRef.current, targetSlotsRef.current);
3772
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: next }).catch(() => {
3773
+ });
3774
+ }
3775
+ return next;
3776
+ });
3777
+ },
3778
+ [host, transitionSceneId]
3779
+ );
3780
+ const insertGapAbove = useCallback8(
3781
+ (col, index) => {
3782
+ const slots = col === "origin" ? originSlots : targetSlots;
3783
+ const next = [...slots.slice(0, index), null, ...slots.slice(index)];
3784
+ if (col === "origin") mutate(next, targetSlots);
3785
+ else mutate(originSlots, next);
3786
+ },
3787
+ [originSlots, targetSlots, mutate]
3788
+ );
3789
+ const removeGap = useCallback8(
3790
+ (col, index) => {
3791
+ const slots = col === "origin" ? originSlots : targetSlots;
3792
+ const next = slots.filter((_, i) => i !== index);
3793
+ if (col === "origin") mutate(next, targetSlots);
3794
+ else mutate(originSlots, next);
3795
+ },
3796
+ [originSlots, targetSlots, mutate]
3797
+ );
3798
+ const handleDrop = useCallback8(
3799
+ (col, to) => {
3800
+ const from = dragRef.current;
3801
+ dragRef.current = null;
3802
+ setDragging(null);
3803
+ setDragOver(null);
3804
+ if (!from || from.col !== col || from.index === to) return;
3805
+ if (col === "origin") mutate(moveItem(originSlots, from.index, to), targetSlots);
3806
+ else mutate(originSlots, moveItem(targetSlots, from.index, to));
3807
+ },
3808
+ [originSlots, targetSlots, mutate]
3809
+ );
3810
+ const rows = useMemo5(() => buildRowSlots(originSlots, targetSlots), [originSlots, targetSlots]);
3811
+ const creatingDbIds = useMemo5(() => dbIdsFromKeys(creatingKeys), [creatingKeys]);
3812
+ const eligibleCount = useMemo5(
3813
+ () => rows.filter((r) => {
3814
+ const k = rowKey(r);
3815
+ return k !== null && !creatingKeys.has(k);
3816
+ }).length,
3817
+ [rows, creatingKeys]
3818
+ );
3819
+ const createRow = useCallback8(
3820
+ async (row) => {
3821
+ const key = rowKey(row);
3822
+ if (!key || !row.type || creatingKeysRef.current.has(key)) return;
3823
+ setCreatingKeys((prev) => new Set(prev).add(key));
3824
+ setRowErrors((prev) => {
3825
+ if (!(key in prev)) return prev;
3826
+ const next = { ...prev };
3827
+ delete next[key];
3828
+ return next;
3829
+ });
3830
+ try {
3831
+ if (row.type === "crossfade") {
3832
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3833
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3834
+ if (!o || !t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3835
+ await onCreateCrossfade(
3836
+ { dbId: o.dbId, name: o.name, role: o.role },
3837
+ { dbId: t.dbId, name: t.name, role: t.role }
3838
+ );
3839
+ } else if (row.type === "fade-out") {
3840
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3841
+ if (!o) throw new Error("Track is no longer available \u2014 refresh and retry.");
3842
+ const eff = rowEffectsRef.current[o.dbId] ?? "fade";
3843
+ if (eff !== "fade" && onCreateAudioTransition) {
3844
+ await onCreateAudioTransition({ dbId: o.dbId, name: o.name, role: o.role }, "out", eff);
3845
+ } else {
3846
+ await onCreateFade({ dbId: o.dbId, name: o.name, role: o.role }, "out", defaultFadeGesture(o.role));
3847
+ }
3848
+ } else {
3849
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3850
+ if (!t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3851
+ const eff = rowEffectsRef.current[t.dbId] ?? "fade";
3852
+ if (eff !== "fade" && onCreateAudioTransition) {
3853
+ await onCreateAudioTransition({ dbId: t.dbId, name: t.name, role: t.role }, "in", eff);
3854
+ } else {
3855
+ await onCreateFade({ dbId: t.dbId, name: t.name, role: t.role }, "in", defaultFadeGesture(t.role));
3856
+ }
3857
+ }
3858
+ } catch (err) {
3859
+ setRowErrors((prev) => ({
3860
+ ...prev,
3861
+ [key]: err instanceof Error ? err.message : "Failed to create transition."
3862
+ }));
3863
+ } finally {
3864
+ setCreatingKeys((prev) => {
3865
+ const next = new Set(prev);
3866
+ next.delete(key);
3867
+ return next;
3868
+ });
3869
+ }
3870
+ },
3871
+ [onCreateCrossfade, onCreateFade, onCreateAudioTransition]
3872
+ );
3873
+ const createAll = useCallback8(async () => {
3874
+ const eligible = buildRowSlots(originSlotsRef.current, targetSlotsRef.current).filter((r) => {
3875
+ const k = rowKey(r);
3876
+ return k !== null && !creatingKeysRef.current.has(k);
3877
+ });
3878
+ if (eligible.length === 0) return;
3879
+ let cursor = 0;
3880
+ const worker = async () => {
3881
+ while (cursor < eligible.length) {
3882
+ const row = eligible[cursor];
3883
+ cursor += 1;
3884
+ await createRow(row);
3885
+ }
3886
+ };
3887
+ await Promise.all(
3888
+ Array.from({ length: Math.min(CREATE_ALL_CONCURRENCY, eligible.length) }, () => worker())
3889
+ );
3890
+ }, [createRow]);
3891
+ const fromLabel = fromName ?? "origin";
3892
+ const toLabel = toName ?? "target";
3893
+ const cellDragProps = (col, index, locked) => ({
3894
+ draggable: !locked,
3895
+ onDragStart: (e) => {
3896
+ if (locked) return;
3897
+ dragRef.current = { col, index };
3898
+ setDragging({ col, index });
3899
+ if (e.dataTransfer) {
3900
+ e.dataTransfer.effectAllowed = "move";
3901
+ try {
3902
+ e.dataTransfer.setData("text/plain", String(index));
3903
+ } catch {
3904
+ }
3905
+ }
3906
+ },
3907
+ onDragEnd: () => {
3908
+ dragRef.current = null;
3909
+ setDragging(null);
3910
+ setDragOver(null);
3911
+ },
3912
+ onDragEnter: (e) => {
3913
+ const d = dragRef.current;
3914
+ if (!d || d.col !== col) return;
3915
+ e.preventDefault();
3916
+ setDragOver({ col, index });
3917
+ },
3918
+ onDragOver: (e) => {
3919
+ const d = dragRef.current;
3920
+ if (!d || d.col !== col) return;
3921
+ e.preventDefault();
3922
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3923
+ },
3924
+ onDragLeave: () => {
3925
+ setDragOver((cur) => cur && cur.col === col && cur.index === index ? null : cur);
3926
+ },
3927
+ onDrop: (e) => {
3928
+ e.preventDefault();
3929
+ handleDrop(col, index);
3930
+ }
3931
+ });
3932
+ const renderCell = (col, index, slotId) => {
3933
+ const byId = col === "origin" ? originById : targetById;
3934
+ const track = slotId ? byId.get(slotId) : void 0;
3935
+ const locked = slotId !== null && creatingDbIds.has(slotId);
3936
+ const isDragging = dragging?.col === col && dragging.index === index;
3937
+ const isDragTarget = dragOver?.col === col && dragOver.index === index && !isDragging;
3938
+ const base = "group relative rounded-sm border px-2 py-1.5 text-left transition-colors select-none";
3939
+ const tone = isDragTarget ? "border-sas-accent bg-sas-accent/10" : "border-sas-border bg-sas-panel";
3940
+ if (slotId === null) {
3941
+ return /* @__PURE__ */ jsxs13(
3942
+ "div",
3943
+ {
3944
+ ...cellDragProps(col, index, false),
3945
+ "data-testid": `${testIdPrefix}-${col}-gap-${index}`,
3946
+ className: `${base} ${tone} border-dashed flex items-center justify-between ${isDragging ? "opacity-40" : "opacity-70"}`,
3947
+ children: [
3948
+ /* @__PURE__ */ jsx17("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "\u2014 gap \u2014" }),
3949
+ /* @__PURE__ */ jsx17(
3950
+ "button",
3951
+ {
3952
+ type: "button",
3953
+ "data-testid": `${testIdPrefix}-${col}-remove-gap-${index}`,
3954
+ onClick: () => removeGap(col, index),
3955
+ title: "Remove gap",
3956
+ className: "text-[10px] text-sas-muted hover:text-sas-danger",
3957
+ children: "\u2715"
3958
+ }
3959
+ )
3960
+ ]
3961
+ }
3962
+ );
3963
+ }
3964
+ const primary = track ? track.prompt?.trim() || track.name : slotId;
3965
+ const meta = track ? [track.role, shortId3(track.dbId)].filter(Boolean).join(" \xB7 ") : "missing";
3966
+ return /* @__PURE__ */ jsx17(
3967
+ "div",
3968
+ {
3969
+ ...cellDragProps(col, index, locked),
3970
+ "data-testid": `${testIdPrefix}-${col}-cell-${slotId}`,
3971
+ "data-value": slotId,
3972
+ className: `${base} ${tone} ${isDragging ? "opacity-40" : ""} ${locked ? "opacity-60" : "cursor-grab active:cursor-grabbing"}`,
3973
+ title: track ? track.dbId : "Track no longer available",
3974
+ children: /* @__PURE__ */ jsxs13("div", { className: "flex items-start gap-1", children: [
3975
+ /* @__PURE__ */ jsx17("span", { className: "text-sas-muted/60 text-xs leading-tight pt-0.5", "aria-hidden": true, children: "\u283F" }),
3976
+ /* @__PURE__ */ jsxs13("div", { className: "min-w-0 flex-1", children: [
3977
+ /* @__PURE__ */ jsx17("div", { className: "text-xs text-sas-text truncate", children: primary }),
3978
+ meta && /* @__PURE__ */ jsx17("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", children: meta })
3979
+ ] }),
3980
+ /* @__PURE__ */ jsx17(
3981
+ "button",
3982
+ {
3983
+ type: "button",
3984
+ "data-testid": `${testIdPrefix}-${col}-insert-gap-${index}`,
3985
+ onClick: () => insertGapAbove(col, index),
3986
+ disabled: locked,
3987
+ title: "Insert a gap above (make this a fade)",
3988
+ className: "text-[10px] text-sas-muted opacity-0 group-hover:opacity-100 hover:text-sas-accent disabled:opacity-30",
3989
+ children: "+gap"
3990
+ }
3991
+ )
3992
+ ] })
3993
+ }
3994
+ );
3995
+ };
3996
+ return /* @__PURE__ */ jsxs13("div", { className: "space-y-2", "data-testid": `${testIdPrefix}-box`, children: [
3997
+ /* @__PURE__ */ jsxs13("div", { className: "flex items-center justify-between gap-3 pb-1 border-b border-sas-border", children: [
3998
+ /* @__PURE__ */ jsxs13("p", { className: "text-[11px] text-sas-muted leading-snug min-w-0", children: [
3999
+ /* @__PURE__ */ jsx17("span", { className: "text-sas-text", children: fromLabel }),
4000
+ " \u2192",
4001
+ " ",
4002
+ /* @__PURE__ */ jsx17("span", { className: "text-sas-text", children: toLabel }),
4003
+ familyLabel ? ` \xB7 ${familyLabel}` : "",
4004
+ " \xB7 line up a track on each side to crossfade; leave one blank (or insert a gap) to fade."
4005
+ ] }),
4006
+ /* @__PURE__ */ jsxs13("div", { className: "flex items-center gap-2 shrink-0", children: [
4007
+ creatingKeys.size > 0 && /* @__PURE__ */ jsxs13("span", { className: "text-[10px] text-sas-accent whitespace-nowrap", "data-testid": `${testIdPrefix}-creating-count`, children: [
4008
+ creatingKeys.size,
4009
+ " creating\u2026"
4010
+ ] }),
4011
+ /* @__PURE__ */ jsxs13(
4012
+ "button",
4013
+ {
4014
+ type: "button",
4015
+ "data-testid": `${testIdPrefix}-create-all`,
4016
+ onClick: createAll,
4017
+ disabled: eligibleCount === 0,
4018
+ title: "Create every staged transition at once (runs several concurrently)",
4019
+ 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"}`,
4020
+ children: [
4021
+ "Create all",
4022
+ eligibleCount > 0 ? ` (${eligibleCount})` : ""
4023
+ ]
4024
+ }
4025
+ )
4026
+ ] })
4027
+ ] }),
4028
+ /* @__PURE__ */ jsxs13("div", { className: "grid grid-cols-[1fr_auto_1fr] gap-2", children: [
4029
+ /* @__PURE__ */ jsxs13("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate", children: [
4030
+ "Origin (",
4031
+ fromLabel,
4032
+ ")"
4033
+ ] }),
4034
+ /* @__PURE__ */ jsx17("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted text-center px-2", children: "Transition" }),
4035
+ /* @__PURE__ */ jsxs13("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate text-right", children: [
4036
+ "Target (",
4037
+ toLabel,
4038
+ ")"
4039
+ ] })
4040
+ ] }),
4041
+ load.status === "loading" && /* @__PURE__ */ jsx17("div", { className: "text-xs text-sas-muted py-6 text-center", children: "Loading tracks\u2026" }),
4042
+ load.status === "error" && /* @__PURE__ */ jsx17("div", { className: "text-xs text-sas-danger py-6 text-center", "data-testid": `${testIdPrefix}-error`, children: load.message }),
4043
+ load.status === "ready" && (rows.length === 0 ? /* @__PURE__ */ jsxs13("div", { className: "text-xs text-sas-muted py-6 text-center", "data-testid": `${testIdPrefix}-empty`, children: [
4044
+ "No tracks to arrange in this panel for either scene. Add tracks to ",
4045
+ fromLabel,
4046
+ " or ",
4047
+ toLabel,
4048
+ " ",
4049
+ "first (or free one by deleting an existing crossfade/fade)."
4050
+ ] }) : /* @__PURE__ */ jsx17("div", { className: "space-y-2", children: rows.map((row, i) => {
4051
+ const key = rowKey(row);
4052
+ const isCreatingThis = key !== null && creatingKeys.has(key);
4053
+ const errMsg = key !== null ? rowErrors[key] : void 0;
4054
+ return /* @__PURE__ */ jsxs13(
4055
+ "div",
4056
+ {
4057
+ "data-testid": `${testIdPrefix}-row-${i}`,
4058
+ className: "grid grid-cols-[1fr_auto_1fr] gap-2 items-center",
4059
+ children: [
4060
+ renderCell("origin", i, row.originId),
4061
+ /* @__PURE__ */ jsxs13("div", { className: "w-[160px] flex flex-col items-center gap-1", children: [
4062
+ !row.type ? /* @__PURE__ */ jsx17("span", { className: "text-[10px] text-sas-muted/50", children: "\u2014" }) : row.type === "crossfade" ? /* @__PURE__ */ jsx17(
4063
+ "span",
4064
+ {
4065
+ "data-testid": `${testIdPrefix}-type-${i}`,
4066
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-accent/50 text-sas-accent",
4067
+ children: TYPE_LABEL[row.type]
4068
+ }
4069
+ ) : audioEffectsEnabled ? /* @__PURE__ */ jsxs13("div", { className: "flex items-center gap-1", "data-testid": `${testIdPrefix}-type-${i}`, children: [
4070
+ /* @__PURE__ */ jsx17(
4071
+ "select",
4072
+ {
4073
+ "data-testid": `${testIdPrefix}-effect-${i}`,
4074
+ value: rowEffects[row.originId ?? row.targetId] ?? "fade",
4075
+ onChange: (e) => {
4076
+ const id = row.originId ?? row.targetId;
4077
+ if (id) setRowEffect(id, e.target.value);
4078
+ },
4079
+ className: "text-[10px] bg-sas-panel border border-sas-border rounded-sm px-1 py-0.5 text-sas-text",
4080
+ children: AUDIO_EFFECTS.map((eff) => /* @__PURE__ */ jsx17("option", { value: eff, children: AUDIO_EFFECT_LABEL[eff] }, eff))
4081
+ }
4082
+ ),
4083
+ /* @__PURE__ */ jsx17("span", { className: "text-[9px] text-sas-muted", children: row.type === "fade-out" ? "out" : "in" })
4084
+ ] }) : /* @__PURE__ */ jsx17(
4085
+ "span",
4086
+ {
4087
+ "data-testid": `${testIdPrefix}-type-${i}`,
4088
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-border text-sas-muted",
4089
+ children: TYPE_LABEL[row.type]
4090
+ }
4091
+ ),
4092
+ isCreatingThis ? /* @__PURE__ */ jsx17("div", { className: "w-full", children: /* @__PURE__ */ jsx17(
4093
+ SorceryProgressBar,
4094
+ {
4095
+ isLoading: true,
4096
+ heightClass: "h-5",
4097
+ statusText: "CREATING",
4098
+ estimatedDurationMs: row.type === "crossfade" ? CROSSFADE_ESTIMATE_MS : FADE_ESTIMATE_MS
4099
+ }
4100
+ ) }) : /* @__PURE__ */ jsx17(
4101
+ "button",
4102
+ {
4103
+ type: "button",
4104
+ "data-testid": `${testIdPrefix}-create-${i}`,
4105
+ onClick: () => createRow(row),
4106
+ disabled: !row.type,
4107
+ 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"}`,
4108
+ children: "Create"
4109
+ }
4110
+ ),
4111
+ errMsg && /* @__PURE__ */ jsx17(
4112
+ "span",
4113
+ {
4114
+ "data-testid": `${testIdPrefix}-row-error-${i}`,
4115
+ className: "text-[10px] text-sas-danger text-center leading-tight",
4116
+ children: errMsg
4117
+ }
4118
+ )
4119
+ ] }),
4120
+ renderCell("target", i, row.targetId)
4121
+ ]
4122
+ },
4123
+ i
4124
+ );
4125
+ }) }))
4126
+ ] });
4127
+ }
4128
+
2935
4129
  // src/components/DownloadPackButton.tsx
2936
- import { useCallback as useCallback6, useEffect as useEffect8, useState as useState9 } from "react";
2937
- import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
4130
+ import { useCallback as useCallback9, useEffect as useEffect10, useState as useState12 } from "react";
4131
+ import { jsx as jsx18, jsxs as jsxs14 } from "react/jsx-runtime";
2938
4132
  function formatSize(bytes) {
2939
4133
  if (!bytes || bytes <= 0) return "";
2940
4134
  const gb = bytes / 1024 ** 3;
@@ -2950,10 +4144,10 @@ var DownloadPackButton = ({
2950
4144
  variant = "compact",
2951
4145
  onDownloadComplete
2952
4146
  }) => {
2953
- const [status, setStatus] = useState9("idle");
2954
- const [progress, setProgress] = useState9(0);
2955
- const [errorMessage, setErrorMessage] = useState9(null);
2956
- useEffect8(() => {
4147
+ const [status, setStatus] = useState12("idle");
4148
+ const [progress, setProgress] = useState12(0);
4149
+ const [errorMessage, setErrorMessage] = useState12(null);
4150
+ useEffect10(() => {
2957
4151
  const unsub = host.onSamplePackProgress(packId, (p) => {
2958
4152
  setStatus(p.status);
2959
4153
  setProgress(p.progress);
@@ -2968,7 +4162,7 @@ var DownloadPackButton = ({
2968
4162
  });
2969
4163
  return unsub;
2970
4164
  }, [host, packId, onDownloadComplete]);
2971
- const handleClick = useCallback6(async () => {
4165
+ const handleClick = useCallback9(async () => {
2972
4166
  if (status !== "idle" && status !== "error") return;
2973
4167
  try {
2974
4168
  setStatus("downloading");
@@ -3022,8 +4216,8 @@ var DownloadPackButton = ({
3022
4216
  } else {
3023
4217
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3024
4218
  }
3025
- return /* @__PURE__ */ jsxs11("div", { children: [
3026
- /* @__PURE__ */ jsx15(
4219
+ return /* @__PURE__ */ jsxs14("div", { children: [
4220
+ /* @__PURE__ */ jsx18(
3027
4221
  "button",
3028
4222
  {
3029
4223
  "data-testid": `download-pack-button-${packId}`,
@@ -3034,12 +4228,12 @@ var DownloadPackButton = ({
3034
4228
  children: buttonLabel
3035
4229
  }
3036
4230
  ),
3037
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx15("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
4231
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx18("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3038
4232
  ] });
3039
4233
  };
3040
4234
 
3041
4235
  // src/components/SamplePackCTACard.tsx
3042
- import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
4236
+ import { jsx as jsx19, jsxs as jsxs15 } from "react/jsx-runtime";
3043
4237
  var SamplePackCTACard = ({
3044
4238
  host,
3045
4239
  pack,
@@ -3047,7 +4241,7 @@ var SamplePackCTACard = ({
3047
4241
  onDownloadComplete
3048
4242
  }) => {
3049
4243
  if (status === "checking") {
3050
- return /* @__PURE__ */ jsx16(
4244
+ return /* @__PURE__ */ jsx19(
3051
4245
  "div",
3052
4246
  {
3053
4247
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -3058,16 +4252,16 @@ var SamplePackCTACard = ({
3058
4252
  }
3059
4253
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3060
4254
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3061
- return /* @__PURE__ */ jsxs12(
4255
+ return /* @__PURE__ */ jsxs15(
3062
4256
  "div",
3063
4257
  {
3064
4258
  "data-testid": `sample-pack-cta-${pack.packId}`,
3065
4259
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3066
4260
  children: [
3067
- /* @__PURE__ */ jsx16("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3068
- /* @__PURE__ */ jsx16("div", { className: "text-base text-sas-text mb-1", children: headline }),
3069
- /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3070
- /* @__PURE__ */ jsx16(
4261
+ /* @__PURE__ */ jsx19("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
4262
+ /* @__PURE__ */ jsx19("div", { className: "text-base text-sas-text mb-1", children: headline }),
4263
+ /* @__PURE__ */ jsx19("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
4264
+ /* @__PURE__ */ jsx19(
3071
4265
  DownloadPackButton,
3072
4266
  {
3073
4267
  host,
@@ -3084,7 +4278,7 @@ var SamplePackCTACard = ({
3084
4278
  };
3085
4279
 
3086
4280
  // src/components/WaveformView.tsx
3087
- import { useEffect as useEffect9, useRef as useRef8, useState as useState10 } from "react";
4281
+ import { useEffect as useEffect11, useRef as useRef11, useState as useState13 } from "react";
3088
4282
 
3089
4283
  // src/components/waveform.ts
3090
4284
  function computePeaks(audioBuffer, bins, targetSamples) {
@@ -3147,7 +4341,7 @@ function drawWaveform(canvas, peaks, options = {}) {
3147
4341
  }
3148
4342
 
3149
4343
  // src/components/WaveformView.tsx
3150
- import { jsx as jsx17 } from "react/jsx-runtime";
4344
+ import { jsx as jsx20 } from "react/jsx-runtime";
3151
4345
  var WaveformView = ({
3152
4346
  host,
3153
4347
  filePath,
@@ -3156,9 +4350,9 @@ var WaveformView = ({
3156
4350
  fillStyle,
3157
4351
  targetSamples
3158
4352
  }) => {
3159
- const canvasRef = useRef8(null);
3160
- const [peaks, setPeaks] = useState10(null);
3161
- useEffect9(() => {
4353
+ const canvasRef = useRef11(null);
4354
+ const [peaks, setPeaks] = useState13(null);
4355
+ useEffect11(() => {
3162
4356
  let cancelled = false;
3163
4357
  let audioContext = null;
3164
4358
  (async () => {
@@ -3184,7 +4378,7 @@ var WaveformView = ({
3184
4378
  cancelled = true;
3185
4379
  };
3186
4380
  }, [host, filePath, bins, targetSamples]);
3187
- useEffect9(() => {
4381
+ useEffect11(() => {
3188
4382
  if (!peaks) return;
3189
4383
  const canvas = canvasRef.current;
3190
4384
  if (!canvas) return;
@@ -3195,7 +4389,7 @@ var WaveformView = ({
3195
4389
  observer.observe(canvas);
3196
4390
  return () => observer.disconnect();
3197
4391
  }, [peaks, fillStyle]);
3198
- return /* @__PURE__ */ jsx17(
4392
+ return /* @__PURE__ */ jsx20(
3199
4393
  "canvas",
3200
4394
  {
3201
4395
  ref: canvasRef,
@@ -3206,8 +4400,8 @@ var WaveformView = ({
3206
4400
  };
3207
4401
 
3208
4402
  // src/components/ScrollingWaveform.tsx
3209
- import { useEffect as useEffect10, useRef as useRef9 } from "react";
3210
- import { jsx as jsx18 } from "react/jsx-runtime";
4403
+ import { useEffect as useEffect12, useRef as useRef12 } from "react";
4404
+ import { jsx as jsx21 } from "react/jsx-runtime";
3211
4405
  var ScrollingWaveform = ({
3212
4406
  getPeakDb,
3213
4407
  active,
@@ -3215,11 +4409,11 @@ var ScrollingWaveform = ({
3215
4409
  className,
3216
4410
  fillStyle
3217
4411
  }) => {
3218
- const canvasRef = useRef9(null);
3219
- const ringRef = useRef9(new Float32Array(columns));
3220
- const writeIdxRef = useRef9(0);
3221
- const rafRef = useRef9(null);
3222
- useEffect10(() => {
4412
+ const canvasRef = useRef12(null);
4413
+ const ringRef = useRef12(new Float32Array(columns));
4414
+ const writeIdxRef = useRef12(0);
4415
+ const rafRef = useRef12(null);
4416
+ useEffect12(() => {
3223
4417
  if (ringRef.current.length !== columns) {
3224
4418
  const next = new Float32Array(columns);
3225
4419
  const prev = ringRef.current;
@@ -3231,7 +4425,7 @@ var ScrollingWaveform = ({
3231
4425
  writeIdxRef.current = writeIdxRef.current % columns;
3232
4426
  }
3233
4427
  }, [columns]);
3234
- useEffect10(() => {
4428
+ useEffect12(() => {
3235
4429
  if (!active) {
3236
4430
  if (rafRef.current !== null) {
3237
4431
  cancelAnimationFrame(rafRef.current);
@@ -3283,7 +4477,7 @@ var ScrollingWaveform = ({
3283
4477
  }
3284
4478
  };
3285
4479
  }, [active, getPeakDb, fillStyle]);
3286
- return /* @__PURE__ */ jsx18(
4480
+ return /* @__PURE__ */ jsx21(
3287
4481
  "canvas",
3288
4482
  {
3289
4483
  ref: canvasRef,
@@ -3294,8 +4488,8 @@ var ScrollingWaveform = ({
3294
4488
  };
3295
4489
 
3296
4490
  // src/components/OffsetScrubber.tsx
3297
- import { useCallback as useCallback7, useEffect as useEffect11, useMemo as useMemo4, useRef as useRef10, useState as useState11 } from "react";
3298
- import { jsx as jsx19, jsxs as jsxs13 } from "react/jsx-runtime";
4491
+ import { useCallback as useCallback10, useEffect as useEffect13, useMemo as useMemo6, useRef as useRef13, useState as useState14 } from "react";
4492
+ import { jsx as jsx22, jsxs as jsxs16 } from "react/jsx-runtime";
3299
4493
  var SLIDER_HEIGHT_PX = 28;
3300
4494
  var TICK_HEIGHT_PX = 14;
3301
4495
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -3308,40 +4502,40 @@ function OffsetScrubber({
3308
4502
  onChange,
3309
4503
  disabled = false
3310
4504
  }) {
3311
- const trackRef = useRef10(null);
3312
- const [draftOffset, setDraftOffset] = useState11(offsetSamples);
3313
- const [isDragging, setIsDragging] = useState11(false);
3314
- useEffect11(() => {
4505
+ const trackRef = useRef13(null);
4506
+ const [draftOffset, setDraftOffset] = useState14(offsetSamples);
4507
+ const [isDragging, setIsDragging] = useState14(false);
4508
+ useEffect13(() => {
3315
4509
  if (!isDragging) setDraftOffset(offsetSamples);
3316
4510
  }, [offsetSamples, isDragging]);
3317
4511
  const sampleRate = cuePoints?.sample_rate ?? 44100;
3318
4512
  const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3319
- const beatsForRange = useMemo4(() => {
4513
+ const beatsForRange = useMemo6(() => {
3320
4514
  return Math.round(60 / projectBpm * sampleRate);
3321
4515
  }, [projectBpm, sampleRate]);
3322
4516
  const rangeSamples = beatsForRange * meter;
3323
- const sampleToFraction = useCallback7(
4517
+ const sampleToFraction = useCallback10(
3324
4518
  (sample) => {
3325
4519
  const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3326
4520
  return (clamped + rangeSamples) / (2 * rangeSamples);
3327
4521
  },
3328
4522
  [rangeSamples]
3329
4523
  );
3330
- const fractionToSample = useCallback7(
4524
+ const fractionToSample = useCallback10(
3331
4525
  (fraction) => {
3332
4526
  const clamped = Math.max(0, Math.min(1, fraction));
3333
4527
  return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3334
4528
  },
3335
4529
  [rangeSamples]
3336
4530
  );
3337
- const snapTargets = useMemo4(() => {
4531
+ const snapTargets = useMemo6(() => {
3338
4532
  if (!cuePoints || cuePoints.beats.length === 0) return [];
3339
4533
  const downbeat = cuePoints.beats[0];
3340
4534
  const positives = cuePoints.beats.map((b) => b - downbeat);
3341
4535
  const negatives = positives.slice(1).map((p) => -p);
3342
4536
  return [...negatives, ...positives].sort((a, b) => a - b);
3343
4537
  }, [cuePoints]);
3344
- const snapToBeat = useCallback7(
4538
+ const snapToBeat = useCallback10(
3345
4539
  (sample) => {
3346
4540
  if (snapTargets.length === 0) return sample;
3347
4541
  let best = snapTargets[0];
@@ -3357,7 +4551,7 @@ function OffsetScrubber({
3357
4551
  },
3358
4552
  [snapTargets]
3359
4553
  );
3360
- const handlePointerDown = useCallback7(
4554
+ const handlePointerDown = useCallback10(
3361
4555
  (e) => {
3362
4556
  if (disabled || !cuePoints) return;
3363
4557
  e.preventDefault();
@@ -3391,7 +4585,7 @@ function OffsetScrubber({
3391
4585
  },
3392
4586
  [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3393
4587
  );
3394
- const handleResetToZero = useCallback7(() => {
4588
+ const handleResetToZero = useCallback10(() => {
3395
4589
  if (disabled) return;
3396
4590
  setDraftOffset(0);
3397
4591
  onChange(0);
@@ -3399,7 +4593,7 @@ function OffsetScrubber({
3399
4593
  const thumbFraction = sampleToFraction(draftOffset);
3400
4594
  const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3401
4595
  const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3402
- const ticks = useMemo4(() => {
4596
+ const ticks = useMemo6(() => {
3403
4597
  if (!cuePoints) return [];
3404
4598
  const downbeat = cuePoints.beats[0] ?? 0;
3405
4599
  return cuePoints.beats.map((b, i) => {
@@ -3410,9 +4604,9 @@ function OffsetScrubber({
3410
4604
  });
3411
4605
  }, [cuePoints, sampleToFraction]);
3412
4606
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3413
- return /* @__PURE__ */ jsxs13("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3414
- /* @__PURE__ */ jsx19("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3415
- /* @__PURE__ */ jsxs13(
4607
+ return /* @__PURE__ */ jsxs16("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4608
+ /* @__PURE__ */ jsx22("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4609
+ /* @__PURE__ */ jsxs16(
3416
4610
  "div",
3417
4611
  {
3418
4612
  ref: trackRef,
@@ -3428,7 +4622,7 @@ function OffsetScrubber({
3428
4622
  "aria-valuenow": draftOffset,
3429
4623
  "aria-disabled": isDisabled,
3430
4624
  children: [
3431
- /* @__PURE__ */ jsx19(
4625
+ /* @__PURE__ */ jsx22(
3432
4626
  "div",
3433
4627
  {
3434
4628
  "aria-hidden": "true",
@@ -3436,7 +4630,7 @@ function OffsetScrubber({
3436
4630
  style: { left: "50%" }
3437
4631
  }
3438
4632
  ),
3439
- ticks.map((t) => /* @__PURE__ */ jsx19(
4633
+ ticks.map((t) => /* @__PURE__ */ jsx22(
3440
4634
  "div",
3441
4635
  {
3442
4636
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -3451,7 +4645,7 @@ function OffsetScrubber({
3451
4645
  },
3452
4646
  t.i
3453
4647
  )),
3454
- /* @__PURE__ */ jsx19(
4648
+ /* @__PURE__ */ jsx22(
3455
4649
  "div",
3456
4650
  {
3457
4651
  "data-testid": "offset-scrubber-thumb",
@@ -3468,7 +4662,7 @@ function OffsetScrubber({
3468
4662
  ]
3469
4663
  }
3470
4664
  ),
3471
- /* @__PURE__ */ jsx19(
4665
+ /* @__PURE__ */ jsx22(
3472
4666
  "span",
3473
4667
  {
3474
4668
  "data-testid": "offset-scrubber-readout",
@@ -3476,7 +4670,7 @@ function OffsetScrubber({
3476
4670
  children: formatOffset(draftOffset, sampleRate)
3477
4671
  }
3478
4672
  ),
3479
- /* @__PURE__ */ jsx19(
4673
+ /* @__PURE__ */ jsx22(
3480
4674
  "button",
3481
4675
  {
3482
4676
  type: "button",
@@ -3488,7 +4682,7 @@ function OffsetScrubber({
3488
4682
  children: "\u2316"
3489
4683
  }
3490
4684
  ),
3491
- bpmMismatch && /* @__PURE__ */ jsx19(
4685
+ bpmMismatch && /* @__PURE__ */ jsx22(
3492
4686
  "span",
3493
4687
  {
3494
4688
  "data-testid": "offset-bpm-mismatch",
@@ -3560,13 +4754,13 @@ function synthesizeCuePoints({
3560
4754
  }
3561
4755
 
3562
4756
  // src/hooks/useSceneState.ts
3563
- import { useState as useState12, useCallback as useCallback8, useRef as useRef11 } from "react";
4757
+ import { useState as useState15, useCallback as useCallback11, useRef as useRef14 } from "react";
3564
4758
  function useSceneState(activeSceneId, initialValue) {
3565
- const [stateMap, setStateMap] = useState12(() => /* @__PURE__ */ new Map());
3566
- const activeSceneIdRef = useRef11(activeSceneId);
4759
+ const [stateMap, setStateMap] = useState15(() => /* @__PURE__ */ new Map());
4760
+ const activeSceneIdRef = useRef14(activeSceneId);
3567
4761
  activeSceneIdRef.current = activeSceneId;
3568
4762
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
3569
- const setForCurrentScene = useCallback8((value) => {
4763
+ const setForCurrentScene = useCallback11((value) => {
3570
4764
  const sid = activeSceneIdRef.current;
3571
4765
  if (sid === null) return;
3572
4766
  setStateMap((prev) => {
@@ -3577,7 +4771,7 @@ function useSceneState(activeSceneId, initialValue) {
3577
4771
  return newMap;
3578
4772
  });
3579
4773
  }, [initialValue]);
3580
- const setForScene = useCallback8((sceneId, value) => {
4774
+ const setForScene = useCallback11((sceneId, value) => {
3581
4775
  setStateMap((prev) => {
3582
4776
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
3583
4777
  const next = typeof value === "function" ? value(current) : value;
@@ -3590,10 +4784,10 @@ function useSceneState(activeSceneId, initialValue) {
3590
4784
  }
3591
4785
 
3592
4786
  // src/hooks/useAnySolo.ts
3593
- import { useEffect as useEffect12, useState as useState13 } from "react";
4787
+ import { useEffect as useEffect14, useState as useState16 } from "react";
3594
4788
  function useAnySolo(host) {
3595
- const [anySolo, setAnySolo] = useState13(false);
3596
- useEffect12(() => {
4789
+ const [anySolo, setAnySolo] = useState16(false);
4790
+ useEffect14(() => {
3597
4791
  let active = true;
3598
4792
  const refresh = () => {
3599
4793
  host.isAnySoloActive().then((v) => {
@@ -3612,7 +4806,7 @@ function useAnySolo(host) {
3612
4806
  }
3613
4807
 
3614
4808
  // src/hooks/useSoundHistory.ts
3615
- import { useCallback as useCallback9, useMemo as useMemo5, useRef as useRef12, useState as useState14 } from "react";
4809
+ import { useCallback as useCallback12, useMemo as useMemo7, useRef as useRef15, useState as useState17 } from "react";
3616
4810
  var EMPTY = { entries: [], cursor: -1 };
3617
4811
  function sameDescriptor(a, b) {
3618
4812
  if (a === b) return true;
@@ -3624,14 +4818,14 @@ function sameDescriptor(a, b) {
3624
4818
  }
3625
4819
  function useSoundHistory(applySound, opts = {}) {
3626
4820
  const max = Math.max(2, opts.max ?? 24);
3627
- const applyRef = useRef12(applySound);
4821
+ const applyRef = useRef15(applySound);
3628
4822
  applyRef.current = applySound;
3629
- const onChangeRef = useRef12(opts.onChange);
4823
+ const onChangeRef = useRef15(opts.onChange);
3630
4824
  onChangeRef.current = opts.onChange;
3631
- const dataRef = useRef12({});
3632
- const [, setVersion] = useState14(0);
3633
- const bump = useCallback9(() => setVersion((v) => v + 1), []);
3634
- const commit = useCallback9(
4825
+ const dataRef = useRef15({});
4826
+ const [, setVersion] = useState17(0);
4827
+ const bump = useCallback12(() => setVersion((v) => v + 1), []);
4828
+ const commit = useCallback12(
3635
4829
  (trackId, next, notify) => {
3636
4830
  dataRef.current = { ...dataRef.current, [trackId]: next };
3637
4831
  bump();
@@ -3639,7 +4833,7 @@ function useSoundHistory(applySound, opts = {}) {
3639
4833
  },
3640
4834
  [bump]
3641
4835
  );
3642
- const record = useCallback9(
4836
+ const record = useCallback12(
3643
4837
  (trackId, descriptor, label) => {
3644
4838
  const h = dataRef.current[trackId];
3645
4839
  const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
@@ -3654,7 +4848,7 @@ function useSoundHistory(applySound, opts = {}) {
3654
4848
  },
3655
4849
  [max, commit]
3656
4850
  );
3657
- const restoreTo = useCallback9(
4851
+ const restoreTo = useCallback12(
3658
4852
  async (trackId, index) => {
3659
4853
  const h = dataRef.current[trackId];
3660
4854
  if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
@@ -3664,7 +4858,7 @@ function useSoundHistory(applySound, opts = {}) {
3664
4858
  },
3665
4859
  [commit]
3666
4860
  );
3667
- const undo = useCallback9(
4861
+ const undo = useCallback12(
3668
4862
  (trackId) => {
3669
4863
  const h = dataRef.current[trackId];
3670
4864
  if (!h || h.cursor <= 0) return Promise.resolve(false);
@@ -3672,7 +4866,7 @@ function useSoundHistory(applySound, opts = {}) {
3672
4866
  },
3673
4867
  [restoreTo]
3674
4868
  );
3675
- const toggleFavorite = useCallback9(
4869
+ const toggleFavorite = useCallback12(
3676
4870
  (trackId, index) => {
3677
4871
  const h = dataRef.current[trackId];
3678
4872
  if (!h || index < 0 || index >= h.entries.length) return;
@@ -3681,7 +4875,7 @@ function useSoundHistory(applySound, opts = {}) {
3681
4875
  },
3682
4876
  [commit]
3683
4877
  );
3684
- const restore = useCallback9(
4878
+ const restore = useCallback12(
3685
4879
  (trackId, state) => {
3686
4880
  const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3687
4881
  const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
@@ -3690,15 +4884,15 @@ function useSoundHistory(applySound, opts = {}) {
3690
4884
  },
3691
4885
  [commit]
3692
4886
  );
3693
- const list = useCallback9(
4887
+ const list = useCallback12(
3694
4888
  (trackId) => dataRef.current[trackId] ?? EMPTY,
3695
4889
  []
3696
4890
  );
3697
- const canUndo = useCallback9((trackId) => {
4891
+ const canUndo = useCallback12((trackId) => {
3698
4892
  const h = dataRef.current[trackId];
3699
4893
  return !!h && h.cursor > 0;
3700
4894
  }, []);
3701
- const clear = useCallback9(
4895
+ const clear = useCallback12(
3702
4896
  (trackId) => {
3703
4897
  if (dataRef.current[trackId]) {
3704
4898
  const next = { ...dataRef.current };
@@ -3710,102 +4904,18 @@ function useSoundHistory(applySound, opts = {}) {
3710
4904
  },
3711
4905
  [bump]
3712
4906
  );
3713
- const reset = useCallback9(() => {
4907
+ const reset = useCallback12(() => {
3714
4908
  dataRef.current = {};
3715
4909
  bump();
3716
4910
  }, [bump]);
3717
- return useMemo5(
4911
+ return useMemo7(
3718
4912
  () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3719
4913
  [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3720
4914
  );
3721
4915
  }
3722
4916
 
3723
- // src/hooks/useTrackReorder.ts
3724
- import { useCallback as useCallback10, useRef as useRef13, useState as useState15 } from "react";
3725
- function moveItem(arr, from, to) {
3726
- const next = arr.slice();
3727
- if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3728
- return next;
3729
- }
3730
- const [moved] = next.splice(from, 1);
3731
- next.splice(to, 0, moved);
3732
- return next;
3733
- }
3734
- function useTrackReorder({
3735
- host,
3736
- items,
3737
- setItems,
3738
- getId,
3739
- onError
3740
- }) {
3741
- const [draggingIndex, setDraggingIndex] = useState15(null);
3742
- const [dragOverIndex, setDragOverIndex] = useState15(null);
3743
- const fromRef = useRef13(null);
3744
- const itemsRef = useRef13(items);
3745
- itemsRef.current = items;
3746
- const dragPropsFor = useCallback10(
3747
- (index) => ({
3748
- handleProps: {
3749
- draggable: true,
3750
- onDragStart: (e) => {
3751
- fromRef.current = index;
3752
- setDraggingIndex(index);
3753
- if (e.dataTransfer) {
3754
- e.dataTransfer.effectAllowed = "move";
3755
- try {
3756
- e.dataTransfer.setData("text/plain", String(index));
3757
- } catch {
3758
- }
3759
- }
3760
- },
3761
- onDragEnd: () => {
3762
- fromRef.current = null;
3763
- setDraggingIndex(null);
3764
- setDragOverIndex(null);
3765
- }
3766
- },
3767
- rowProps: {
3768
- onDragEnter: (e) => {
3769
- if (fromRef.current === null) return;
3770
- e.preventDefault();
3771
- setDragOverIndex(index);
3772
- },
3773
- onDragOver: (e) => {
3774
- if (fromRef.current === null) return;
3775
- e.preventDefault();
3776
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3777
- setDragOverIndex((cur) => cur === index ? cur : index);
3778
- },
3779
- onDragLeave: () => {
3780
- setDragOverIndex((cur) => cur === index ? null : cur);
3781
- },
3782
- onDrop: (e) => {
3783
- e.preventDefault();
3784
- const from = fromRef.current;
3785
- fromRef.current = null;
3786
- setDraggingIndex(null);
3787
- setDragOverIndex(null);
3788
- if (from === null || from === index) return;
3789
- const prev = itemsRef.current;
3790
- const next = moveItem(prev, from, index);
3791
- setItems(next);
3792
- const ids = next.map(getId);
3793
- Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3794
- setItems(prev);
3795
- onError?.(err);
3796
- });
3797
- }
3798
- },
3799
- isDragging: draggingIndex === index,
3800
- isDragTarget: dragOverIndex === index && draggingIndex !== index
3801
- }),
3802
- [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3803
- );
3804
- return { dragPropsFor, draggingIndex, dragOverIndex };
3805
- }
3806
-
3807
4917
  // src/constants/sdk-version.ts
3808
- var PLUGIN_SDK_VERSION = "2.26.0";
4918
+ var PLUGIN_SDK_VERSION = "2.34.0";
3809
4919
 
3810
4920
  // src/utils/format-concurrent-tracks.ts
3811
4921
  function formatConcurrentTracks(ctx) {
@@ -3948,6 +5058,8 @@ function pickTopKWeighted(scored, options = {}) {
3948
5058
  return top[top.length - 1].item;
3949
5059
  }
3950
5060
  export {
5061
+ AUDIO_EFFECTS,
5062
+ AUDIO_EFFECT_LABEL,
3951
5063
  ConfirmDialog,
3952
5064
  CrossfadeModal,
3953
5065
  CrossfadeTrackRow,
@@ -3965,6 +5077,8 @@ export {
3965
5077
  FX_DISPLAY_LABELS,
3966
5078
  FX_ENGINE_PLUGIN_NAMES,
3967
5079
  FX_PRESET_CONFIGS,
5080
+ FadeModal,
5081
+ FadeTrackRow,
3968
5082
  FxToggleBar,
3969
5083
  GUTTER_W,
3970
5084
  ImportTrackModal,
@@ -3983,30 +5097,50 @@ export {
3983
5097
  SamplePackCTACard,
3984
5098
  ScrollingWaveform,
3985
5099
  SorceryProgressBar,
5100
+ TEXTURAL_ROLES,
5101
+ TRANSITION_DESIGNER_DRAFT_KEY,
3986
5102
  TrackDrawer,
3987
5103
  TrackMeterStrip,
3988
5104
  TrackRow,
5105
+ TransitionDesigner,
3989
5106
  VolumeSlider,
3990
5107
  WaveformView,
3991
5108
  analyzeWavPeak,
5109
+ asAudioEffect,
3992
5110
  asCrossfadeMeta,
5111
+ asFadeMeta,
5112
+ asTransitionDesignerDraft,
3993
5113
  buildCrossfadeInpaintPrompt,
3994
5114
  buildCrossfadeVolumeCurves,
5115
+ buildFadeVolumeCurve,
5116
+ buildRowSlots,
3995
5117
  calculateTimeBasedTarget,
3996
5118
  cellToPx,
3997
5119
  centerScrollTop,
3998
5120
  computePeaks,
5121
+ dbIdsFromKeys,
3999
5122
  dbToSlider,
5123
+ defaultFadeGesture,
4000
5124
  drawWaveform,
4001
5125
  formatConcurrentTracks,
5126
+ hashString,
4002
5127
  moveItem,
5128
+ normalizeSlots,
5129
+ padPair,
5130
+ padSlots,
4003
5131
  parseCrossfadePairs,
5132
+ parseFades,
4004
5133
  pickTopKWeighted,
4005
5134
  pitchToName,
4006
5135
  pxToCell,
5136
+ reconcileSlots,
4007
5137
  resizeNoteDuration,
5138
+ rowKey,
5139
+ rowType,
4008
5140
  scorePromptMatch,
4009
5141
  sliderToDb,
5142
+ slotsEqual,
5143
+ soundIdentity,
4010
5144
  synthesizeCuePoints,
4011
5145
  tokenizePrompt,
4012
5146
  transposeNotes,