@snowcone-app/ui 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5571,6 +5571,67 @@ function useMockupBackgroundOptional() {
5571
5571
  return useContext5(MockupBackgroundContext);
5572
5572
  }
5573
5573
 
5574
+ // src/patterns/croppedFrameComposite.ts
5575
+ var COMPOSITE_QUALITY = 0.92;
5576
+ function isFullRect(rect) {
5577
+ if (!rect) return true;
5578
+ return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
5579
+ }
5580
+ function compositeOffset(rect, bgWidth, bgHeight) {
5581
+ return {
5582
+ dx: Math.round(rect.x * bgWidth),
5583
+ dy: Math.round(rect.y * bgHeight)
5584
+ };
5585
+ }
5586
+ function allBackgroundsSeeded(mockupIds, seeded) {
5587
+ return mockupIds.length > 0 && mockupIds.every((id) => seeded.has(id));
5588
+ }
5589
+ function loadImageElement(url) {
5590
+ return new Promise((resolve, reject) => {
5591
+ const img = new Image();
5592
+ img.crossOrigin = "anonymous";
5593
+ img.onload = () => {
5594
+ if (img.decode) {
5595
+ img.decode().then(
5596
+ () => resolve(img),
5597
+ () => resolve(img)
5598
+ );
5599
+ } else {
5600
+ resolve(img);
5601
+ }
5602
+ };
5603
+ img.onerror = () => reject(
5604
+ new Error(`E_OVERLAY_FRAME_LOAD: image failed to load: ${url.slice(0, 160)}`)
5605
+ );
5606
+ img.src = url;
5607
+ });
5608
+ }
5609
+ async function compositeCroppedFrame(args) {
5610
+ const frame = await loadImageElement(args.frameUrl);
5611
+ const width = args.background.naturalWidth;
5612
+ const height = args.background.naturalHeight;
5613
+ if (!width || !height) {
5614
+ throw new Error("E_OVERLAY_BG_EMPTY: background image has no pixels");
5615
+ }
5616
+ const canvas = args.canvas ?? document.createElement("canvas");
5617
+ canvas.width = width;
5618
+ canvas.height = height;
5619
+ const ctx = canvas.getContext("2d");
5620
+ if (!ctx) {
5621
+ throw new Error("E_OVERLAY_NO_2D_CONTEXT: canvas 2d context unavailable");
5622
+ }
5623
+ ctx.drawImage(args.background, 0, 0);
5624
+ const { dx, dy } = compositeOffset(args.rect, width, height);
5625
+ ctx.drawImage(frame, dx, dy);
5626
+ const blob = await new Promise(
5627
+ (resolve) => canvas.toBlob(resolve, "image/webp", COMPOSITE_QUALITY)
5628
+ );
5629
+ if (!blob) {
5630
+ throw new Error("E_OVERLAY_ENCODE_FAILED: canvas.toBlob returned null");
5631
+ }
5632
+ return blob;
5633
+ }
5634
+
5574
5635
  // src/patterns/RealtimeProvider.tsx
5575
5636
  import { jsx as jsx52 } from "react/jsx-runtime";
5576
5637
  var DEFAULT_ARTBOARD_SIZE = 2e3;
