@snowcone-app/ui 0.4.13 → 0.8.0

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.cts CHANGED
@@ -2894,6 +2894,13 @@ interface RealtimeContextValue {
2894
2894
  * than blanking it or logging console-only.
2895
2895
  */
2896
2896
  renderError: RealtimeRenderError | null;
2897
+ /**
2898
+ * Mockups the server refused for this session (`E_MOCKUP_NOT_BLESSED`: the
2899
+ * mockup's scene failed render parity and is served on no path). Keyed by
2900
+ * mockupId; the other mockups keep streaming. Render these as unavailable
2901
+ * — never as a stale or blank image.
2902
+ */
2903
+ unavailableMockups: ReadonlyMap<string, RealtimeRenderError>;
2897
2904
  isPendingMockups: boolean;
2898
2905
  canvasBlobsSent: number;
2899
2906
  colorBlobsSent: number;
@@ -3030,6 +3037,16 @@ interface RealtimeProviderProps {
3030
3037
  * via a renewable token. Without them, the legacy seal path runs (shim).
3031
3038
  */
3032
3039
  grantUrl?: string;
3040
+ /**
3041
+ * A grant minted server-side while the page rendered (POST grantUrl during
3042
+ * SSR). Used for the FIRST connection so the socket opens without a
3043
+ * grant round trip (382–566 ms on the PDP, 2026-09-05); renewals and
3044
+ * reconnects go through `grantUrl`. Ignored when already expired.
3045
+ */
3046
+ initialGrant?: {
3047
+ token: string;
3048
+ expiresAt: number;
3049
+ };
3033
3050
  /**
3034
3051
  * Frame delivery strategy. `"cropped"` (default, the fast path): once every
3035
3052
  * configured mockup has rendered one full frame (frozen internally as its
@@ -3048,7 +3065,7 @@ interface RealtimeProviderProps {
3048
3065
  *
3049
3066
  * Must be used inside a <Product> provider to access product data.
3050
3067
  */
3051
- declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
3068
+ declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, initialGrant, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
3052
3069
  /**
3053
3070
  * Hook to access realtime context
3054
3071
  * Throws if not inside RealtimeProvider
@@ -3625,6 +3642,21 @@ interface CanvasExportServiceConfig {
3625
3642
  enableRealtime?: () => void;
3626
3643
  /** Disable realtime function (called when exiting editor mode) */
3627
3644
  disableRealtime?: () => void;
3645
+ /**
3646
+ * Subscribe to "the frame for the last send landed" (RealtimeProvider's
3647
+ * pipeline-settled signal). Drives the one-in-flight send policy: a state
3648
+ * held while a frame is in flight goes out the moment this fires.
3649
+ */
3650
+ subscribePipelineSettled?: (callback: () => void) => () => void;
3651
+ /**
3652
+ * Pipeline depth: edits allowed in flight before the latest state is held
3653
+ * (default 2). The box's window is ~30 ms of a ~96 ms edit→frame at w4000 —
3654
+ * the rest is wire and decode — so with one in flight the box idles ~70% of
3655
+ * every cycle. Two keeps it busy and never queues on the box while its
3656
+ * window stays under half the round trip (the lab's rt_coalesce says when
3657
+ * it does not).
3658
+ */
3659
+ maxInFlight?: number;
3628
3660
  /**
3629
3661
  * Function to clear ImageBitmap cache (memory management for iOS Safari)
3630
3662
  * Pass clearImageBitmapCache from @snowcone-app/canvas
@@ -3655,9 +3687,17 @@ declare class CanvasExportService {
3655
3687
  private jsonModeSentSuccessfully;
3656
3688
  private stateChangeThrottleTimeout;
3657
3689
  private stateChangeLastSendTime;
3658
- /** How long to wait after last change before sending (quick discrete actions) */
3659
- private static SETTLE_MS;
3660
- /** During continuous activity (drag), force a send at least this often */
3690
+ /** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
3691
+ private inFlightSentAt;
3692
+ private maxInFlight;
3693
+ private subscribePipelineSettled;
3694
+ private unsubscribePipelineSettled;
3695
+ /**
3696
+ * Safety net for the one-in-flight policy: a held state never waits longer
3697
+ * than this for the in-flight frame (a swallowed render error would
3698
+ * otherwise hold the pipeline forever), and a change arriving while a
3699
+ * frame has been in flight this long is sent regardless.
3700
+ */
3661
3701
  private static MAX_WAIT_MS;
3662
3702
  private lastExportedArtwork;
3663
3703
  private initialArtwork;
@@ -3728,6 +3768,19 @@ declare class CanvasExportService {
3728
3768
  */
3729
3769
  private stateChangeCallCount;
3730
3770
  handleStateChange: (state: any) => void;
3771
+ /** A send landed (every mockup of it): free its slot, release the held state if any. */
3772
+ private onPipelineSettled;
3773
+ /**
3774
+ * The pointer was released (drag end, slider release, key up). Send the held
3775
+ * state NOW instead of waiting for the in-flight frame: the server coalesces
3776
+ * (latest wins), and the "let go" frame is the one the shopper waits for.
3777
+ * A no-op when nothing is held, so it is safe on every pointerup.
3778
+ */
3779
+ handleInteractionEnd: () => void;
3780
+ private armFlushTimer;
3781
+ /** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
3782
+ private dropStalledSends;
3783
+ private clearFlushTimer;
3731
3784
  private sendStateNow;
3732
3785
  /**
3733
3786
  * Reset the service state (e.g., when unmounting)
@@ -3791,6 +3844,8 @@ declare function useIsolatedCanvas(): {
3791
3844
  handleExport: (exports: Record<string, string | Blob>) => Promise<void>;
3792
3845
  /** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
3793
3846
  handleStateChange: (state: any) => void;
3847
+ /** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
3848
+ handleInteractionEnd: () => void;
3794
3849
  /** Call when entering editor mode */
3795
3850
  onEnterEditorMode: () => void;
3796
3851
  /** Call when exiting editor mode */
package/dist/index.d.ts CHANGED
@@ -2894,6 +2894,13 @@ interface RealtimeContextValue {
2894
2894
  * than blanking it or logging console-only.
2895
2895
  */
2896
2896
  renderError: RealtimeRenderError | null;
2897
+ /**
2898
+ * Mockups the server refused for this session (`E_MOCKUP_NOT_BLESSED`: the
2899
+ * mockup's scene failed render parity and is served on no path). Keyed by
2900
+ * mockupId; the other mockups keep streaming. Render these as unavailable
2901
+ * — never as a stale or blank image.
2902
+ */
2903
+ unavailableMockups: ReadonlyMap<string, RealtimeRenderError>;
2897
2904
  isPendingMockups: boolean;
2898
2905
  canvasBlobsSent: number;
2899
2906
  colorBlobsSent: number;
@@ -3030,6 +3037,16 @@ interface RealtimeProviderProps {
3030
3037
  * via a renewable token. Without them, the legacy seal path runs (shim).
3031
3038
  */
3032
3039
  grantUrl?: string;
3040
+ /**
3041
+ * A grant minted server-side while the page rendered (POST grantUrl during
3042
+ * SSR). Used for the FIRST connection so the socket opens without a
3043
+ * grant round trip (382–566 ms on the PDP, 2026-09-05); renewals and
3044
+ * reconnects go through `grantUrl`. Ignored when already expired.
3045
+ */
3046
+ initialGrant?: {
3047
+ token: string;
3048
+ expiresAt: number;
3049
+ };
3033
3050
  /**
3034
3051
  * Frame delivery strategy. `"cropped"` (default, the fast path): once every
3035
3052
  * configured mockup has rendered one full frame (frozen internally as its
@@ -3048,7 +3065,7 @@ interface RealtimeProviderProps {
3048
3065
  *
3049
3066
  * Must be used inside a <Product> provider to access product data.
3050
3067
  */
3051
- declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
3068
+ declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, initialGrant, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
3052
3069
  /**
3053
3070
  * Hook to access realtime context
3054
3071
  * Throws if not inside RealtimeProvider
@@ -3625,6 +3642,21 @@ interface CanvasExportServiceConfig {
3625
3642
  enableRealtime?: () => void;
3626
3643
  /** Disable realtime function (called when exiting editor mode) */
3627
3644
  disableRealtime?: () => void;
3645
+ /**
3646
+ * Subscribe to "the frame for the last send landed" (RealtimeProvider's
3647
+ * pipeline-settled signal). Drives the one-in-flight send policy: a state
3648
+ * held while a frame is in flight goes out the moment this fires.
3649
+ */
3650
+ subscribePipelineSettled?: (callback: () => void) => () => void;
3651
+ /**
3652
+ * Pipeline depth: edits allowed in flight before the latest state is held
3653
+ * (default 2). The box's window is ~30 ms of a ~96 ms edit→frame at w4000 —
3654
+ * the rest is wire and decode — so with one in flight the box idles ~70% of
3655
+ * every cycle. Two keeps it busy and never queues on the box while its
3656
+ * window stays under half the round trip (the lab's rt_coalesce says when
3657
+ * it does not).
3658
+ */
3659
+ maxInFlight?: number;
3628
3660
  /**
3629
3661
  * Function to clear ImageBitmap cache (memory management for iOS Safari)
3630
3662
  * Pass clearImageBitmapCache from @snowcone-app/canvas
@@ -3655,9 +3687,17 @@ declare class CanvasExportService {
3655
3687
  private jsonModeSentSuccessfully;
3656
3688
  private stateChangeThrottleTimeout;
3657
3689
  private stateChangeLastSendTime;
3658
- /** How long to wait after last change before sending (quick discrete actions) */
3659
- private static SETTLE_MS;
3660
- /** During continuous activity (drag), force a send at least this often */
3690
+ /** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
3691
+ private inFlightSentAt;
3692
+ private maxInFlight;
3693
+ private subscribePipelineSettled;
3694
+ private unsubscribePipelineSettled;
3695
+ /**
3696
+ * Safety net for the one-in-flight policy: a held state never waits longer
3697
+ * than this for the in-flight frame (a swallowed render error would
3698
+ * otherwise hold the pipeline forever), and a change arriving while a
3699
+ * frame has been in flight this long is sent regardless.
3700
+ */
3661
3701
  private static MAX_WAIT_MS;
3662
3702
  private lastExportedArtwork;
3663
3703
  private initialArtwork;
@@ -3728,6 +3768,19 @@ declare class CanvasExportService {
3728
3768
  */
3729
3769
  private stateChangeCallCount;
3730
3770
  handleStateChange: (state: any) => void;
3771
+ /** A send landed (every mockup of it): free its slot, release the held state if any. */
3772
+ private onPipelineSettled;
3773
+ /**
3774
+ * The pointer was released (drag end, slider release, key up). Send the held
3775
+ * state NOW instead of waiting for the in-flight frame: the server coalesces
3776
+ * (latest wins), and the "let go" frame is the one the shopper waits for.
3777
+ * A no-op when nothing is held, so it is safe on every pointerup.
3778
+ */
3779
+ handleInteractionEnd: () => void;
3780
+ private armFlushTimer;
3781
+ /** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
3782
+ private dropStalledSends;
3783
+ private clearFlushTimer;
3731
3784
  private sendStateNow;
3732
3785
  /**
3733
3786
  * Reset the service state (e.g., when unmounting)
@@ -3791,6 +3844,8 @@ declare function useIsolatedCanvas(): {
3791
3844
  handleExport: (exports: Record<string, string | Blob>) => Promise<void>;
3792
3845
  /** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
3793
3846
  handleStateChange: (state: any) => void;
3847
+ /** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
3848
+ handleInteractionEnd: () => void;
3794
3849
  /** Call when entering editor mode */
3795
3850
  onEnterEditorMode: () => void;
3796
3851
  /** Call when exiting editor mode */
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ var brand_default = {
39
39
  display_name: "Snowcone",
40
40
  tagline: "Your art, on everything.",
41
41
  seo_title: "Snowcone \u2014 Your art, on everything.",
42
- legal_entity: "Snowcone Inc.",
42
+ legal_entity: "Art Shop AI LLC",
43
43
  lang: "en",
44
44
  wordmark: "/assets/wordmark-en.svg"
45
45
  },
@@ -49,7 +49,7 @@ var brand_default = {
49
49
  display_name: "\u96EA\u7ED8 (Snowcone)",
50
50
  tagline: "\u5728\u96EA\u767D\u4E4B\u4E0A\uFF0C\u7ED8\u51FA\u4F60\u7684\u8272\u5F69\u3002",
51
51
  seo_title: "\u96EA\u7ED8 (Snowcone) \u2014 \u5728\u96EA\u767D\u4E4B\u4E0A\uFF0C\u7ED8\u51FA\u4F60\u7684\u8272\u5F69",
52
- legal_entity: "\u96EA\u7ED8\u79D1\u6280\u6709\u9650\u516C\u53F8",
52
+ legal_entity: "Art Shop AI LLC",
53
53
  lang: "zh",
54
54
  wordmark: "/assets/wordmark-zh.svg"
55
55
  },
@@ -59,7 +59,7 @@ var brand_default = {
59
59
  display_name: "Snowcone",
60
60
  tagline: "Tu arte, en todo.",
61
61
  seo_title: "Snowcone \u2014 Tu arte, en todo.",
62
- legal_entity: "Snowcone Inc.",
62
+ legal_entity: "Art Shop AI LLC",
63
63
  lang: "es",
64
64
  wordmark: "/assets/wordmark-en.svg"
65
65
  },
@@ -69,7 +69,7 @@ var brand_default = {
69
69
  display_name: "Snowcone",
70
70
  tagline: "Votre art, sur tout.",
71
71
  seo_title: "Snowcone \u2014 Votre art, sur tout.",
72
- legal_entity: "Snowcone Inc.",
72
+ legal_entity: "Art Shop AI LLC",
73
73
  lang: "fr",
74
74
  wordmark: "/assets/wordmark-en.svg"
75
75
  }
@@ -5648,13 +5648,18 @@ function RealtimeProvider({
5648
5648
  autoEnable = false,
5649
5649
  shop,
5650
5650
  grantUrl,
5651
+ initialGrant,
5651
5652
  delivery = "cropped"
5652
5653
  }) {
5653
5654
  const productContext = useProductOptional();
5654
5655
  const mockupBg = useMockupBackgroundOptional();
5655
5656
  const [isEnabled, setIsEnabled] = useState17(autoEnable);
5656
5657
  const useSessionGrant = Boolean(shop && grantUrl);
5658
+ const initialGrantRef = useRef12(initialGrant);
5657
5659
  const getToken = useCallback10(async () => {
5660
+ const minted = initialGrantRef.current;
5661
+ initialGrantRef.current = void 0;
5662
+ if (minted && minted.expiresAt * 1e3 - Date.now() > 3e4) return minted;
5658
5663
  const res = await fetch(grantUrl, {
5659
5664
  method: "POST",
5660
5665
  headers: { "Content-Type": "application/json" },
@@ -5677,6 +5682,9 @@ function RealtimeProvider({
5677
5682
  const [spreadCropsVersion, setSpreadCropsVersion] = useState17(0);
5678
5683
  const lastSentSpreadCropsRef = useRef12("null");
5679
5684
  const lastSentColorsRef = useRef12({});
5685
+ const [unavailableMockups, setUnavailableMockups] = useState17(
5686
+ () => /* @__PURE__ */ new Map()
5687
+ );
5680
5688
  const realtimeMockup = useRealtimeMockup2({
5681
5689
  wsUrl,
5682
5690
  getToken: useSessionGrant ? getToken : void 0,
@@ -5706,6 +5714,10 @@ function RealtimeProvider({
5706
5714
  },
5707
5715
  onError: (error, detail) => {
5708
5716
  console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
5717
+ if (detail.mockupId) {
5718
+ const id = detail.mockupId;
5719
+ setUnavailableMockups((prev) => prev.get(id)?.code === detail.code ? prev : new Map(prev).set(id, detail));
5720
+ }
5709
5721
  }
5710
5722
  });
5711
5723
  const subscribeRTCTiming = useCallback10((_callback) => {
@@ -5726,7 +5738,8 @@ function RealtimeProvider({
5726
5738
  connect,
5727
5739
  disconnect,
5728
5740
  flushPendingBlobs,
5729
- hasPendingBlobs
5741
+ hasPendingBlobs,
5742
+ getLastSentVersion
5730
5743
  } = realtimeMockup;
5731
5744
  const mockupResultsRef = useRef12([]);
5732
5745
  const mockupSubscribersRef = useRef12(
@@ -5748,6 +5761,8 @@ function RealtimeProvider({
5748
5761
  const blobQueuedGenRef = useRef12(0);
5749
5762
  const hasBlobInFlightRef = useRef12(false);
5750
5763
  const pipelineResetCooldownRef = useRef12(false);
5764
+ const dueByVersionRef = useRef12(/* @__PURE__ */ new Map());
5765
+ const configuredMockupIdsRef = useRef12([]);
5751
5766
  const mockupUrlCacheRef = useRef12(/* @__PURE__ */ new Map());
5752
5767
  const registeredBlobUrlsRef = useRef12(/* @__PURE__ */ new Set());
5753
5768
  const pendingPlacementsRef = useRef12(/* @__PURE__ */ new Set());
@@ -5866,7 +5881,29 @@ function RealtimeProvider({
5866
5881
  console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
5867
5882
  }
5868
5883
  });
5869
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
5884
+ let settledVersions = 0;
5885
+ const due = dueByVersionRef.current;
5886
+ results.forEach((result) => {
5887
+ const v = result.requestVersion;
5888
+ if (v === void 0) return;
5889
+ const entry = due.get(v);
5890
+ if (!entry) return;
5891
+ if (entry.due <= 1) {
5892
+ due.delete(v);
5893
+ settledVersions += 1;
5894
+ } else {
5895
+ entry.due -= 1;
5896
+ }
5897
+ });
5898
+ due.forEach((entry, v) => {
5899
+ if (now - entry.at > 1e4) due.delete(v);
5900
+ });
5901
+ if (settledVersions > 0) {
5902
+ if (!pipelineResetCooldownRef.current) {
5903
+ hasBlobInFlightRef.current = false;
5904
+ for (let i = 0; i < settledVersions; i++) pipelineSettledSubscribersRef.current.forEach((cb) => cb());
5905
+ }
5906
+ } else if (due.size === 0 && hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
5870
5907
  hasBlobInFlightRef.current = false;
5871
5908
  pipelineSettledSubscribersRef.current.forEach((cb) => cb());
5872
5909
  }
@@ -5995,11 +6032,17 @@ function RealtimeProvider({
5995
6032
  canvasBlobsSentRef.current += 1;
5996
6033
  lastBlobSentTimeRef.current = (/* @__PURE__ */ new Date()).toLocaleTimeString();
5997
6034
  isPendingMockupsRef.current = true;
6035
+ const version = getLastSentVersion();
6036
+ if (version > 0) {
6037
+ const configured = configuredMockupIdsRef.current.length;
6038
+ const expected = Math.max(1, Math.min(configured || Infinity, mockupCount || Infinity));
6039
+ dueByVersionRef.current.set(version, { due: Number.isFinite(expected) ? expected : 1, at: Date.now() });
6040
+ }
5998
6041
  pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
5999
6042
  }
6000
6043
  return result;
6001
6044
  },
6002
- [sendCanvasStateRaw]
6045
+ [sendCanvasStateRaw, getLastSentVersion]
6003
6046
  );
6004
6047
  const sendColorBlobTracked = useCallback10(
6005
6048
  (placement, color) => {
@@ -6019,6 +6062,8 @@ function RealtimeProvider({
6019
6062
  return () => {
6020
6063
  disconnect();
6021
6064
  configSentRef.current = false;
6065
+ dueByVersionRef.current.clear();
6066
+ setUnavailableMockups((prev) => prev.size ? /* @__PURE__ */ new Map() : prev);
6022
6067
  lastSentVariantIdRef.current = null;
6023
6068
  lastSentBgRef.current = void 0;
6024
6069
  lastSentSpreadCropsRef.current = "null";
@@ -6062,6 +6107,7 @@ function RealtimeProvider({
6062
6107
  }
6063
6108
  });
6064
6109
  }
6110
+ configuredMockupIdsRef.current = mockupIds;
6065
6111
  const configPayload = useSessionGrant ? {
6066
6112
  productId,
6067
6113
  mockupIds,
@@ -6277,6 +6323,7 @@ function RealtimeProvider({
6277
6323
  () => {
6278
6324
  pipelineGenRef.current++;
6279
6325
  hasBlobInFlightRef.current = false;
6326
+ dueByVersionRef.current.clear();
6280
6327
  pipelineResetCooldownRef.current = true;
6281
6328
  requestAnimationFrame(() => {
6282
6329
  pipelineResetCooldownRef.current = false;
@@ -6415,6 +6462,7 @@ function RealtimeProvider({
6415
6462
  isConnected,
6416
6463
  isConfigured,
6417
6464
  renderError,
6465
+ unavailableMockups,
6418
6466
  // Read from ref - this is a snapshot at render time, NOT reactive
6419
6467
  // For reactive updates, use getMockupResultsImmediate() or subscribe functions
6420
6468
  get mockupResults() {
@@ -6460,6 +6508,7 @@ function RealtimeProvider({
6460
6508
  isConnected,
6461
6509
  isConfigured,
6462
6510
  renderError,
6511
+ unavailableMockups,
6463
6512
  // mockupResults removed from deps - it's a getter that reads from ref
6464
6513
  canvasExportSize,
6465
6514
  mockupWidth,
@@ -8308,9 +8357,18 @@ var CanvasExportService = class _CanvasExportService {
8308
8357
  // Debounce state for handleStateChange
8309
8358
  stateChangeThrottleTimeout = null;
8310
8359
  stateChangeLastSendTime = 0;
8311
- /** How long to wait after last change before sending (quick discrete actions) */
8312
- static SETTLE_MS = 300;
8313
- /** During continuous activity (drag), force a send at least this often */
8360
+ /** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
8361
+ inFlightSentAt = [];
8362
+ maxInFlight = 2;
8363
+ // CanvasExportServiceConfig.maxInFlight
8364
+ subscribePipelineSettled = null;
8365
+ unsubscribePipelineSettled = null;
8366
+ /**
8367
+ * Safety net for the one-in-flight policy: a held state never waits longer
8368
+ * than this for the in-flight frame (a swallowed render error would
8369
+ * otherwise hold the pipeline forever), and a change arriving while a
8370
+ * frame has been in flight this long is sent regardless.
8371
+ */
8314
8372
  static MAX_WAIT_MS = 1e3;
8315
8373
  // Internal state for tracking exports
8316
8374
  lastExportedArtwork = null;
@@ -8352,6 +8410,14 @@ var CanvasExportService = class _CanvasExportService {
8352
8410
  if (config.disableRealtime !== void 0) {
8353
8411
  this.disableRealtime = config.disableRealtime;
8354
8412
  }
8413
+ if (config.maxInFlight !== void 0) {
8414
+ this.maxInFlight = config.maxInFlight;
8415
+ }
8416
+ if (config.subscribePipelineSettled !== void 0 && config.subscribePipelineSettled !== this.subscribePipelineSettled) {
8417
+ this.unsubscribePipelineSettled?.();
8418
+ this.subscribePipelineSettled = config.subscribePipelineSettled;
8419
+ this.unsubscribePipelineSettled = config.subscribePipelineSettled?.(this.onPipelineSettled) ?? null;
8420
+ }
8355
8421
  if (config.clearImageBitmapCache !== void 0) {
8356
8422
  this.clearImageBitmapCache = config.clearImageBitmapCache;
8357
8423
  }
@@ -8555,32 +8621,62 @@ var CanvasExportService = class _CanvasExportService {
8555
8621
  console.log(`[CES] handleStateChange #${callId}: not ready (realtime=${this.isRealtimeEnabled} sendState=${!!this.sendCanvasState} serialize=${!!this.serializeStateForServer}) \u2014 state cached`);
8556
8622
  return;
8557
8623
  }
8558
- const settleMs = _CanvasExportService.SETTLE_MS;
8559
- const maxWaitMs = _CanvasExportService.MAX_WAIT_MS;
8560
- const hasSentBefore = this.stateChangeLastSendTime > 0;
8561
- const timeSinceLastSend = hasSentBefore ? now - this.stateChangeLastSendTime : 0;
8562
- const isActiveDrag = hasSentBefore && timeSinceLastSend < maxWaitMs * 3;
8563
- if (isActiveDrag && timeSinceLastSend >= maxWaitMs) {
8564
- console.log(`[CES] handleStateChange #${callId}: MAX-WAIT FLUSH (${timeSinceLastSend}ms >= ${maxWaitMs}ms)`);
8565
- if (this.stateChangeThrottleTimeout) {
8566
- clearTimeout(this.stateChangeThrottleTimeout);
8567
- this.stateChangeThrottleTimeout = null;
8568
- }
8624
+ this.dropStalledSends(now);
8625
+ const inFlight = this.inFlightSentAt.length;
8626
+ if (inFlight < this.maxInFlight) {
8627
+ console.log(`[CES] handleStateChange #${callId}: SEND now (${inFlight} in flight)`);
8628
+ this.clearFlushTimer();
8569
8629
  this.sendStateNow(state);
8570
8630
  return;
8571
8631
  }
8572
- if (this.stateChangeThrottleTimeout) {
8573
- clearTimeout(this.stateChangeThrottleTimeout);
8632
+ const oldestMs = now - this.inFlightSentAt[0];
8633
+ console.log(`[CES] handleStateChange #${callId}: HELD (${inFlight} in flight, oldest ${oldestMs}ms) \u2014 sends when one lands`);
8634
+ this.armFlushTimer(_CanvasExportService.MAX_WAIT_MS - oldestMs);
8635
+ };
8636
+ /** A send landed (every mockup of it): free its slot, release the held state if any. */
8637
+ onPipelineSettled = () => {
8638
+ this.inFlightSentAt.shift();
8639
+ this.clearFlushTimer();
8640
+ if (this.pendingCanvasState && this.isRealtimeEnabled) {
8641
+ console.log(`[CES] pipeline settled \u2192 sending held state`);
8642
+ this.sendStateNow(this.pendingCanvasState);
8574
8643
  }
8575
- console.log(`[CES] handleStateChange #${callId}: SETTLE scheduled ${settleMs}ms`);
8644
+ };
8645
+ /**
8646
+ * The pointer was released (drag end, slider release, key up). Send the held
8647
+ * state NOW instead of waiting for the in-flight frame: the server coalesces
8648
+ * (latest wins), and the "let go" frame is the one the shopper waits for.
8649
+ * A no-op when nothing is held, so it is safe on every pointerup.
8650
+ */
8651
+ handleInteractionEnd = () => {
8652
+ if (!this.pendingCanvasState || !this.isRealtimeEnabled || !this.sendCanvasState || !this.serializeStateForServer) return;
8653
+ if (this.inFlightSentAt.length < this.maxInFlight) return;
8654
+ console.log(`[CES] interaction end \u2192 flushing held state`);
8655
+ this.clearFlushTimer();
8656
+ this.sendStateNow(this.pendingCanvasState);
8657
+ };
8658
+ armFlushTimer(ms) {
8659
+ this.clearFlushTimer();
8576
8660
  this.stateChangeThrottleTimeout = setTimeout(() => {
8577
8661
  this.stateChangeThrottleTimeout = null;
8578
- console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
8579
8662
  if (this.pendingCanvasState) {
8663
+ console.log(`[CES] flush timer \u2192 sending held state (no frame landed within ${_CanvasExportService.MAX_WAIT_MS}ms)`);
8664
+ this.dropStalledSends(Date.now());
8580
8665
  this.sendStateNow(this.pendingCanvasState);
8581
8666
  }
8582
- }, settleMs);
8583
- };
8667
+ }, Math.max(0, ms));
8668
+ }
8669
+ /** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
8670
+ dropStalledSends(now) {
8671
+ if (this.inFlightSentAt.length === 0) return;
8672
+ this.inFlightSentAt = this.inFlightSentAt.filter((t) => now - t < _CanvasExportService.MAX_WAIT_MS);
8673
+ }
8674
+ clearFlushTimer() {
8675
+ if (this.stateChangeThrottleTimeout) {
8676
+ clearTimeout(this.stateChangeThrottleTimeout);
8677
+ this.stateChangeThrottleTimeout = null;
8678
+ }
8679
+ }
8584
8680
  sendStateNow(state) {
8585
8681
  if (!this.sendCanvasState || !this.serializeStateForServer) {
8586
8682
  console.log(`[CES] sendStateNow SKIPPED: sendCanvasState=${!!this.sendCanvasState} serializeStateForServer=${!!this.serializeStateForServer}`);
@@ -8605,6 +8701,7 @@ var CanvasExportService = class _CanvasExportService {
8605
8701
  if (sent) {
8606
8702
  this.pendingCanvasState = null;
8607
8703
  this.jsonModeSentSuccessfully = true;
8704
+ this.inFlightSentAt.push(Date.now());
8608
8705
  console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
8609
8706
  } else {
8610
8707
  console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) \u2014 state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
@@ -8626,6 +8723,7 @@ var CanvasExportService = class _CanvasExportService {
8626
8723
  this.stateChangeThrottleTimeout = null;
8627
8724
  }
8628
8725
  this.stateChangeLastSendTime = 0;
8726
+ this.inFlightSentAt = [];
8629
8727
  this.jsonModeSentSuccessfully = false;
8630
8728
  this.pendingCanvasState = null;
8631
8729
  if (this.clearImageBitmapCache) {
@@ -8661,7 +8759,10 @@ function CanvasIsolationConfigurator({
8661
8759
  // Wire CES's "I sent nothing" signal to the realtime context's
8662
8760
  // cancellation broadcaster. Roll back any optimistic
8663
8761
  // notifyPendingExport that didn't end up producing a real export.
8664
- notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled
8762
+ notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled,
8763
+ // One-in-flight send policy: the held state goes out when the last
8764
+ // send's frame lands.
8765
+ subscribePipelineSettled: realtimeContext?.subscribePipelineSettled
8665
8766
  });
8666
8767
  }, [
8667
8768
  realtimeContext?.sendCanvasBlob,
@@ -8673,6 +8774,7 @@ function CanvasIsolationConfigurator({
8673
8774
  realtimeContext?.enableRealtime,
8674
8775
  realtimeContext?.disableRealtime,
8675
8776
  realtimeContext?.notifyPendingExportCancelled,
8777
+ realtimeContext?.subscribePipelineSettled,
8676
8778
  placementNameProp,
8677
8779
  onArtworkChange,
8678
8780
  clearImageBitmapCache
@@ -8718,6 +8820,8 @@ function useIsolatedCanvas() {
8718
8820
  handleExport: canvasExportService.handleExport,
8719
8821
  /** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
8720
8822
  handleStateChange: canvasExportService.handleStateChange,
8823
+ /** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
8824
+ handleInteractionEnd: canvasExportService.handleInteractionEnd,
8721
8825
  /** Call when entering editor mode */
8722
8826
  onEnterEditorMode: () => canvasExportService.onEnterEditorMode(),
8723
8827
  /** Call when exiting editor mode */
@@ -10009,6 +10113,7 @@ var HeroProductImage = memo3(function HeroProductImage2({
10009
10113
  const actualMockupId = useMemo14(() => {
10010
10114
  return resolveMockupId(context, actualGvid, mockupId);
10011
10115
  }, [mockupId, actualGvid, context]);
10116
+ const unavailable = actualMockupId ? realtimeContext?.unavailableMockups?.get(actualMockupId) ?? null : null;
10012
10117
  const artworkSrc = artwork?.src;
10013
10118
  const selectedArtworkSrc = designContext?.selectedArtwork?.src;
10014
10119
  const effectiveArtworkSrc = artworkSrc || selectedArtworkSrc;
@@ -10457,7 +10562,17 @@ var HeroProductImage = memo3(function HeroProductImage2({
10457
10562
  className: `relative overflow-hidden ${className || ""}`,
10458
10563
  style,
10459
10564
  "data-hero-image": "true",
10565
+ "data-mockup-unavailable": unavailable ? unavailable.code : void 0,
10460
10566
  children: [
10567
+ unavailable && /* @__PURE__ */ jsx61(
10568
+ "div",
10569
+ {
10570
+ role: "status",
10571
+ title: unavailable.message,
10572
+ className: "absolute inset-0 z-20 flex items-center justify-center bg-white/80 text-sm text-neutral-700",
10573
+ children: "Mockup unavailable"
10574
+ }
10575
+ ),
10461
10576
  renderedUrl && !imgFailed && /* @__PURE__ */ jsx61(
10462
10577
  "img",
10463
10578
  {