@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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`e57a5bc`](https://github.com/snowcone-app/snowcone-monorepo/commit/e57a5bcb3b62ac612e43c47ab2709992ef6db385)]:
8
+ - @snowcone-app/sdk@0.20.3
9
+
10
+ ## 0.4.9
11
+
12
+ ### Patch Changes
13
+
14
+ - [#1150](https://github.com/snowcone-app/snowcone-monorepo/pull/1150) [`06997b2`](https://github.com/snowcone-app/snowcone-monorepo/commit/06997b211736d906d16eb9edeb186404f5acb19c) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Spread-mode printables now use JSON canvas-state realtime mode: `WebSocketConfig.spreadCrops` carries the spread/group artboard geometry (piece rects keyed by placement label), and `RealtimeProvider.setSpreadCrops` sends it on the realtime config so the server slices each rendered spread artboard into per-placement images — no client pixel upload.
15
+
16
+ - Updated dependencies [[`06997b2`](https://github.com/snowcone-app/snowcone-monorepo/commit/06997b211736d906d16eb9edeb186404f5acb19c)]:
17
+ - @snowcone-app/sdk@0.20.2
18
+
3
19
  ## 0.4.8
4
20
 
5
21
  ### 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();
@@ -5969,6 +6031,9 @@ function RealtimeProvider({
5969
6031
  const placementSettingsOverridesRef = (0, import_react25.useRef)({});
5970
6032
  const lastSentVariantIdRef = (0, import_react25.useRef)(null);
5971
6033
  const lastSentBgRef = (0, import_react25.useRef)(void 0);
6034
+ const spreadCropsRef = (0, import_react25.useRef)(void 0);
6035
+ const [spreadCropsVersion, setSpreadCropsVersion] = (0, import_react25.useState)(0);
6036
+ const lastSentSpreadCropsRef = (0, import_react25.useRef)("null");
5972
6037
  const lastSentColorsRef = (0, import_react25.useRef)({});
5973
6038
  const realtimeMockup = (0, import_react26.useRealtimeMockup)({
5974
6039
  wsUrl,
@@ -6045,58 +6110,214 @@ function RealtimeProvider({
6045
6110
  const registeredBlobUrlsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6046
6111
  const pendingPlacementsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6047
6112
  const pendingMockupIdsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6048
- (0, import_react25.useEffect)(() => {
6049
- if (rawMockupResults.length === 0) {
6050
- if (mockupResultsRef.current.length > 0) {
6051
- mockupResultsRef.current = [];
6052
- 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
+ }
6053
6139
  }
6054
- return;
6140
+ url = URL.createObjectURL(blobOrUrl);
6141
+ registeredBlobUrlsRef.current.add(url);
6142
+ } else {
6143
+ url = blobOrUrl;
6055
6144
  }
6056
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}`));
6057
- mockupResultsRef.current = rawMockupResults;
6058
- const now = Date.now();
6059
- rawMockupResults.forEach((result) => {
6060
- 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) => {
6061
6200
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
6062
6201
  mockupUrlCacheRef.current.set(result.mockupId, {
6063
6202
  url: result.imageUrl,
6064
- isBlob: false,
6065
- // Server URLs are not blob URLs
6203
+ isBlob: result.imageUrl.startsWith("blob:"),
6066
6204
  timestamp: now
6067
6205
  });
6068
6206
  pendingMockupIdsRef.current.delete(result.mockupId);
6069
6207
  if (wasPending) {
6070
6208
  console.log(`[RTP] Cleared pending for ${result.mockupId} \u2014 url cached`);
6071
6209
  }
6210
+ if (result.placement) clearedPlacements.add(result.placement);
6211
+ });
6212
+ if (clearedPlacements.size > 0) {
6213
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6072
6214
  } else {
6073
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
6215
+ pendingPlacementsRef.current.clear();
6074
6216
  }
6075
- });
6076
- const clearedPlacements = /* @__PURE__ */ new Set();
6077
- rawMockupResults.forEach((result) => {
6078
- if (result.placement) clearedPlacements.add(result.placement);
6079
- });
6080
- if (clearedPlacements.size > 0) {
6081
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6082
- } else {
6083
- 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;
6084
6293
  }
6085
- 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 = [];
6086
6296
  rawMockupResults.forEach((result) => {
6087
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
6088
- if (subscribers && subscribers.size > 0) {
6089
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
6090
- subscribers.forEach((cb) => cb(result));
6091
- } else {
6092
- 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
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl });
6307
+ }
6308
+ displayable.push(result);
6309
+ } else if (firstSight && sessionLive) {
6310
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
6311
+ compositeQueueRef.current.set(result.mockupId, result);
6312
+ pumpCompositeQueue(result.mockupId);
6313
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
6314
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
6315
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
6093
6316
  }
6094
6317
  });
6095
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
6096
- hasBlobInFlightRef.current = false;
6097
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
6098
- }
6099
- }, [rawMockupResults]);
6318
+ deliverResults(displayable);
6319
+ maybeFlipCrop();
6320
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
6100
6321
  const sendCanvasBlob = (0, import_react25.useCallback)(
6101
6322
  (placement, blob, mockupCount, baseThrottleMs) => {
6102
6323
  blobQueuedGenRef.current = pipelineGenRef.current;
@@ -6155,10 +6376,13 @@ function RealtimeProvider({
6155
6376
  configSentRef.current = false;
6156
6377
  lastSentVariantIdRef.current = null;
6157
6378
  lastSentBgRef.current = void 0;
6379
+ lastSentSpreadCropsRef.current = "null";
6158
6380
  lastSentColorsRef.current = {};
6381
+ invalidateBackgrounds();
6382
+ lastConfigBaseRef.current = null;
6159
6383
  };
6160
6384
  }
6161
- }, [isEnabled, connect, disconnect, wsUrl]);
6385
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
6162
6386
  (0, import_react25.useEffect)(() => {
6163
6387
  if (rawMockupResults.length > 0 && isPendingMockupsRef.current) {
6164
6388
  isPendingMockupsRef.current = false;
@@ -6177,9 +6401,14 @@ function RealtimeProvider({
6177
6401
  const isFirstSend = !configSentRef.current;
6178
6402
  const variantChanged = lastSentVariantIdRef.current !== null && lastSentVariantIdRef.current !== variantId;
6179
6403
  const bgChanged = configSentRef.current && lastSentBgRef.current !== mockupBg;
6180
- if (!isFirstSend && !variantChanged && !bgChanged) {
6404
+ const spreadCropsJson = JSON.stringify(spreadCropsRef.current ?? null);
6405
+ const spreadCropsChanged = configSentRef.current && lastSentSpreadCropsRef.current !== spreadCropsJson;
6406
+ if (!isFirstSend && !variantChanged && !bgChanged && !spreadCropsChanged) {
6181
6407
  return;
6182
6408
  }
6409
+ if (variantChanged || bgChanged) {
6410
+ invalidateBackgrounds();
6411
+ }
6183
6412
  const mockupIds = [];
6184
6413
  if (product.mockups && Array.isArray(product.mockups)) {
6185
6414
  product.mockups.forEach((mockup) => {
@@ -6195,6 +6424,7 @@ function RealtimeProvider({
6195
6424
  shop,
6196
6425
  width: mockupWidth,
6197
6426
  ...mockupBg ? { bg: mockupBg } : {},
6427
+ ...spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {},
6198
6428
  placementSettings: { ...placementSettingsOverridesRef.current }
6199
6429
  } : {
6200
6430
  productId,
@@ -6204,6 +6434,7 @@ function RealtimeProvider({
6204
6434
  seal: "bypass-sig-for-k6-load-test",
6205
6435
  width: mockupWidth,
6206
6436
  ...mockupBg ? { bg: mockupBg } : {},
6437
+ ...spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {},
6207
6438
  placementSettings: { ...placementSettingsOverridesRef.current }
6208
6439
  };
6209
6440
  if (variantChanged) {
@@ -6212,10 +6443,14 @@ function RealtimeProvider({
6212
6443
  pendingBlobSubscribersRef.current.forEach((cb) => cb(p.label));
6213
6444
  });
6214
6445
  }
6215
- sendConfigRaw(configPayload);
6446
+ lastConfigBaseRef.current = configPayload;
6447
+ sendConfigRaw(
6448
+ croppedActiveRef.current ? { ...configPayload, overlayCrop: "auto" } : configPayload
6449
+ );
6216
6450
  configSentRef.current = true;
6217
6451
  lastSentVariantIdRef.current = variantId;
6218
6452
  lastSentBgRef.current = mockupBg;
6453
+ lastSentSpreadCropsRef.current = spreadCropsJson;
6219
6454
  setTimeout(() => {
6220
6455
  const colorPlacements = placements.filter((p) => p.type === "color");
6221
6456
  colorPlacements.forEach((p) => {
@@ -6233,8 +6468,10 @@ function RealtimeProvider({
6233
6468
  productContext?.selection,
6234
6469
  sendConfigRaw,
6235
6470
  sendColorBlobTracked,
6471
+ invalidateBackgrounds,
6236
6472
  mockupWidth,
6237
- mockupBg
6473
+ mockupBg,
6474
+ spreadCropsVersion
6238
6475
  ]);
6239
6476
  (0, import_react25.useEffect)(() => {
6240
6477
  if (!isEnabled || !isConnected || !isConfigured || !productContext?.product)
@@ -6261,6 +6498,15 @@ function RealtimeProvider({
6261
6498
  productContext?.selection,
6262
6499
  sendColorBlobTracked
6263
6500
  ]);
6501
+ const updateMockupIdsGuarded = (0, import_react25.useCallback)(
6502
+ (mockupIds) => {
6503
+ if (croppedActiveRef.current && mockupIds.some((id) => !backgroundsRef.current.has(id))) {
6504
+ unflipCrop();
6505
+ }
6506
+ return updateMockupIdsRaw(mockupIds);
6507
+ },
6508
+ [updateMockupIdsRaw, unflipCrop]
6509
+ );
6264
6510
  const updatePlacementSettingsWrapped = (0, import_react25.useCallback)(
6265
6511
  (settings) => {
6266
6512
  placementSettingsOverridesRef.current = settings;
@@ -6268,6 +6514,17 @@ function RealtimeProvider({
6268
6514
  },
6269
6515
  [updatePlacementSettingsRaw]
6270
6516
  );
6517
+ const setSpreadCrops = (0, import_react25.useCallback)(
6518
+ (crops) => {
6519
+ const next = crops && Object.keys(crops).length > 0 ? crops : void 0;
6520
+ if (JSON.stringify(next ?? null) === JSON.stringify(spreadCropsRef.current ?? null)) {
6521
+ return;
6522
+ }
6523
+ spreadCropsRef.current = next;
6524
+ setSpreadCropsVersion((v) => v + 1);
6525
+ },
6526
+ []
6527
+ );
6271
6528
  const enableRealtime = (0, import_react25.useCallback)(() => {
6272
6529
  setIsEnabled(true);
6273
6530
  }, []);
@@ -6449,28 +6706,6 @@ function RealtimeProvider({
6449
6706
  const isMockupLoading = (0, import_react25.useCallback)((mockupId) => {
6450
6707
  return pendingMockupIdsRef.current.has(mockupId);
6451
6708
  }, []);
6452
- const registerBlobUrl = (0, import_react25.useCallback)((blobOrUrl, placementName) => {
6453
- let url;
6454
- if (blobOrUrl instanceof Blob) {
6455
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6456
- const oldest = registeredBlobUrlsRef.current.values().next().value;
6457
- if (oldest) {
6458
- URL.revokeObjectURL(oldest);
6459
- registeredBlobUrlsRef.current.delete(oldest);
6460
- mockupUrlCacheRef.current.forEach((value, key) => {
6461
- if (value.url === oldest) {
6462
- mockupUrlCacheRef.current.delete(key);
6463
- }
6464
- });
6465
- }
6466
- }
6467
- url = URL.createObjectURL(blobOrUrl);
6468
- registeredBlobUrlsRef.current.add(url);
6469
- } else {
6470
- url = blobOrUrl;
6471
- }
6472
- return url;
6473
- }, []);
6474
6709
  const subscribeMockupResultsRef = (0, import_react25.useRef)(subscribeMockupResults);
6475
6710
  const subscribeMockupResultByIdRef = (0, import_react25.useRef)(subscribeMockupResultById);
6476
6711
  const subscribePendingBlobRef = (0, import_react25.useRef)(subscribePendingBlob);
@@ -6551,8 +6786,9 @@ function RealtimeProvider({
6551
6786
  disableRealtime,
6552
6787
  sendCanvasBlob,
6553
6788
  sendCanvasState,
6554
- updateMockupIds: updateMockupIdsRaw,
6789
+ updateMockupIds: updateMockupIdsGuarded,
6555
6790
  updatePlacementSettings: updatePlacementSettingsWrapped,
6791
+ setSpreadCrops,
6556
6792
  getMockupResultsImmediate: () => mockupResultsRef.current,
6557
6793
  subscribeMockupResults: stableSubscribeMockupResults,
6558
6794
  subscribeMockupResultById: stableSubscribeMockupResultById,
@@ -6587,8 +6823,9 @@ function RealtimeProvider({
6587
6823
  disableRealtime,
6588
6824
  sendCanvasBlob,
6589
6825
  sendCanvasState,
6590
- updateMockupIdsRaw,
6826
+ updateMockupIdsGuarded,
6591
6827
  updatePlacementSettingsWrapped,
6828
+ setSpreadCrops,
6592
6829
  stableSubscribeMockupResults,
6593
6830
  stableSubscribeMockupResultById,
6594
6831
  notifyPendingExport,
@@ -10063,7 +10300,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10063
10300
  // Quick crossfade
10064
10301
  shimmerDelay = 200,
10065
10302
  shimmerFadeDuration = 500,
10066
- darkShimmerFadeInDuration = 300
10303
+ darkShimmerFadeInDuration = 300,
10304
+ emptyText = "No artwork",
10305
+ errorText = "Error loading",
10306
+ unavailableText = "Preview unavailable"
10067
10307
  }) {
10068
10308
  const context = useProductOptional();
10069
10309
  const designContext = useDesignOptional();
@@ -10508,7 +10748,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10508
10748
  )
10509
10749
  }
10510
10750
  ),
10511
- /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: "No artwork" })
10751
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: emptyText })
10512
10752
  ] })
10513
10753
  }
10514
10754
  );
@@ -10519,7 +10759,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10519
10759
  {
10520
10760
  className: `bg-red-50 flex items-center justify-center ${className || ""}`,
10521
10761
  style,
10522
- 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" }) })
10762
+ 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 }) })
10523
10763
  }
10524
10764
  );
10525
10765
  }
@@ -10538,7 +10778,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10538
10778
  crossOrigin: "anonymous",
10539
10779
  className: "absolute inset-0 w-full h-full object-cover",
10540
10780
  draggable,
10541
- src: retriedUrlRef.current === renderedUrl ? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}` : renderedUrl,
10781
+ src: renderedUrl,
10542
10782
  loading: "eager",
10543
10783
  fetchPriority: "high",
10544
10784
  onClick,
@@ -10554,7 +10794,8 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10554
10794
  onError: () => {
10555
10795
  if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
10556
10796
  retriedUrlRef.current = renderedUrl;
10557
- setRetryNonce((n) => n + 1);
10797
+ void fetch(renderedUrl, { mode: "cors", cache: "reload", credentials: "omit" }).catch(() => {
10798
+ }).then(() => setRetryNonce((n) => n + 1));
10558
10799
  return;
10559
10800
  }
10560
10801
  setImgFailed(true);
@@ -10568,9 +10809,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10568
10809
  );
10569
10810
  }
10570
10811
  }
10571
- }
10812
+ },
10813
+ retryNonce
10572
10814
  ),
10573
- 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" }) }),
10815
+ 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 }) }),
10574
10816
  realtimeContext?.renderError && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
