@snowcone-app/ui 0.4.9 → 0.4.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.11
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1632](https://github.com/snowcone-app/snowcone-monorepo/pull/1632) [`8a3b7e2`](https://github.com/snowcone-app/snowcone-monorepo/commit/8a3b7e26867257a22073dc5a55fb19d94ffded3f) Thanks [@kevinsproles](https://github.com/kevinsproles)! - RealtimeProvider: decode the frozen cropped-delivery background at seed time. With ws binary frame delivery the full frame's `imageUrl` is a live `blob:` URL the SDK revokes when the next frame for that mockup arrives; lazily loading it at composite time failed (`E_OVERLAY_FRAME_LOAD`), disabled cropped delivery for the session, and cost three extra full renders per edit.
8
+
9
+ ## 0.4.10
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [[`e57a5bc`](https://github.com/snowcone-app/snowcone-monorepo/commit/e57a5bcb3b62ac612e43c47ab2709992ef6db385)]:
14
+ - @snowcone-app/sdk@0.20.3
15
+
3
16
  ## 0.4.9
4
17
 
5
18
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -5929,6 +5929,67 @@ function useMockupBackgroundOptional() {
5929
5929
  return (0, import_react24.useContext)(MockupBackgroundContext);
5930
5930
  }
5931
5931
 
5932
+ // src/patterns/croppedFrameComposite.ts
5933
+ var COMPOSITE_QUALITY = 0.92;
5934
+ function isFullRect(rect) {
5935
+ if (!rect) return true;
5936
+ return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
5937
+ }
5938
+ function compositeOffset(rect, bgWidth, bgHeight) {
5939
+ return {
5940
+ dx: Math.round(rect.x * bgWidth),
5941
+ dy: Math.round(rect.y * bgHeight)
5942
+ };
5943
+ }
5944
+ function allBackgroundsSeeded(mockupIds, seeded) {
5945
+ return mockupIds.length > 0 && mockupIds.every((id) => seeded.has(id));
5946
+ }
5947
+ function loadImageElement(url) {
5948
+ return new Promise((resolve, reject) => {
5949
+ const img = new Image();
5950
+ img.crossOrigin = "anonymous";
5951
+ img.onload = () => {
5952
+ if (img.decode) {
5953
+ img.decode().then(
5954
+ () => resolve(img),
5955
+ () => resolve(img)
5956
+ );
5957
+ } else {
5958
+ resolve(img);
5959
+ }
5960
+ };
5961
+ img.onerror = () => reject(
5962
+ new Error(`E_OVERLAY_FRAME_LOAD: image failed to load: ${url.slice(0, 160)}`)
5963
+ );
5964
+ img.src = url;
5965
+ });
5966
+ }
5967
+ async function compositeCroppedFrame(args) {
5968
+ const frame = await loadImageElement(args.frameUrl);
5969
+ const width = args.background.naturalWidth;
5970
+ const height = args.background.naturalHeight;
5971
+ if (!width || !height) {
5972
+ throw new Error("E_OVERLAY_BG_EMPTY: background image has no pixels");
5973
+ }
5974
+ const canvas = args.canvas ?? document.createElement("canvas");
5975
+ canvas.width = width;
5976
+ canvas.height = height;
5977
+ const ctx = canvas.getContext("2d");
5978
+ if (!ctx) {
5979
+ throw new Error("E_OVERLAY_NO_2D_CONTEXT: canvas 2d context unavailable");
5980
+ }
5981
+ ctx.drawImage(args.background, 0, 0);
5982
+ const { dx, dy } = compositeOffset(args.rect, width, height);
5983
+ ctx.drawImage(frame, dx, dy);
5984
+ const blob = await new Promise(
5985
+ (resolve) => canvas.toBlob(resolve, "image/webp", COMPOSITE_QUALITY)
5986
+ );
5987
+ if (!blob) {
5988
+ throw new Error("E_OVERLAY_ENCODE_FAILED: canvas.toBlob returned null");
5989
+ }
5990
+ return blob;
5991
+ }
5992
+
5932
5993
  // src/patterns/RealtimeProvider.tsx
5933
5994
  var import_jsx_runtime52 = require("react/jsx-runtime");
5934
5995
  var DEFAULT_ARTBOARD_SIZE = 2e3;
@@ -5944,7 +6005,8 @@ function RealtimeProvider({
5944
6005
  mockupWidth = 1400,
5945
6006
  autoEnable = false,
5946
6007
  shop,
5947
- grantUrl
6008
+ grantUrl,
6009
+ delivery = "cropped"
5948
6010
  }) {
5949
6011
  const productContext = useProductOptional();
5950
6012
  const mockupBg = useMockupBackgroundOptional();
@@ -6048,58 +6110,217 @@ function RealtimeProvider({
6048
6110
  const registeredBlobUrlsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6049
6111
  const pendingPlacementsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6050
6112
  const pendingMockupIdsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6051
- (0, import_react25.useEffect)(() => {
6052
- if (rawMockupResults.length === 0) {
6053
- if (mockupResultsRef.current.length > 0) {
6054
- mockupResultsRef.current = [];
6055
- mockupSubscribersRef.current.forEach((cb) => cb([]));
6113
+ const backgroundsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6114
+ const croppedActiveRef = (0, import_react25.useRef)(false);
6115
+ const cropCapableRef = (0, import_react25.useRef)(true);
6116
+ const lastConfigBaseRef = (0, import_react25.useRef)(null);
6117
+ const sceneGenRef = (0, import_react25.useRef)(0);
6118
+ const processedFrameRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6119
+ const compositeQueueRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6120
+ const compositeBusyRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6121
+ const compositeCanvasRef = (0, import_react25.useRef)(null);
6122
+ const registerBlobUrl = (0, import_react25.useCallback)((blobOrUrl, placementName) => {
6123
+ let url;
6124
+ if (blobOrUrl instanceof Blob) {
6125
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6126
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
6127
+ if (oldest) {
6128
+ URL.revokeObjectURL(oldest);
6129
+ registeredBlobUrlsRef.current.delete(oldest);
6130
+ mockupUrlCacheRef.current.forEach((value, key) => {
6131
+ if (value.url === oldest) {
6132
+ mockupUrlCacheRef.current.delete(key);
6133
+ }
6134
+ });
6135
+ mockupResultsRef.current = mockupResultsRef.current.filter(
6136
+ (r) => r.imageUrl !== oldest
6137
+ );
6138
+ }
6056
6139
  }
6057
- return;
6140
+ url = URL.createObjectURL(blobOrUrl);
6141
+ registeredBlobUrlsRef.current.add(url);
6142
+ } else {
6143
+ url = blobOrUrl;
6058
6144
  }
6059
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}`));
6060
- mockupResultsRef.current = rawMockupResults;
6061
- const now = Date.now();
6062
- rawMockupResults.forEach((result) => {
6063
- if (result.imageUrl) {
6145
+ return url;
6146
+ }, []);
6147
+ const croppedDeliveryEnabled = delivery !== "full-frame";
6148
+ const sendCropConfig = (0, import_react25.useCallback)(
6149
+ (withCrop) => {
6150
+ const base = lastConfigBaseRef.current;
6151
+ if (!base) return;
6152
+ croppedActiveRef.current = withCrop;
6153
+ sendConfigRaw({
6154
+ ...base,
6155
+ placementSettings: { ...placementSettingsOverridesRef.current },
6156
+ ...withCrop ? { overlayCrop: "auto" } : {}
6157
+ });
6158
+ },
6159
+ [sendConfigRaw]
6160
+ );
6161
+ const unflipCrop = (0, import_react25.useCallback)(() => {
6162
+ if (!croppedActiveRef.current) return;
6163
+ sendCropConfig(false);
6164
+ }, [sendCropConfig]);
6165
+ const maybeFlipCrop = (0, import_react25.useCallback)(() => {
6166
+ if (!croppedDeliveryEnabled || !cropCapableRef.current) return;
6167
+ if (croppedActiveRef.current) return;
6168
+ const base = lastConfigBaseRef.current;
6169
+ if (!base) return;
6170
+ const ids = base.mockupIds ?? [];
6171
+ const seeded = new Set(backgroundsRef.current.keys());
6172
+ if (allBackgroundsSeeded(ids, seeded)) {
6173
+ console.log(`[RTP] Cropped delivery ON \u2014 all ${ids.length} mockup background(s) seeded`);
6174
+ sendCropConfig(true);
6175
+ } else {
6176
+ const missing = ids.filter((id) => !seeded.has(id));
6177
+ console.log(`[RTP] Cropped delivery waiting \u2014 ${missing.length}/${ids.length} mockup(s) unseeded: [${missing.slice(0, 8).join(", ")}]`);
6178
+ }
6179
+ }, [croppedDeliveryEnabled, sendCropConfig]);
6180
+ const invalidateBackgrounds = (0, import_react25.useCallback)(() => {
6181
+ sceneGenRef.current += 1;
6182
+ backgroundsRef.current.clear();
6183
+ processedFrameRef.current.clear();
6184
+ compositeQueueRef.current.clear();
6185
+ croppedActiveRef.current = false;
6186
+ }, []);
6187
+ const deliverResults = (0, import_react25.useCallback)(
6188
+ (results) => {
6189
+ if (results.length === 0) return;
6190
+ const now = Date.now();
6191
+ const merged = [...mockupResultsRef.current];
6192
+ results.forEach((result) => {
6193
+ const idx = merged.findIndex((r) => r.mockupId === result.mockupId);
6194
+ if (idx >= 0) merged[idx] = result;
6195
+ else merged.push(result);
6196
+ });
6197
+ mockupResultsRef.current = merged;
6198
+ const clearedPlacements = /* @__PURE__ */ new Set();
6199
+ results.forEach((result) => {
6064
6200
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
6065
6201
  mockupUrlCacheRef.current.set(result.mockupId, {
6066
6202
  url: result.imageUrl,
6067
- isBlob: false,
6068
- // Server URLs are not blob URLs
6203
+ isBlob: result.imageUrl.startsWith("blob:"),
6069
6204
  timestamp: now
6070
6205
  });
6071
6206
  pendingMockupIdsRef.current.delete(result.mockupId);
6072
6207
  if (wasPending) {
6073
6208
  console.log(`[RTP] Cleared pending for ${result.mockupId} \u2014 url cached`);
6074
6209
  }
6210
+ if (result.placement) clearedPlacements.add(result.placement);
6211
+ });
6212
+ if (clearedPlacements.size > 0) {
6213
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6075
6214
  } else {
6076
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
6215
+ pendingPlacementsRef.current.clear();
6077
6216
  }
6078
- });
6079
- const clearedPlacements = /* @__PURE__ */ new Set();
6080
- rawMockupResults.forEach((result) => {
6081
- if (result.placement) clearedPlacements.add(result.placement);
6082
- });
6083
- if (clearedPlacements.size > 0) {
6084
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6085
- } else {
6086
- pendingPlacementsRef.current.clear();
6217
+ mockupSubscribersRef.current.forEach((cb) => cb(results));
6218
+ results.forEach((result) => {
6219
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
6220
+ if (subscribers && subscribers.size > 0) {
6221
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
6222
+ subscribers.forEach((cb) => cb(result));
6223
+ } else {
6224
+ console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
6225
+ }
6226
+ });
6227
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
6228
+ hasBlobInFlightRef.current = false;
6229
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
6230
+ }
6231
+ },
6232
+ [hasPendingBlobs]
6233
+ );
6234
+ const runComposite = (0, import_react25.useCallback)(
6235
+ async (result) => {
6236
+ const gen = sceneGenRef.current;
6237
+ const bg = backgroundsRef.current.get(result.mockupId);
6238
+ if (!bg) {
6239
+ console.error(
6240
+ `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background \u2014 falling back to full-frame and re-rendering it.`
6241
+ );
6242
+ unflipCrop();
6243
+ updateMockupIdsRaw([result.mockupId]);
6244
+ return;
6245
+ }
6246
+ try {
6247
+ if (!bg.imgPromise) bg.imgPromise = loadImageElement(bg.url);
6248
+ const background = await bg.imgPromise;
6249
+ if (!compositeCanvasRef.current && typeof document !== "undefined") {
6250
+ compositeCanvasRef.current = document.createElement("canvas");
6251
+ }
6252
+ const blob = await compositeCroppedFrame({
6253
+ background,
6254
+ frameUrl: result.imageUrl,
6255
+ rect: result.cropRect,
6256
+ canvas: compositeCanvasRef.current ?? void 0
6257
+ });
6258
+ if (gen !== sceneGenRef.current) return;
6259
+ deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
6260
+ } catch (err) {
6261
+ console.error(
6262
+ "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed \u2014 full-frame delivery for the rest of this session.",
6263
+ err
6264
+ );
6265
+ cropCapableRef.current = false;
6266
+ unflipCrop();
6267
+ updateMockupIdsRaw([result.mockupId]);
6268
+ }
6269
+ },
6270
+ [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
6271
+ );
6272
+ const pumpCompositeQueue = (0, import_react25.useCallback)(
6273
+ (mockupId) => {
6274
+ if (compositeBusyRef.current.has(mockupId)) return;
6275
+ const job = compositeQueueRef.current.get(mockupId);
6276
+ if (!job) return;
6277
+ compositeQueueRef.current.delete(mockupId);
6278
+ compositeBusyRef.current.add(mockupId);
6279
+ void runComposite(job).finally(() => {
6280
+ compositeBusyRef.current.delete(mockupId);
6281
+ pumpCompositeQueue(mockupId);
6282
+ });
6283
+ },
6284
+ [runComposite]
6285
+ );
6286
+ (0, import_react25.useEffect)(() => {
6287
+ if (rawMockupResults.length === 0) {
6288
+ if (mockupResultsRef.current.length > 0) {
6289
+ mockupResultsRef.current = [];
6290
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
6291
+ }
6292
+ return;
6087
6293
  }
6088
- mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
6294
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}${r.cropRect ? " [crop]" : ""}`));
6295
+ const displayable = [];
6089
6296
  rawMockupResults.forEach((result) => {
6090
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
6091
- if (subscribers && subscribers.size > 0) {
6092
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
6093
- subscribers.forEach((cb) => cb(result));
6094
- } else {
6095
- console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
6297
+ if (!result.imageUrl) {
6298
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
6299
+ return;
6300
+ }
6301
+ const firstSight = processedFrameRef.current.get(result.mockupId) !== result.imageUrl;
6302
+ const sessionLive = configSentRef.current;
6303
+ if (isFullRect(result.cropRect)) {
6304
+ if (firstSight && sessionLive) {
6305
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
6306
+ const imgPromise = loadImageElement(result.imageUrl);
6307
+ imgPromise.catch(() => {
6308
+ });
6309
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl, imgPromise });
6310
+ }
6311
+ displayable.push(result);
6312
+ } else if (firstSight && sessionLive) {
6313
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
6314
+ compositeQueueRef.current.set(result.mockupId, result);
6315
+ pumpCompositeQueue(result.mockupId);
6316
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
6317
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
6318
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
6096
6319
  }
6097
6320
  });
6098
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
6099
- hasBlobInFlightRef.current = false;
6100
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
6101
- }
6102
- }, [rawMockupResults]);
6321
+ deliverResults(displayable);
6322
+ maybeFlipCrop();
6323
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
6103
6324
  const sendCanvasBlob = (0, import_react25.useCallback)(
6104
6325
  (placement, blob, mockupCount, baseThrottleMs) => {
6105
6326
  blobQueuedGenRef.current = pipelineGenRef.current;
@@ -6160,9 +6381,11 @@ function RealtimeProvider({
6160
6381
  lastSentBgRef.current = void 0;
6161
6382
  lastSentSpreadCropsRef.current = "null";
6162
6383
  lastSentColorsRef.current = {};
6384
+ invalidateBackgrounds();
6385
+ lastConfigBaseRef.current = null;
6163
6386
  };
6164
6387
  }
6165
- }, [isEnabled, connect, disconnect, wsUrl]);
6388
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
6166
6389
  (0, import_react25.useEffect)(() => {
6167
6390
  if (rawMockupResults.length > 0 && isPendingMockupsRef.current) {
6168
6391
  isPendingMockupsRef.current = false;
@@ -6186,6 +6409,9 @@ function RealtimeProvider({
6186
6409
  if (!isFirstSend && !variantChanged && !bgChanged && !spreadCropsChanged) {
6187
6410
  return;
6188
6411
  }
6412
+ if (variantChanged || bgChanged) {
6413
+ invalidateBackgrounds();
6414
+ }
6189
6415
  const mockupIds = [];
6190
6416
  if (product.mockups && Array.isArray(product.mockups)) {
6191
6417
  product.mockups.forEach((mockup) => {
@@ -6220,7 +6446,10 @@ function RealtimeProvider({
6220
6446
  pendingBlobSubscribersRef.current.forEach((cb) => cb(p.label));
6221
6447
  });
6222
6448
  }
6223
- sendConfigRaw(configPayload);
6449
+ lastConfigBaseRef.current = configPayload;
6450
+ sendConfigRaw(
6451
+ croppedActiveRef.current ? { ...configPayload, overlayCrop: "auto" } : configPayload
6452
+ );
6224
6453
  configSentRef.current = true;
6225
6454
  lastSentVariantIdRef.current = variantId;
6226
6455
  lastSentBgRef.current = mockupBg;
@@ -6242,6 +6471,7 @@ function RealtimeProvider({
6242
6471
  productContext?.selection,
6243
6472
  sendConfigRaw,
6244
6473
  sendColorBlobTracked,
6474
+ invalidateBackgrounds,
6245
6475
  mockupWidth,
6246
6476
  mockupBg,
6247
6477
  spreadCropsVersion
@@ -6271,6 +6501,15 @@ function RealtimeProvider({
6271
6501
  productContext?.selection,
6272
6502
  sendColorBlobTracked
6273
6503
  ]);
6504
+ const updateMockupIdsGuarded = (0, import_react25.useCallback)(
6505
+ (mockupIds) => {
6506
+ if (croppedActiveRef.current && mockupIds.some((id) => !backgroundsRef.current.has(id))) {
6507
+ unflipCrop();
6508
+ }
6509
+ return updateMockupIdsRaw(mockupIds);
6510
+ },
6511
+ [updateMockupIdsRaw, unflipCrop]
6512
+ );
6274
6513
  const updatePlacementSettingsWrapped = (0, import_react25.useCallback)(
6275
6514
  (settings) => {
6276
6515
  placementSettingsOverridesRef.current = settings;
@@ -6470,28 +6709,6 @@ function RealtimeProvider({
6470
6709
  const isMockupLoading = (0, import_react25.useCallback)((mockupId) => {
6471
6710
  return pendingMockupIdsRef.current.has(mockupId);
6472
6711
  }, []);
6473
- const registerBlobUrl = (0, import_react25.useCallback)((blobOrUrl, placementName) => {
6474
- let url;
6475
- if (blobOrUrl instanceof Blob) {
6476
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6477
- const oldest = registeredBlobUrlsRef.current.values().next().value;
6478
- if (oldest) {
6479
- URL.revokeObjectURL(oldest);
6480
- registeredBlobUrlsRef.current.delete(oldest);
6481
- mockupUrlCacheRef.current.forEach((value, key) => {
6482
- if (value.url === oldest) {
6483
- mockupUrlCacheRef.current.delete(key);
6484
- }
6485
- });
6486
- }
6487
- }
6488
- url = URL.createObjectURL(blobOrUrl);
6489
- registeredBlobUrlsRef.current.add(url);
6490
- } else {
6491
- url = blobOrUrl;
6492
- }
6493
- return url;
6494
- }, []);
6495
6712
  const subscribeMockupResultsRef = (0, import_react25.useRef)(subscribeMockupResults);
6496
6713
  const subscribeMockupResultByIdRef = (0, import_react25.useRef)(subscribeMockupResultById);
6497
6714
  const subscribePendingBlobRef = (0, import_react25.useRef)(subscribePendingBlob);
@@ -6572,7 +6789,7 @@ function RealtimeProvider({
6572
6789
  disableRealtime,
6573
6790
  sendCanvasBlob,
6574
6791
  sendCanvasState,
6575
- updateMockupIds: updateMockupIdsRaw,
6792
+ updateMockupIds: updateMockupIdsGuarded,
6576
6793
  updatePlacementSettings: updatePlacementSettingsWrapped,
6577
6794
  setSpreadCrops,
6578
6795
  getMockupResultsImmediate: () => mockupResultsRef.current,
@@ -6609,7 +6826,7 @@ function RealtimeProvider({
6609
6826
  disableRealtime,
6610
6827
  sendCanvasBlob,
6611
6828
  sendCanvasState,
6612
- updateMockupIdsRaw,
6829
+ updateMockupIdsGuarded,
6613
6830
  updatePlacementSettingsWrapped,
6614
6831
  setSpreadCrops,
6615
6832
  stableSubscribeMockupResults,
@@ -10086,7 +10303,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10086
10303
  // Quick crossfade
10087
10304
  shimmerDelay = 200,
10088
10305
  shimmerFadeDuration = 500,
10089
- darkShimmerFadeInDuration = 300
10306
+ darkShimmerFadeInDuration = 300,
10307
+ emptyText = "No artwork",
10308
+ errorText = "Error loading",
10309
+ unavailableText = "Preview unavailable"
10090
10310
  }) {
10091
10311
  const context = useProductOptional();
10092
10312
  const designContext = useDesignOptional();
@@ -10531,7 +10751,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10531
10751
  )
10532
10752
  }
10533
10753
  ),
10534
- /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: "No artwork" })
10754
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: emptyText })
10535
10755
  ] })
10536
10756
  }
10537
10757
  );
@@ -10542,7 +10762,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10542
10762
  {
10543
10763
  className: `bg-red-50 flex items-center justify-center ${className || ""}`,
10544
10764
  style,
10545
- children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: "Error loading" }) })
10765
+ children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: errorText }) })
10546
10766
  }
