@signalsandsorcery/plugin-sdk 2.25.1 → 2.26.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.d.mts CHANGED
@@ -547,6 +547,11 @@ interface PluginHost {
547
547
  key: string;
548
548
  mode: string;
549
549
  } | null>;
550
+ /**
551
+ * Read a scene's human display name by db id (for labelling a crossfade's
552
+ * origin/target scenes). Optional — callers MUST null-check. @since SDK 2.26.0
553
+ */
554
+ getSceneName?(sceneDbId: string): Promise<string | null>;
550
555
  /** Subscribe to transport state changes. Returns unsubscribe function. */
551
556
  onTransportEvent(listener: TransportEventListener): UnsubscribeFn;
552
557
  /** Subscribe to deck boundary events. Returns unsubscribe function. */
@@ -2602,6 +2607,10 @@ interface CrossfadePairMeta {
2602
2607
  sliderPos: number;
2603
2608
  originDbId: string;
2604
2609
  targetDbId: string;
2610
+ /** DB id of the ORIGIN source track (in the from scene) — drives the "used once" exclusion. */
2611
+ originSourceDbId: string;
2612
+ /** DB id of the TARGET source track (in the to scene). */
2613
+ targetSourceDbId: string;
2605
2614
  originSourceName: string;
2606
2615
  originSoundLabel: string;
2607
2616
  targetSourceName: string;
@@ -2826,8 +2835,9 @@ declare function ImportTrackModal({ host, open, onClose, onImported, title, test
2826
2835
  *
2827
2836
  * Shown only inside a `scene_type='transition'` scene. The user picks an ORIGIN
2828
2837
  * track (from the transition's FROM scene) and a TARGET track (from its TO
2829
- * scene). Crossfades are same-role: once an origin is chosen, the target
2830
- * dropdown is filtered to the origin's role.
2838
+ * scene), in ANY order the only constraint is same plugin/family (the picker is
2839
+ * per-panel). A source track already used in a crossfade is hidden (via
2840
+ * excludeSourceDbIds), so each source is used at most once.
2831
2841
  *
2832
2842
  * Self-fetching: given the scoped `host`, it calls `host.listSceneFamilyTracks`
2833
2843
  * for both scenes (ungated — a transition deliberately bridges different keys).
@@ -2845,7 +2855,7 @@ interface CrossfadeSelection {
2845
2855
  dbId: string;
2846
2856
  /** Display name (for the row caption). */
2847
2857
  name: string;
2848
- /** Musical role (same for both enforced by the picker). */
2858
+ /** Musical role of the source track (the panel uses the TARGET's for generation). */
2849
2859
  role?: string;
2850
2860
  }
2851
2861
  interface CrossfadeModalProps {
@@ -2861,6 +2871,12 @@ interface CrossfadeModalProps {
2861
2871
  fromSceneName?: string;
2862
2872
  /** Display name for the target scene heading (optional). */
2863
2873
  toSceneName?: string;
2874
+ /**
2875
+ * Source-track DB ids already used in a crossfade (origin + target of every
2876
+ * existing pair in this panel). Hidden from BOTH dropdowns so each source is
2877
+ * used at most once. @since SDK 2.26.0
2878
+ */
2879
+ excludeSourceDbIds?: readonly string[];
2864
2880
  /** Close handler (Escape, backdrop, Cancel, or after a successful create). */
2865
2881
  onClose: () => void;
2866
2882
  /** Build the crossfade pair. Should reject on failure so the modal shows it. */
@@ -2868,7 +2884,7 @@ interface CrossfadeModalProps {
2868
2884
  /** data-testid prefix. */
2869
2885
  testIdPrefix?: string;
2870
2886
  }
2871
- declare function CrossfadeModal({ host, open, fromSceneId, toSceneId, fromSceneName, toSceneName, onClose, onCreate, testIdPrefix, }: CrossfadeModalProps): React.ReactElement | null;
2887
+ declare function CrossfadeModal({ host, open, fromSceneId, toSceneId, fromSceneName, toSceneName, excludeSourceDbIds, onClose, onCreate, testIdPrefix, }: CrossfadeModalProps): React.ReactElement | null;
2872
2888
 
2873
2889
  /**
2874
2890
  * ConfirmDialog — styled in-app confirmation modal (SDK component).
@@ -3593,7 +3609,7 @@ declare function useSoundHistory(applySound: (trackId: string, descriptor: unkno
3593
3609
  * Registry checks semver.gte(PLUGIN_SDK_VERSION, manifest.minHostVersion)
3594
3610
  * during activation and marks incompatible plugins accordingly.
3595
3611
  */
3596
- declare const PLUGIN_SDK_VERSION = "2.25.0";
3612
+ declare const PLUGIN_SDK_VERSION = "2.26.0";
3597
3613
 
3598
3614
  /**
3599
3615
  * FX Preset Definitions
package/dist/index.d.ts CHANGED
@@ -547,6 +547,11 @@ interface PluginHost {
547
547
  key: string;
548
548
  mode: string;
549
549
  } | null>;
550
+ /**
551
+ * Read a scene's human display name by db id (for labelling a crossfade's
552
+ * origin/target scenes). Optional — callers MUST null-check. @since SDK 2.26.0
553
+ */
554
+ getSceneName?(sceneDbId: string): Promise<string | null>;
550
555
  /** Subscribe to transport state changes. Returns unsubscribe function. */
551
556
  onTransportEvent(listener: TransportEventListener): UnsubscribeFn;
552
557
  /** Subscribe to deck boundary events. Returns unsubscribe function. */
@@ -2602,6 +2607,10 @@ interface CrossfadePairMeta {
2602
2607
  sliderPos: number;
2603
2608
  originDbId: string;
2604
2609
  targetDbId: string;
2610
+ /** DB id of the ORIGIN source track (in the from scene) — drives the "used once" exclusion. */
2611
+ originSourceDbId: string;
2612
+ /** DB id of the TARGET source track (in the to scene). */
2613
+ targetSourceDbId: string;
2605
2614
  originSourceName: string;
2606
2615
  originSoundLabel: string;
2607
2616
  targetSourceName: string;
@@ -2826,8 +2835,9 @@ declare function ImportTrackModal({ host, open, onClose, onImported, title, test
2826
2835
  *
2827
2836
  * Shown only inside a `scene_type='transition'` scene. The user picks an ORIGIN
2828
2837
  * track (from the transition's FROM scene) and a TARGET track (from its TO
2829
- * scene). Crossfades are same-role: once an origin is chosen, the target
2830
- * dropdown is filtered to the origin's role.
2838
+ * scene), in ANY order the only constraint is same plugin/family (the picker is
2839
+ * per-panel). A source track already used in a crossfade is hidden (via
2840
+ * excludeSourceDbIds), so each source is used at most once.
2831
2841
  *
2832
2842
  * Self-fetching: given the scoped `host`, it calls `host.listSceneFamilyTracks`
2833
2843
  * for both scenes (ungated — a transition deliberately bridges different keys).
@@ -2845,7 +2855,7 @@ interface CrossfadeSelection {
2845
2855
  dbId: string;
2846
2856
  /** Display name (for the row caption). */
2847
2857
  name: string;
2848
- /** Musical role (same for both enforced by the picker). */
2858
+ /** Musical role of the source track (the panel uses the TARGET's for generation). */
2849
2859
  role?: string;
2850
2860
  }
2851
2861
  interface CrossfadeModalProps {
@@ -2861,6 +2871,12 @@ interface CrossfadeModalProps {
2861
2871
  fromSceneName?: string;
2862
2872
  /** Display name for the target scene heading (optional). */
2863
2873
  toSceneName?: string;
2874
+ /**
2875
+ * Source-track DB ids already used in a crossfade (origin + target of every
2876
+ * existing pair in this panel). Hidden from BOTH dropdowns so each source is
2877
+ * used at most once. @since SDK 2.26.0
2878
+ */
2879
+ excludeSourceDbIds?: readonly string[];
2864
2880
  /** Close handler (Escape, backdrop, Cancel, or after a successful create). */
2865
2881
  onClose: () => void;
2866
2882
  /** Build the crossfade pair. Should reject on failure so the modal shows it. */
@@ -2868,7 +2884,7 @@ interface CrossfadeModalProps {
2868
2884
  /** data-testid prefix. */
2869
2885
  testIdPrefix?: string;
2870
2886
  }
2871
- declare function CrossfadeModal({ host, open, fromSceneId, toSceneId, fromSceneName, toSceneName, onClose, onCreate, testIdPrefix, }: CrossfadeModalProps): React.ReactElement | null;
2887
+ declare function CrossfadeModal({ host, open, fromSceneId, toSceneId, fromSceneName, toSceneName, excludeSourceDbIds, onClose, onCreate, testIdPrefix, }: CrossfadeModalProps): React.ReactElement | null;
2872
2888
 
2873
2889
  /**
2874
2890
  * ConfirmDialog — styled in-app confirmation modal (SDK component).
@@ -3593,7 +3609,7 @@ declare function useSoundHistory(applySound: (trackId: string, descriptor: unkno
3593
3609
  * Registry checks semver.gte(PLUGIN_SDK_VERSION, manifest.minHostVersion)
3594
3610
  * during activation and marks incompatible plugins accordingly.
3595
3611
  */
3596
- declare const PLUGIN_SDK_VERSION = "2.25.0";
3612
+ declare const PLUGIN_SDK_VERSION = "2.26.0";
3597
3613
 
3598
3614
  /**
3599
3615
  * FX Preset Definitions
package/dist/index.js CHANGED
@@ -2570,6 +2570,8 @@ function parseCrossfadePairs(sceneData) {
2570
2570
  sliderPos: g.origin.meta.sliderPos,
2571
2571
  originDbId: g.origin.dbId,
2572
2572
  targetDbId: g.target.dbId,
2573
+ originSourceDbId: g.origin.meta.sourceTrackDbId,
2574
+ targetSourceDbId: g.target.meta.sourceTrackDbId,
2573
2575
  originSourceName: g.origin.meta.sourceName,
2574
2576
  originSoundLabel: g.origin.meta.soundLabel,
2575
2577
  targetSourceName: g.target.meta.sourceName,
@@ -2845,6 +2847,7 @@ function CrossfadeModal({
2845
2847
  toSceneId,
2846
2848
  fromSceneName,
2847
2849
  toSceneName,
2850
+ excludeSourceDbIds,
2848
2851
  onClose,
2849
2852
  onCreate,
2850
2853
  testIdPrefix = "crossfade-modal"
@@ -2854,6 +2857,8 @@ function CrossfadeModal({
2854
2857
  const [targetDbId, setTargetDbId] = (0, import_react12.useState)("");
2855
2858
  const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2856
2859
  const [error, setError] = (0, import_react12.useState)(null);
2860
+ const [fromName, setFromName] = (0, import_react12.useState)(null);
2861
+ const [toName, setToName] = (0, import_react12.useState)(null);
2857
2862
  const cancelRef = (0, import_react12.useRef)(null);
2858
2863
  const refresh = (0, import_react12.useCallback)(async () => {
2859
2864
  if (!host.listSceneFamilyTracks) {
@@ -2862,12 +2867,15 @@ function CrossfadeModal({
2862
2867
  }
2863
2868
  setLoad({ status: "loading" });
2864
2869
  try {
2865
- const [origin, target] = await Promise.all([
2870
+ const [origin, target, fName, tName] = await Promise.all([
2866
2871
  host.listSceneFamilyTracks(fromSceneId),
2867
- host.listSceneFamilyTracks(toSceneId)
2872
+ host.listSceneFamilyTracks(toSceneId),
2873
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
2874
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2868
2875
  ]);
2876
+ setFromName(fName);
2877
+ setToName(tName);
2869
2878
  setLoad({ status: "ready", origin, target });
2870
- setOriginDbId(origin[0]?.dbId ?? "");
2871
2879
  } catch (err) {
2872
2880
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2873
2881
  }
@@ -2881,21 +2889,26 @@ function CrossfadeModal({
2881
2889
  void refresh();
2882
2890
  }
2883
2891
  }, [open, refresh]);
2884
- const originTrack = (0, import_react12.useMemo)(
2885
- () => load.status === "ready" ? load.origin.find((t) => t.dbId === originDbId) ?? null : null,
2886
- [load, originDbId]
2892
+ const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2893
+ const originCandidates = (0, import_react12.useMemo)(
2894
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
2895
+ [load, excludeSet]
2887
2896
  );
2888
- const originRole = originTrack?.role;
2889
- const targetCandidates = (0, import_react12.useMemo)(() => {
2890
- if (load.status !== "ready") return [];
2891
- if (!originRole) return load.target;
2892
- return load.target.filter((t) => t.role === originRole);
2893
- }, [load, originRole]);
2897
+ const targetCandidates = (0, import_react12.useMemo)(
2898
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
2899
+ [load, excludeSet]
2900
+ );
2901
+ (0, import_react12.useEffect)(() => {
2902
+ if (!originCandidates.some((t) => t.dbId === originDbId)) {
2903
+ setOriginDbId(originCandidates[0]?.dbId ?? "");
2904
+ }
2905
+ }, [originCandidates, originDbId]);
2894
2906
  (0, import_react12.useEffect)(() => {
2895
2907
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2896
2908
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2897
2909
  }
2898
2910
  }, [targetCandidates, targetDbId]);
2911
+ const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2899
2912
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2900
2913
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2901
2914
  const handleClose = (0, import_react12.useCallback)(() => {
@@ -2916,6 +2929,8 @@ function CrossfadeModal({
2916
2929
  setIsCreating(false);
2917
2930
  }
2918
2931
  }, [originTrack, targetTrack, onCreate, onClose]);
2932
+ const fromLabel = fromName ?? fromSceneName ?? null;
2933
+ const toLabel = toName ?? toSceneName ?? null;
2919
2934
  if (!open) return null;
2920
2935
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
2921
2936
  "div",
@@ -2928,24 +2943,31 @@ function CrossfadeModal({
2928
2943
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2929
2944
  "Bridge a track from",
2930
2945
  " ",
2931
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromSceneName ?? "the origin scene" }),
2946
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2932
2947
  " into one from",
2933
2948
  " ",
2934
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toSceneName ?? "the target scene" }),
2949
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2935
2950
  ". Both layers share one generated part; each keeps its own preset."
2936
2951
  ] }),
2937
2952
  load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2938
2953
  load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2939
- load.status === "ready" && (load.origin.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2954
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
2940
2955
  "div",
2941
2956
  {
2942
2957
  className: "text-xs text-sas-muted py-4 text-center",
2943
2958
  "data-testid": `${testIdPrefix}-empty-origin`,
2944
- children: "No matching tracks in the origin scene. Add one there first."
2959
+ children: [
2960
+ "No available tracks in ",
2961
+ fromLabel ?? "the origin scene",
2962
+ ". Add one (or free one from another crossfade) first."
2963
+ ]
2945
2964
  }
2946
2965
  ) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2947
2966
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2948
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "Origin (top)" }),
2967
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2968
+ "Origin ",
2969
+ fromLabel ? `(${fromLabel})` : "(top)"
2970
+ ] }),
2949
2971
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2950
2972
  "select",
2951
2973
  {
@@ -2954,7 +2976,7 @@ function CrossfadeModal({
2954
2976
  onChange: (e) => setOriginDbId(e.target.value),
2955
2977
  disabled: isCreating,
2956
2978
  className: "sas-input w-full mt-0.5 text-xs",
2957
- children: load.origin.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2979
+ children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2958
2980
  t.name,
2959
2981
  t.role ? ` \xB7 ${t.role}` : ""
2960
2982
  ] }, t.dbId))
@@ -2963,13 +2985,13 @@ function CrossfadeModal({
2963
2985
  ] }),
2964
2986
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2965
2987
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2966
- "Target (bottom)",
2967
- originRole ? ` \xB7 ${originRole}` : ""
2988
+ "Target ",
2989
+ toLabel ? `(${toLabel})` : "(bottom)"
2968
2990
  ] }),
2969
2991
  targetCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2970
- "No ",
2971
- originRole ?? "matching",
2972
- " track in the target scene to crossfade into."
2992
+ "No available tracks in ",
2993
+ toLabel ?? "the target scene",
2994
+ " to crossfade into."
2973
2995
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2974
2996
  "select",
2975
2997
  {
@@ -3888,7 +3910,7 @@ function useTrackReorder({
3888
3910
  }
3889
3911
 
3890
3912
  // src/constants/sdk-version.ts
3891
- var PLUGIN_SDK_VERSION = "2.25.0";
3913
+ var PLUGIN_SDK_VERSION = "2.26.0";
3892
3914
 
3893
3915
  // src/utils/format-concurrent-tracks.ts
3894
3916
  function formatConcurrentTracks(ctx) {