10575
10817
  "div",
10576
10818
  {
@@ -12060,7 +12302,10 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12060
12302
  onZoomChange,
12061
12303
  stickyHeroContext,
12062
12304
  mockupWidth,
12063
- onMockupUrlGenerated
12305
+ onMockupUrlGenerated,
12306
+ renderImageAction,
12307
+ errorText,
12308
+ unavailableText
12064
12309
  }) {
12065
12310
  const [activeIndex, setActiveIndex] = (0, import_react56.useState)(currentIndex);
12066
12311
  const [touchStart, setTouchStart] = (0, import_react56.useState)(
@@ -12594,34 +12839,52 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12594
12839
  children: images.map((image, index) => {
12595
12840
  const DECODE_WINDOW = 1;
12596
12841
  const isWithinDecodeWindow = Math.abs(index - activeIndex) <= DECODE_WINDOW;
12597
- return /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12842
+ return /* @__PURE__ */ (0, import_jsx_runtime74.jsxs)(
12598
12843
  "div",
12599
12844
  {
12600
12845
  className: "w-full h-full flex-shrink-0 relative select-none",
12601
- children: !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12602
- // Rainbow prism loader (same as the /create route) instead of
12603
- // a flat pulse; fills this relative slide via inset:0.
12604
- /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12605
- ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12606
- HeroProductImage,
12607
- {
12608
- productId,
12609
- artwork: currentArtwork,
12610
- placement: image.placement,
12611
- mockupId: image.mockupId,
12612
- width: mockupWidth,
12613
- realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12614
- onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12615
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12616
- }
12617
- ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12618
- StaticCarouselImage,
12619
- {
12620
- src: image.src,
12621
- alt: image.label ?? "",
12622
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12623
- }
12624
- )
12846
+ children: [
12847
+ !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12848
+ // Rainbow prism loader (same as the /create route) instead of
12849
+ // a flat pulse; fills this relative slide via inset:0.
12850
+ /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12851
+ ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12852
+ HeroProductImage,
12853
+ {
12854
+ productId,
12855
+ artwork: currentArtwork,
12856
+ placement: image.placement,
12857
+ mockupId: image.mockupId,
12858
+ width: mockupWidth,
12859
+ realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12860
+ onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12861
+ errorText,
12862
+ unavailableText,
12863
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12864
+ }
12865
+ ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12866
+ StaticCarouselImage,
12867
+ {
12868
+ src: image.src,
12869
+ alt: image.label ?? "",
12870
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12871
+ }
12872
+ ),
12873
+ isWithinDecodeWindow && (() => {
12874
+ const action = renderImageAction?.(image, index);
12875
+ return action ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12876
+ "div",
12877
+ {
12878
+ className: "absolute top-3 right-3 z-10",
12879
+ onClick: (e) => e.stopPropagation(),
12880
+ onTouchStart: (e) => e.stopPropagation(),
12881
+ onTouchEnd: (e) => e.stopPropagation(),
12882
+ onMouseDown: (e) => e.stopPropagation(),
12883
+ children: action
12884
+ }
12885
+ ) : null;
12886
+ })()
12887
+ ]
12625
12888
  },
12626
12889
  index
12627
12890
  );