10547
10767
  );
10548
10768
  }
@@ -10561,7 +10781,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10561
10781
  crossOrigin: "anonymous",
10562
10782
  className: "absolute inset-0 w-full h-full object-cover",
10563
10783
  draggable,
10564
- src: retriedUrlRef.current === renderedUrl ? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}` : renderedUrl,
10784
+ src: renderedUrl,
10565
10785
  loading: "eager",
10566
10786
  fetchPriority: "high",
10567
10787
  onClick,
@@ -10577,7 +10797,8 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10577
10797
  onError: () => {
10578
10798
  if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
10579
10799
  retriedUrlRef.current = renderedUrl;
10580
- setRetryNonce((n) => n + 1);
10800
+ void fetch(renderedUrl, { mode: "cors", cache: "reload", credentials: "omit" }).catch(() => {
10801
+ }).then(() => setRetryNonce((n) => n + 1));
10581
10802
  return;
10582
10803
  }
10583
10804
  setImgFailed(true);
@@ -10591,9 +10812,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10591
10812
  );
10592
10813
  }
10593
10814
  }
10594
- }
10815
+ },
10816
+ retryNonce
10595
10817
  ),
10596
- imgFailed && !prevUrl && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-muted-foreground text-xs", children: "Preview unavailable" }) }),
10818
+ imgFailed && !prevUrl && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-muted-foreground text-xs", children: unavailableText }) }),
10597
10819
  realtimeContext?.renderError && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
10598
10820
  "div",
10599
10821
  {
@@ -12083,7 +12305,10 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12083
12305
  onZoomChange,
12084
12306
  stickyHeroContext,
12085
12307
  mockupWidth,
12086
- onMockupUrlGenerated
12308
+ onMockupUrlGenerated,
12309
+ renderImageAction,
12310
+ errorText,
12311
+ unavailableText
12087
12312
  }) {
12088
12313
  const [activeIndex, setActiveIndex] = (0, import_react56.useState)(currentIndex);
12089
12314
  const [touchStart, setTouchStart] = (0, import_react56.useState)(
@@ -12617,34 +12842,52 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12617
12842
  children: images.map((image, index) => {
12618
12843
  const DECODE_WINDOW = 1;
12619
12844
  const isWithinDecodeWindow = Math.abs(index - activeIndex) <= DECODE_WINDOW;
12620
- return /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12845
+ return /* @__PURE__ */ (0, import_jsx_runtime74.jsxs)(
12621
12846
  "div",
12622
12847
  {
12623
12848
  className: "w-full h-full flex-shrink-0 relative select-none",
12624
- children: !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12625
- // Rainbow prism loader (same as the /create route) instead of
12626
- // a flat pulse; fills this relative slide via inset:0.
12627
- /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12628
- ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12629
- HeroProductImage,
12630
- {
12631
- productId,
12632
- artwork: currentArtwork,
12633
- placement: image.placement,
12634
- mockupId: image.mockupId,
12635
- width: mockupWidth,
12636
- realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12637
- onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12638
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12639
- }
12640
- ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12641
- StaticCarouselImage,
12642
- {
12643
- src: image.src,
12644
- alt: image.label ?? "",
12645
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12646
- }
12647
- )
12849
+ children: [
12850
+ !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12851
+ // Rainbow prism loader (same as the /create route) instead of
12852
+ // a flat pulse; fills this relative slide via inset:0.
12853
+ /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12854
+ ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12855
+ HeroProductImage,
12856
+ {
12857
+ productId,
12858
+ artwork: currentArtwork,
12859
+ placement: image.placement,
12860
+ mockupId: image.mockupId,
12861
+ width: mockupWidth,
12862
+ realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12863
+ onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12864
+ errorText,
12865
+ unavailableText,
12866
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12867
+ }
12868
+ ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12869
+ StaticCarouselImage,
12870
+ {
12871
+ src: image.src,
12872
+ alt: image.label ?? "",
12873
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12874
+ }
12875
+ ),
12876
+ isWithinDecodeWindow && (() => {
12877
+ const action = renderImageAction?.(image, index);
12878
+ return action ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12879
+ "div",
12880
+ {
12881
+ className: "absolute top-3 right-3 z-10",
12882
+ onClick: (e) => e.stopPropagation(),
12883
+ onTouchStart: (e) => e.stopPropagation(),
12884
+ onTouchEnd: (e) => e.stopPropagation(),
12885
+ onMouseDown: (e) => e.stopPropagation(),
12886
+ children: action
12887
+ }
12888
+ ) : null;
12889
+ })()
12890
+ ]
12648
12891
  },
12649
12892
  index
12650
12893
  );