@@ -5586,7 +5647,8 @@ function RealtimeProvider({
5586
5647
  mockupWidth = 1400,
5587
5648
  autoEnable = false,
5588
5649
  shop,
5589
- grantUrl
5650
+ grantUrl,
5651
+ delivery = "cropped"
5590
5652
  }) {
5591
5653
  const productContext = useProductOptional();
5592
5654
  const mockupBg = useMockupBackgroundOptional();
@@ -5611,6 +5673,9 @@ function RealtimeProvider({
5611
5673
  const placementSettingsOverridesRef = useRef12({});
5612
5674
  const lastSentVariantIdRef = useRef12(null);
5613
5675
  const lastSentBgRef = useRef12(void 0);
5676
+ const spreadCropsRef = useRef12(void 0);
5677
+ const [spreadCropsVersion, setSpreadCropsVersion] = useState17(0);
5678
+ const lastSentSpreadCropsRef = useRef12("null");
5614
5679
  const lastSentColorsRef = useRef12({});
5615
5680
  const realtimeMockup = useRealtimeMockup2({
5616
5681
  wsUrl,
@@ -5687,58 +5752,214 @@ function RealtimeProvider({
5687
5752
  const registeredBlobUrlsRef = useRef12(/* @__PURE__ */ new Set());
5688
5753
  const pendingPlacementsRef = useRef12(/* @__PURE__ */ new Set());
5689
5754
  const pendingMockupIdsRef = useRef12(/* @__PURE__ */ new Set());
5690
- useEffect17(() => {
5691
- if (rawMockupResults.length === 0) {
5692
- if (mockupResultsRef.current.length > 0) {
5693
- mockupResultsRef.current = [];
5694
- mockupSubscribersRef.current.forEach((cb) => cb([]));
5755
+ const backgroundsRef = useRef12(/* @__PURE__ */ new Map());
5756
+ const croppedActiveRef = useRef12(false);
5757
+ const cropCapableRef = useRef12(true);
5758
+ const lastConfigBaseRef = useRef12(null);
5759
+ const sceneGenRef = useRef12(0);
5760
+ const processedFrameRef = useRef12(/* @__PURE__ */ new Map());
5761
+ const compositeQueueRef = useRef12(/* @__PURE__ */ new Map());
5762
+ const compositeBusyRef = useRef12(/* @__PURE__ */ new Set());
5763
+ const compositeCanvasRef = useRef12(null);
5764
+ const registerBlobUrl = useCallback10((blobOrUrl, placementName) => {
5765
+ let url;
5766
+ if (blobOrUrl instanceof Blob) {
5767
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
5768
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
5769
+ if (oldest) {
5770
+ URL.revokeObjectURL(oldest);
5771
+ registeredBlobUrlsRef.current.delete(oldest);
5772
+ mockupUrlCacheRef.current.forEach((value, key) => {
5773
+ if (value.url === oldest) {
5774
+ mockupUrlCacheRef.current.delete(key);
5775
+ }
5776
+ });
5777
+ mockupResultsRef.current = mockupResultsRef.current.filter(
5778
+ (r) => r.imageUrl !== oldest
5779
+ );
5780
+ }
5695
5781
  }
5696
- return;
5782
+ url = URL.createObjectURL(blobOrUrl);
5783
+ registeredBlobUrlsRef.current.add(url);
5784
+ } else {
5785
+ url = blobOrUrl;
5697
5786
  }
5698
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}`));
5699
- mockupResultsRef.current = rawMockupResults;
5700
- const now = Date.now();
5701
- rawMockupResults.forEach((result) => {
5702
- if (result.imageUrl) {
5787
+ return url;
5788
+ }, []);
5789
+ const croppedDeliveryEnabled = delivery !== "full-frame";
5790
+ const sendCropConfig = useCallback10(
5791
+ (withCrop) => {
5792
+ const base = lastConfigBaseRef.current;
5793
+ if (!base) return;
5794
+ croppedActiveRef.current = withCrop;
5795
+ sendConfigRaw({
5796
+ ...base,
5797
+ placementSettings: { ...placementSettingsOverridesRef.current },
5798
+ ...withCrop ? { overlayCrop: "auto" } : {}
5799
+ });
5800
+ },
5801
+ [sendConfigRaw]
5802
+ );
5803
+ const unflipCrop = useCallback10(() => {
5804
+ if (!croppedActiveRef.current) return;
5805
+ sendCropConfig(false);
5806
+ }, [sendCropConfig]);
5807
+ const maybeFlipCrop = useCallback10(() => {
5808
+ if (!croppedDeliveryEnabled || !cropCapableRef.current) return;
5809
+ if (croppedActiveRef.current) return;
5810
+ const base = lastConfigBaseRef.current;
5811
+ if (!base) return;
5812
+ const ids = base.mockupIds ?? [];
5813
+ const seeded = new Set(backgroundsRef.current.keys());
5814
+ if (allBackgroundsSeeded(ids, seeded)) {
5815
+ console.log(`[RTP] Cropped delivery ON \u2014 all ${ids.length} mockup background(s) seeded`);
5816
+ sendCropConfig(true);
5817
+ } else {
5818
+ const missing = ids.filter((id) => !seeded.has(id));
5819
+ console.log(`[RTP] Cropped delivery waiting \u2014 ${missing.length}/${ids.length} mockup(s) unseeded: [${missing.slice(0, 8).join(", ")}]`);
5820
+ }
5821
+ }, [croppedDeliveryEnabled, sendCropConfig]);
5822
+ const invalidateBackgrounds = useCallback10(() => {
5823
+ sceneGenRef.current += 1;
5824
+ backgroundsRef.current.clear();
5825
+ processedFrameRef.current.clear();
5826
+ compositeQueueRef.current.clear();
5827
+ croppedActiveRef.current = false;
5828
+ }, []);
5829
+ const deliverResults = useCallback10(
5830
+ (results) => {
5831
+ if (results.length === 0) return;
5832
+ const now = Date.now();
5833
+ const merged = [...mockupResultsRef.current];
5834
+ results.forEach((result) => {
5835
+ const idx = merged.findIndex((r) => r.mockupId === result.mockupId);
5836
+ if (idx >= 0) merged[idx] = result;
5837
+ else merged.push(result);
5838
+ });
5839
+ mockupResultsRef.current = merged;
5840
+ const clearedPlacements = /* @__PURE__ */ new Set();
5841
+ results.forEach((result) => {
5703
5842
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
5704
5843
  mockupUrlCacheRef.current.set(result.mockupId, {
5705
5844
  url: result.imageUrl,
5706
- isBlob: false,
5707
- // Server URLs are not blob URLs
5845
+ isBlob: result.imageUrl.startsWith("blob:"),
5708
5846
  timestamp: now
5709
5847
  });
5710
5848
  pendingMockupIdsRef.current.delete(result.mockupId);
5711
5849
  if (wasPending) {
5712
5850
  console.log(`[RTP] Cleared pending for ${result.mockupId} \u2014 url cached`);
5713
5851
  }
5852
+ if (result.placement) clearedPlacements.add(result.placement);
5853
+ });
5854
+ if (clearedPlacements.size > 0) {
5855
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
5714
5856
  } else {
5715
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
5857
+ pendingPlacementsRef.current.clear();
5716
5858
  }
5717
- });
5718
- const clearedPlacements = /* @__PURE__ */ new Set();
5719
- rawMockupResults.forEach((result) => {
5720
- if (result.placement) clearedPlacements.add(result.placement);
5721
- });
5722
- if (clearedPlacements.size > 0) {
5723
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
5724
- } else {
5725
- pendingPlacementsRef.current.clear();
5859
+ mockupSubscribersRef.current.forEach((cb) => cb(results));
5860
+ results.forEach((result) => {
5861
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
5862
+ if (subscribers && subscribers.size > 0) {
5863
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
5864
+ subscribers.forEach((cb) => cb(result));
5865
+ } else {
5866
+ console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
5867
+ }
5868
+ });
5869
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
5870
+ hasBlobInFlightRef.current = false;
5871
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
5872
+ }
5873
+ },
5874
+ [hasPendingBlobs]
5875
+ );
5876
+ const runComposite = useCallback10(
5877
+ async (result) => {
5878
+ const gen = sceneGenRef.current;
5879
+ const bg = backgroundsRef.current.get(result.mockupId);
5880
+ if (!bg) {
5881
+ console.error(
5882
+ `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background \u2014 falling back to full-frame and re-rendering it.`
5883
+ );
5884
+ unflipCrop();
5885
+ updateMockupIdsRaw([result.mockupId]);
5886
+ return;
5887
+ }
5888
+ try {
5889
+ if (!bg.imgPromise) bg.imgPromise = loadImageElement(bg.url);
5890
+ const background = await bg.imgPromise;
5891
+ if (!compositeCanvasRef.current && typeof document !== "undefined") {
5892
+ compositeCanvasRef.current = document.createElement("canvas");
5893
+ }
5894
+ const blob = await compositeCroppedFrame({
5895
+ background,
5896
+ frameUrl: result.imageUrl,
5897
+ rect: result.cropRect,
5898
+ canvas: compositeCanvasRef.current ?? void 0
5899
+ });
5900
+ if (gen !== sceneGenRef.current) return;
5901
+ deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
5902
+ } catch (err) {
5903
+ console.error(
5904
+ "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed \u2014 full-frame delivery for the rest of this session.",
5905
+ err
5906
+ );
5907
+ cropCapableRef.current = false;
5908
+ unflipCrop();
5909
+ updateMockupIdsRaw([result.mockupId]);
5910
+ }
5911
+ },
5912
+ [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
5913
+ );
5914
+ const pumpCompositeQueue = useCallback10(
5915
+ (mockupId) => {
5916
+ if (compositeBusyRef.current.has(mockupId)) return;
5917
+ const job = compositeQueueRef.current.get(mockupId);
5918
+ if (!job) return;
5919
+ compositeQueueRef.current.delete(mockupId);
5920
+ compositeBusyRef.current.add(mockupId);
5921
+ void runComposite(job).finally(() => {
5922
+ compositeBusyRef.current.delete(mockupId);
5923
+ pumpCompositeQueue(mockupId);
5924
+ });
5925
+ },
5926
+ [runComposite]
5927
+ );
5928
+ useEffect17(() => {
5929
+ if (rawMockupResults.length === 0) {
5930
+ if (mockupResultsRef.current.length > 0) {
5931
+ mockupResultsRef.current = [];
5932
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
5933
+ }
5934
+ return;
5726
5935
  }
5727
- mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
5936
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}${r.cropRect ? " [crop]" : ""}`));
5937
+ const displayable = [];
5728
5938
  rawMockupResults.forEach((result) => {
5729
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
5730
- if (subscribers && subscribers.size > 0) {
5731
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
5732
- subscribers.forEach((cb) => cb(result));
5733
- } else {
5734
- console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
5939
+ if (!result.imageUrl) {
5940
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
5941
+ return;
5942
+ }
5943
+ const firstSight = processedFrameRef.current.get(result.mockupId) !== result.imageUrl;
5944
+ const sessionLive = configSentRef.current;
5945
+ if (isFullRect(result.cropRect)) {
5946
+ if (firstSight && sessionLive) {
5947
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
5948
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl });
5949
+ }
5950
+ displayable.push(result);
5951
+ } else if (firstSight && sessionLive) {
5952
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
5953
+ compositeQueueRef.current.set(result.mockupId, result);
5954
+ pumpCompositeQueue(result.mockupId);
5955
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
5956
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
5957
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
5735
5958
  }
5736
5959
  });
5737
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
5738
- hasBlobInFlightRef.current = false;
5739
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
5740
- }
5741
- }, [rawMockupResults]);
5960
+ deliverResults(displayable);
5961
+ maybeFlipCrop();
5962
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
5742
5963
  const sendCanvasBlob = useCallback10(
5743
5964
  (placement, blob, mockupCount, baseThrottleMs) => {
5744
5965
  blobQueuedGenRef.current = pipelineGenRef.current;
@@ -5797,10 +6018,13 @@ function RealtimeProvider({
5797
6018
  configSentRef.current = false;
5798
6019
  lastSentVariantIdRef.current = null;
5799
6020
  lastSentBgRef.current = void 0;
6021
+ lastSentSpreadCropsRef.current = "null";
5800
6022
  lastSentColorsRef.current = {};
6023
+ invalidateBackgrounds();
6024
+ lastConfigBaseRef.current = null;
5801
6025
  };
5802
6026
  }
5803
- }, [isEnabled, connect, disconnect, wsUrl]);
6027
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
5804
6028
  useEffect17(() => {
5805
6029
  if (rawMockupResults.length > 0 && isPendingMockupsRef.current) {
5806
6030
  isPendingMockupsRef.current = false;
@@ -5819,9 +6043,14 @@ function RealtimeProvider({
5819
6043
  const isFirstSend = !configSentRef.current;
5820
6044
  const variantChanged = lastSentVariantIdRef.current !== null && lastSentVariantIdRef.current !== variantId;
5821
6045
  const bgChanged = configSentRef.current && lastSentBgRef.current !== mockupBg;
5822
- if (!isFirstSend && !variantChanged && !bgChanged) {
6046
+ const spreadCropsJson = JSON.stringify(spreadCropsRef.current ?? null);
6047
+ const spreadCropsChanged = configSentRef.current && lastSentSpreadCropsRef.current !== spreadCropsJson;
6048
+ if (!isFirstSend && !variantChanged && !bgChanged && !spreadCropsChanged) {
5823
6049
  return;
5824
6050
  }
6051
+ if (variantChanged || bgChanged) {
6052
+ invalidateBackgrounds();
6053
+ }
5825
6054
  const mockupIds = [];
5826
6055
  if (product.mockups && Array.isArray(product.mockups)) {
5827
6056
  product.mockups.forEach((mockup) => {
@@ -5837,6 +6066,7 @@ function RealtimeProvider({
5837
6066
  shop,
5838
6067
  width: mockupWidth,
5839
6068
  ...mockupBg ? { bg: mockupBg } : {},
6069
+ ...spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {},
5840
6070
  placementSettings: { ...placementSettingsOverridesRef.current }
5841
6071
  } : {
5842
6072
  productId,
@@ -5846,6 +6076,7 @@ function RealtimeProvider({
5846
6076
  seal: "bypass-sig-for-k6-load-test",
5847
6077
  width: mockupWidth,
5848
6078
  ...mockupBg ? { bg: mockupBg } : {},
6079
+ ...spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {},
5849
6080
  placementSettings: { ...placementSettingsOverridesRef.current }
5850
6081
  };
5851
6082
  if (variantChanged) {
@@ -5854,10 +6085,14 @@ function RealtimeProvider({
5854
6085
  pendingBlobSubscribersRef.current.forEach((cb) => cb(p.label));
5855
6086
  });
5856
6087
  }
5857
- sendConfigRaw(configPayload);
6088
+ lastConfigBaseRef.current = configPayload;
6089
+ sendConfigRaw(
6090
+ croppedActiveRef.current ? { ...configPayload, overlayCrop: "auto" } : configPayload
6091
+ );
5858
6092
  configSentRef.current = true;
5859
6093
  lastSentVariantIdRef.current = variantId;
5860
6094
  lastSentBgRef.current = mockupBg;
6095
+ lastSentSpreadCropsRef.current = spreadCropsJson;
5861
6096
  setTimeout(() => {
5862
6097
  const colorPlacements = placements.filter((p) => p.type === "color");
5863
6098
  colorPlacements.forEach((p) => {
@@ -5875,8 +6110,10 @@ function RealtimeProvider({
5875
6110
  productContext?.selection,
5876
6111
  sendConfigRaw,
5877
6112
  sendColorBlobTracked,
6113
+ invalidateBackgrounds,
5878
6114
  mockupWidth,
5879
- mockupBg
6115
+ mockupBg,
6116
+ spreadCropsVersion
5880
6117
  ]);
5881
6118
  useEffect17(() => {
5882
6119
  if (!isEnabled || !isConnected || !isConfigured || !productContext?.product)
@@ -5903,6 +6140,15 @@ function RealtimeProvider({
5903
6140
  productContext?.selection,
5904
6141
  sendColorBlobTracked
5905
6142
  ]);
6143
+ const updateMockupIdsGuarded = useCallback10(
6144
+ (mockupIds) => {
6145
+ if (croppedActiveRef.current && mockupIds.some((id) => !backgroundsRef.current.has(id))) {
6146
+ unflipCrop();
6147
+ }
6148
+ return updateMockupIdsRaw(mockupIds);
6149
+ },
6150
+ [updateMockupIdsRaw, unflipCrop]
6151
+ );
5906
6152
  const updatePlacementSettingsWrapped = useCallback10(
5907
6153
  (settings) => {
5908
6154
  placementSettingsOverridesRef.current = settings;
@@ -5910,6 +6156,17 @@ function RealtimeProvider({
5910
6156
  },
5911
6157
  [updatePlacementSettingsRaw]
5912
6158
  );
6159
+ const setSpreadCrops = useCallback10(
6160
+ (crops) => {
6161
+ const next = crops && Object.keys(crops).length > 0 ? crops : void 0;
6162
+ if (JSON.stringify(next ?? null) === JSON.stringify(spreadCropsRef.current ?? null)) {
6163
+ return;
6164
+ }
6165
+ spreadCropsRef.current = next;
6166
+ setSpreadCropsVersion((v) => v + 1);
6167
+ },
6168
+ []
6169
+ );
5913
6170
  const enableRealtime = useCallback10(() => {
5914
6171
  setIsEnabled(true);
5915
6172
  }, []);
@@ -6091,28 +6348,6 @@ function RealtimeProvider({
6091
6348
  const isMockupLoading = useCallback10((mockupId) => {
6092
6349
  return pendingMockupIdsRef.current.has(mockupId);
6093
6350
  }, []);
6094
- const registerBlobUrl = useCallback10((blobOrUrl, placementName) => {
6095
- let url;
6096
- if (blobOrUrl instanceof Blob) {
6097
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6098
- const oldest = registeredBlobUrlsRef.current.values().next().value;
6099
- if (oldest) {
6100
- URL.revokeObjectURL(oldest);
6101
- registeredBlobUrlsRef.current.delete(oldest);
6102
- mockupUrlCacheRef.current.forEach((value, key) => {
6103
- if (value.url === oldest) {
6104
- mockupUrlCacheRef.current.delete(key);
6105
- }
6106
- });
6107
- }
6108
- }
6109
- url = URL.createObjectURL(blobOrUrl);
6110
- registeredBlobUrlsRef.current.add(url);
6111
- } else {
6112
- url = blobOrUrl;
6113
- }
6114
- return url;
6115
- }, []);
6116
6351
  const subscribeMockupResultsRef = useRef12(subscribeMockupResults);
6117
6352
  const subscribeMockupResultByIdRef = useRef12(subscribeMockupResultById);
6118
6353
  const subscribePendingBlobRef = useRef12(subscribePendingBlob);
@@ -6193,8 +6428,9 @@ function RealtimeProvider({
6193
6428
  disableRealtime,
6194
6429
  sendCanvasBlob,
6195
6430
  sendCanvasState,
6196
- updateMockupIds: updateMockupIdsRaw,
6431
+ updateMockupIds: updateMockupIdsGuarded,
6197
6432
  updatePlacementSettings: updatePlacementSettingsWrapped,
6433
+ setSpreadCrops,
6198
6434
  getMockupResultsImmediate: () => mockupResultsRef.current,
6199
6435
  subscribeMockupResults: stableSubscribeMockupResults,
6200
6436
  subscribeMockupResultById: stableSubscribeMockupResultById,
@@ -6229,8 +6465,9 @@ function RealtimeProvider({
6229
6465
  disableRealtime,
6230
6466
  sendCanvasBlob,
6231
6467
  sendCanvasState,
6232
- updateMockupIdsRaw,
6468
+ updateMockupIdsGuarded,
6233
6469
  updatePlacementSettingsWrapped,
6470
+ setSpreadCrops,
6234
6471
  stableSubscribeMockupResults,
6235
6472
  stableSubscribeMockupResultById,
6236
6473
  notifyPendingExport,
@@ -9745,7 +9982,10 @@ var HeroProductImage = memo3(function HeroProductImage2({
9745
9982
  // Quick crossfade
9746
9983
  shimmerDelay = 200,
9747
9984
  shimmerFadeDuration = 500,
9748
- darkShimmerFadeInDuration = 300
9985
+ darkShimmerFadeInDuration = 300,
9986
+ emptyText = "No artwork",
9987
+ errorText = "Error loading",
9988
+ unavailableText = "Preview unavailable"
9749
9989
  }) {
9750
9990
  const context = useProductOptional();
9751
9991
  const designContext = useDesignOptional();
@@ -10190,7 +10430,7 @@ var HeroProductImage = memo3(function HeroProductImage2({
10190
10430
  )
10191
10431
  }
10192
10432
  ),
10193
- /* @__PURE__ */ jsx61("p", { className: "text-xs", children: "No artwork" })
10433
+ /* @__PURE__ */ jsx61("p", { className: "text-xs", children: emptyText })
10194
10434
  ] })
10195
10435
  }
10196
10436
  );
@@ -10201,7 +10441,7 @@ var HeroProductImage = memo3(function HeroProductImage2({
10201
10441
  {
10202
10442
  className: `bg-red-50 flex items-center justify-center ${className || ""}`,
10203
10443
  style,
10204
- children: /* @__PURE__ */ jsx61("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ jsx61("p", { className: "text-xs", children: "Error loading" }) })
10444
+ children: /* @__PURE__ */ jsx61("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ jsx61("p", { className: "text-xs", children: errorText }) })
10205
10445
  }
10206
10446
  );
10207
10447
  }
@@ -10220,7 +10460,7 @@ var HeroProductImage = memo3(function HeroProductImage2({
10220
10460
  crossOrigin: "anonymous",
10221
10461
  className: "absolute inset-0 w-full h-full object-cover",
10222
10462
  draggable,
10223
- src: retriedUrlRef.current === renderedUrl ? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}` : renderedUrl,
10463
+ src: renderedUrl,
10224
10464
  loading: "eager",
10225
10465
  fetchPriority: "high",
10226
10466
  onClick,
@@ -10236,7 +10476,8 @@ var HeroProductImage = memo3(function HeroProductImage2({
10236
10476
  onError: () => {
10237
10477
  if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
10238
10478
  retriedUrlRef.current = renderedUrl;
10239
- setRetryNonce((n) => n + 1);
10479
+ void fetch(renderedUrl, { mode: "cors", cache: "reload", credentials: "omit" }).catch(() => {
10480
+ }).then(() => setRetryNonce((n) => n + 1));
10240
10481
  return;
10241
10482
  }
10242
10483
  setImgFailed(true);
@@ -10250,9 +10491,10 @@ var HeroProductImage = memo3(function HeroProductImage2({
10250
10491
  );
10251
10492
  }
10252
10493
  }
10253
- }
10494
+ },
10495
+ retryNonce
10254
10496
  ),
10255
- imgFailed && !prevUrl && /* @__PURE__ */ jsx61("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ jsx61("p", { className: "text-muted-foreground text-xs", children: "Preview unavailable" }) }),
10497
+ imgFailed && !prevUrl && /* @__PURE__ */ jsx61("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ jsx61("p", { className: "text-muted-foreground text-xs", children: unavailableText }) }),
10256
10498
  realtimeContext?.renderError && /* @__PURE__ */ jsx61(
10257
10499
  "div",
10258
10500
  {
@@ -11761,7 +12003,10 @@ var MobileProductCarousel = memo4(function MobileProductCarousel2({
11761
12003
  onZoomChange,
11762
12004
  stickyHeroContext,
11763
12005
  mockupWidth,
11764
- onMockupUrlGenerated
12006
+ onMockupUrlGenerated,
12007
+ renderImageAction,
12008
+ errorText,
12009
+ unavailableText
11765
12010
  }) {
11766
12011
  const [activeIndex, setActiveIndex] = useState40(currentIndex);
11767
12012
  const [touchStart, setTouchStart] = useState40(
@@ -12295,34 +12540,52 @@ var MobileProductCarousel = memo4(function MobileProductCarousel2({
12295
12540
  children: images.map((image, index) => {
12296
12541
  const DECODE_WINDOW = 1;
12297
12542
  const isWithinDecodeWindow = Math.abs(index - activeIndex) <= DECODE_WINDOW;
12298
- return /* @__PURE__ */ jsx74(
12543
+ return /* @__PURE__ */ jsxs50(
12299
12544
  "div",
12300
12545
  {
12301
12546
  className: "w-full h-full flex-shrink-0 relative select-none",
12302
- children: !isWithinDecodeWindow ? /* @__PURE__ */ jsx74("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12303
- // Rainbow prism loader (same as the /create route) instead of
12304
- // a flat pulse; fills this relative slide via inset:0.
12305
- /* @__PURE__ */ jsx74(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12306
- ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ jsx74(
12307
- HeroProductImage,
12308
- {
12309
- productId,
12310
- artwork: currentArtwork,
12311
- placement: image.placement,
12312
- mockupId: image.mockupId,
12313
- width: mockupWidth,
12314
- realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12315
- onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12316
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12317
- }
12318
- ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ jsx74(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ jsx74(
12319
- StaticCarouselImage,
12320
- {
12321
- src: image.src,
12322
- alt: image.label ?? "",
12323
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12324
- }
12325
- )
12547
+ children: [
12548
+ !isWithinDecodeWindow ? /* @__PURE__ */ jsx74("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12549
+ // Rainbow prism loader (same as the /create route) instead of
12550
+ // a flat pulse; fills this relative slide via inset:0.
12551
+ /* @__PURE__ */ jsx74(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12552
+ ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ jsx74(
12553
+ HeroProductImage,
12554
+ {
12555
+ productId,
12556
+ artwork: currentArtwork,
12557
+ placement: image.placement,
12558
+ mockupId: image.mockupId,
12559
+ width: mockupWidth,
12560
+ realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12561
+ onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12562
+ errorText,
12563
+ unavailableText,
12564
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12565
+ }
12566
+ ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ jsx74(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ jsx74(
12567
+ StaticCarouselImage,
12568
+ {
12569
+ src: image.src,
12570
+ alt: image.label ?? "",
12571
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12572
+ }
12573
+ ),
12574
+ isWithinDecodeWindow && (() => {
12575
+ const action = renderImageAction?.(image, index);
12576
+ return action ? /* @__PURE__ */ jsx74(
12577
+ "div",
12578
+ {
12579
+ className: "absolute top-3 right-3 z-10",
12580
+ onClick: (e) => e.stopPropagation(),
12581
+ onTouchStart: (e) => e.stopPropagation(),
12582
+ onTouchEnd: (e) => e.stopPropagation(),
12583
+ onMouseDown: (e) => e.stopPropagation(),
12584
+ children: action
12585
+ }
12586
+ ) : null;
12587
+ })()
12588
+ ]
12326
12589
  },
12327
12590
  index
12328
12591
  );