@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.
@@ -38,11 +38,20 @@ import React, {
38
38
  } from "react";
39
39
  import { useRealtimeMockup } from "@snowcone-app/sdk/react";
40
40
  import { resolveVariantId as resolveVariantIdUtil } from "@snowcone-app/sdk";
41
- import type { RealtimeRenderError } from "@snowcone-app/sdk";
41
+ import type {
42
+ RealtimeRenderError,
43
+ MockupResult as SdkMockupResult,
44
+ } from "@snowcone-app/sdk";
42
45
  import { readEnv } from "../lib/env";
43
46
  import { useProductOptional } from "./Product";
44
47
  import type { ReactProductContext } from "./Product";
45
48
  import { useMockupBackgroundOptional } from "./MockupBackground";
49
+ import {
50
+ allBackgroundsSeeded,
51
+ compositeCroppedFrame,
52
+ isFullRect,
53
+ loadImageElement,
54
+ } from "./croppedFrameComposite";
46
55
 
47
56
  // Default artboard size for export calculations (matches @snowcone-app/canvas default)
48
57
  const DEFAULT_ARTBOARD_SIZE = 2000;
@@ -276,6 +285,18 @@ export interface RealtimeProviderProps {
276
285
  * via a renewable token. Without them, the legacy seal path runs (shim).
277
286
  */
278
287
  grantUrl?: string;
288
+ /**
289
+ * Frame delivery strategy. `"cropped"` (default, the fast path): once every
290
+ * configured mockup has rendered one full frame (frozen internally as its
291
+ * background), the session flips to `overlayCrop: 'auto'` — the server ships
292
+ * only the product-region pixels per edit and the provider composites them
293
+ * over the frozen background before notifying consumers, so subscribers and
294
+ * the URL cache only ever see full-frame images. Opt out with
295
+ * `"full-frame"` to ship the whole scene on every render (the pre-crop
296
+ * behavior). Any composite failure falls back to full-frame delivery for
297
+ * the rest of the session automatically.
298
+ */
299
+ delivery?: "cropped" | "full-frame";
279
300
  }
280
301
 
281
302
  /**
@@ -292,6 +313,7 @@ export function RealtimeProvider({
292
313
  autoEnable = false,
293
314
  shop,
294
315
  grantUrl,
316
+ delivery = "cropped",
295
317
  }: RealtimeProviderProps) {
296
318
  const productContext = useProductOptional();
297
319
  // Page background for renders (see MockupBackground) — sent on the config so
@@ -472,74 +494,318 @@ export function RealtimeProvider({
472
494
  // Track which mockupIds are pending (derived from placements)
473
495
  const pendingMockupIdsRef = useRef<Set<string>>(new Set());
474
496
 
475
- // Sync rawMockupResults to ref, update URL cache, and notify subscribers (NO STATE UPDATES)
476
- useEffect(() => {
477
- if (rawMockupResults.length === 0) {
478
- if (mockupResultsRef.current.length > 0) {
479
- mockupResultsRef.current = [];
480
- mockupSubscribersRef.current.forEach((cb) => cb([]));
497
+ // ============================================================================
498
+ // CROPPED-FRAME DELIVERY (internal — consumers only ever see full frames)
499
+ // ============================================================================
500
+
501
+ // Frozen full-frame background per mockup (the composite base for cropped
502
+ // frames). Values are server URLs, never blob URLs.
503
+ const backgroundsRef = useRef<
504
+ Map<string, { url: string; imgPromise?: Promise<HTMLImageElement> }>
505
+ >(new Map());
506
+ // Whether the last-sent config included overlayCrop: 'auto'.
507
+ const croppedActiveRef = useRef(false);
508
+ // Composite kill-switch: any composite failure disables cropped delivery
509
+ // for the rest of the session (full-frame is always correct, just heavier).
510
+ const cropCapableRef = useRef(true);
511
+ // Last config payload sent, WITHOUT overlayCrop — flip/unflip re-sends it.
512
+ const lastConfigBaseRef = useRef<Record<string, unknown> | null>(null);
513
+ // Scene generation — bumped when a config change invalidates backgrounds
514
+ // (variant/bg). In-flight composites from an older scene are dropped.
515
+ const sceneGenRef = useRef(0);
516
+ // Frame URL last processed per mockup. The sdk hook's results array is
517
+ // cumulative state (not a delta), so this gates background seeding and
518
+ // composite work across effect re-runs. Cleared on scene invalidation —
519
+ // safe because the sdk clears mockupResults on every config send, so an
520
+ // old-scene frame is never re-observed after the config that replaced it;
521
+ // clearing is what lets a reconnect re-seed from identical (render-cached)
522
+ // frame URLs.
523
+ const processedFrameRef = useRef<Map<string, string>>(new Map());
524
+ // Latest-wins composite queue per mockup + in-flight tracking. Frames can
525
+ // stream faster than a device composites; only the newest matters.
526
+ const compositeQueueRef = useRef<Map<string, SdkMockupResult>>(new Map());
527
+ const compositeBusyRef = useRef<Set<string>>(new Set());
528
+ const compositeCanvasRef = useRef<HTMLCanvasElement | null>(null);
529
+
530
+ /**
531
+ * Register a blob URL with lifecycle management
532
+ */
533
+ const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
534
+ let url: string;
535
+
536
+ if (blobOrUrl instanceof Blob) {
537
+ // Enforce blob URL limit before creating new one
538
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
539
+ // Revoke oldest blob URL (first entry in Set maintains insertion order)
540
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
541
+ if (oldest) {
542
+ URL.revokeObjectURL(oldest);
543
+ registeredBlobUrlsRef.current.delete(oldest);
544
+
545
+ // Also remove from URL cache and the results snapshot if present, so
546
+ // late subscribers are never replayed a revoked blob URL.
547
+ mockupUrlCacheRef.current.forEach((value, key) => {
548
+ if (value.url === oldest) {
549
+ mockupUrlCacheRef.current.delete(key);
550
+ }
551
+ });
552
+ mockupResultsRef.current = mockupResultsRef.current.filter(
553
+ (r) => r.imageUrl !== oldest
554
+ );
555
+ }
481
556
  }
482
- return;
557
+
558
+ url = URL.createObjectURL(blobOrUrl);
559
+ registeredBlobUrlsRef.current.add(url);
560
+ } else {
561
+ url = blobOrUrl;
483
562
  }
484
563
 
485
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}`));
564
+ return url;
565
+ }, []);
486
566
 
487
- // Update ref immediately
488
- mockupResultsRef.current = rawMockupResults;
567
+ const croppedDeliveryEnabled = delivery !== "full-frame";
568
+
569
+ /** Re-send the last config with/without overlayCrop (delivery flip only). */
570
+ const sendCropConfig = useCallback(
571
+ (withCrop: boolean) => {
572
+ const base = lastConfigBaseRef.current;
573
+ if (!base) return;
574
+ croppedActiveRef.current = withCrop;
575
+ sendConfigRaw({
576
+ ...base,
577
+ placementSettings: { ...placementSettingsOverridesRef.current },
578
+ ...(withCrop ? { overlayCrop: "auto" as const } : {}),
579
+ } as Parameters<typeof sendConfigRaw>[0]);
580
+ },
581
+ [sendConfigRaw]
582
+ );
489
583
 
490
- // Update centralized URL cache with new results
491
- const now = Date.now();
492
- rawMockupResults.forEach((result) => {
493
- if (result.imageUrl) {
584
+ /** Drop back to full-frame delivery (always correct, just heavier). */
585
+ const unflipCrop = useCallback(() => {
586
+ if (!croppedActiveRef.current) return;
587
+ sendCropConfig(false);
588
+ }, [sendCropConfig]);
589
+
590
+ /** Flip to cropped delivery once every configured mockup has a frozen
591
+ * background (a cropped frame can't seed one). */
592
+ const maybeFlipCrop = useCallback(() => {
593
+ if (!croppedDeliveryEnabled || !cropCapableRef.current) return;
594
+ if (croppedActiveRef.current) return;
595
+ const base = lastConfigBaseRef.current;
596
+ if (!base) return;
597
+ const ids = (base.mockupIds as string[] | undefined) ?? [];
598
+ const seeded = new Set(backgroundsRef.current.keys());
599
+ if (allBackgroundsSeeded(ids, seeded)) {
600
+ console.log(`[RTP] Cropped delivery ON — all ${ids.length} mockup background(s) seeded`);
601
+ sendCropConfig(true);
602
+ } else {
603
+ const missing = ids.filter((id) => !seeded.has(id));
604
+ console.log(`[RTP] Cropped delivery waiting — ${missing.length}/${ids.length} mockup(s) unseeded: [${missing.slice(0, 8).join(", ")}]`);
605
+ }
606
+ }, [croppedDeliveryEnabled, sendCropConfig]);
607
+
608
+ /** Scene changed (variant/bg) or session reset — every frozen background
609
+ * is stale. */
610
+ const invalidateBackgrounds = useCallback(() => {
611
+ sceneGenRef.current += 1;
612
+ backgroundsRef.current.clear();
613
+ processedFrameRef.current.clear();
614
+ compositeQueueRef.current.clear();
615
+ croppedActiveRef.current = false;
616
+ }, []);
617
+
618
+ /**
619
+ * Deliver displayable (always full-frame) results: update the snapshot and
620
+ * URL cache, clear pending state, notify subscribers, check settle.
621
+ */
622
+ const deliverResults = useCallback(
623
+ (results: SdkMockupResult[]) => {
624
+ if (results.length === 0) return;
625
+ const now = Date.now();
626
+
627
+ // Upsert into the results snapshot (latest per mockup)
628
+ const merged = [...mockupResultsRef.current];
629
+ results.forEach((result) => {
630
+ const idx = merged.findIndex((r) => r.mockupId === result.mockupId);
631
+ if (idx >= 0) merged[idx] = result;
632
+ else merged.push(result);
633
+ });
634
+ mockupResultsRef.current = merged;
635
+
636
+ const clearedPlacements = new Set<string>();
637
+ results.forEach((result) => {
494
638
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
495
639
  mockupUrlCacheRef.current.set(result.mockupId, {
496
640
  url: result.imageUrl,
497
- isBlob: false, // Server URLs are not blob URLs
641
+ isBlob: result.imageUrl.startsWith("blob:"),
498
642
  timestamp: now,
499
643
  });
500
-
501
- // Clear pending state for this mockup
502
644
  pendingMockupIdsRef.current.delete(result.mockupId);
503
645
  if (wasPending) {
504
646
  console.log(`[RTP] Cleared pending for ${result.mockupId} — url cached`);
505
647
  }
648
+ if (result.placement) clearedPlacements.add(result.placement);
649
+ });
650
+
651
+ // Clear pending placement state so notifyPendingExport can re-fire on
652
+ // the next edit. Placement-scoped when known, otherwise clear all.
653
+ if (clearedPlacements.size > 0) {
654
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
506
655
  } else {
507
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
656
+ pendingPlacementsRef.current.clear();
508
657
  }
509
- });
510
658
 
511
- // Clear pending placement state so notifyPendingExport can re-fire on next edit.
512
- // We clear based on result placements when available, otherwise clear all.
513
- const clearedPlacements = new Set<string>();
514
- rawMockupResults.forEach((result) => {
515
- if (result.placement) clearedPlacements.add(result.placement);
516
- });
517
- if (clearedPlacements.size > 0) {
518
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
519
- } else {
520
- pendingPlacementsRef.current.clear();
659
+ // Notify subscribers immediately (no throttle needed - no state updates!)
660
+ mockupSubscribersRef.current.forEach((cb) => cb(results));
661
+
662
+ // Notify filtered subscribers
663
+ results.forEach((result) => {
664
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
665
+ if (subscribers && subscribers.size > 0) {
666
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
667
+ subscribers.forEach((cb) => cb(result));
668
+ } else {
669
+ console.log(`[RTP] No subscribers for ${result.mockupId} — result received but nobody listening`);
670
+ }
671
+ });
672
+
673
+ // Pipeline-settled check
674
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
675
+ hasBlobInFlightRef.current = false;
676
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
677
+ }
678
+ },
679
+ [hasPendingBlobs]
680
+ );
681
+
682
+ /** Composite one cropped frame over its frozen background and deliver it. */
683
+ const runComposite = useCallback(
684
+ async (result: SdkMockupResult) => {
685
+ const gen = sceneGenRef.current;
686
+ const bg = backgroundsRef.current.get(result.mockupId);
687
+ if (!bg) {
688
+ // A cropped frame arrived for a mockup with no frozen background
689
+ // (e.g. a priority request raced the flip). Drop the session back to
690
+ // full-frame and re-render this mockup from the server's cached
691
+ // blobs so the edit isn't lost; the flip re-arms once it seeds.
692
+ console.error(
693
+ `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background — falling back to full-frame and re-rendering it.`
694
+ );
695
+ unflipCrop();
696
+ updateMockupIdsRaw([result.mockupId]);
697
+ return;
698
+ }
699
+ try {
700
+ if (!bg.imgPromise) bg.imgPromise = loadImageElement(bg.url);
701
+ const background = await bg.imgPromise;
702
+ if (!compositeCanvasRef.current && typeof document !== "undefined") {
703
+ compositeCanvasRef.current = document.createElement("canvas");
704
+ }
705
+ const blob = await compositeCroppedFrame({
706
+ background,
707
+ frameUrl: result.imageUrl,
708
+ rect: result.cropRect!,
709
+ canvas: compositeCanvasRef.current ?? undefined,
710
+ });
711
+ // Scene changed while compositing (variant/bg flip) — stale, drop.
712
+ if (gen !== sceneGenRef.current) return;
713
+ deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
714
+ } catch (err) {
715
+ console.error(
716
+ "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
717
+ err
718
+ );
719
+ cropCapableRef.current = false;
720
+ unflipCrop();
721
+ // Re-render the affected mockup full-frame so this edit isn't lost.
722
+ updateMockupIdsRaw([result.mockupId]);
723
+ }
724
+ },
725
+ [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
726
+ );
727
+
728
+ /** Serial per-mockup composite pump (latest-wins). */
729
+ const pumpCompositeQueue = useCallback(
730
+ (mockupId: string) => {
731
+ if (compositeBusyRef.current.has(mockupId)) return;
732
+ const job = compositeQueueRef.current.get(mockupId);
733
+ if (!job) return;
734
+ compositeQueueRef.current.delete(mockupId);
735
+ compositeBusyRef.current.add(mockupId);
736
+ void runComposite(job).finally(() => {
737
+ compositeBusyRef.current.delete(mockupId);
738
+ pumpCompositeQueue(mockupId);
739
+ });
740
+ },
741
+ [runComposite]
742
+ );
743
+
744
+ // Sync sdk results into the delivery pipeline (NO STATE UPDATES).
745
+ // Full frames pass straight through (and seed/refresh their mockup's frozen
746
+ // background); cropped frames composite over that background first — so
747
+ // consumers only ever receive full-frame URLs, whatever the wire delivery.
748
+ useEffect(() => {
749
+ if (rawMockupResults.length === 0) {
750
+ if (mockupResultsRef.current.length > 0) {
751
+ mockupResultsRef.current = [];
752
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
753
+ }
754
+ return;
521
755
  }
522
756
 
523
- // Notify subscribers immediately (no throttle needed - no state updates!)
524
- mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
757
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}${r.cropRect ? ' [crop]' : ''}`));
525
758
 
526
- // Notify filtered subscribers
759
+ const displayable: SdkMockupResult[] = [];
527
760
  rawMockupResults.forEach((result) => {
528
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
529
- if (subscribers && subscribers.size > 0) {
530
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
531
- subscribers.forEach((cb) => cb(result));
532
- } else {
533
- console.log(`[RTP] No subscribers for ${result.mockupId} — result received but nobody listening`);
761
+ if (!result.imageUrl) {
762
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
763
+ return;
764
+ }
765
+ const firstSight =
766
+ processedFrameRef.current.get(result.mockupId) !== result.imageUrl;
767
+ // Only frames observed under a live session config may seed backgrounds
768
+ // or composite — a leftover pre-config array (e.g. across a reconnect)
769
+ // may belong to a scene this session hasn't configured yet.
770
+ const sessionLive = configSentRef.current;
771
+
772
+ if (isFullRect(result.cropRect)) {
773
+ if (firstSight && sessionLive) {
774
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
775
+ // Seed/refresh the frozen background — the freshest full frame is
776
+ // always the best composite base. Decode it NOW, not at composite
777
+ // time: with ws binary delivery `imageUrl` is the SDK's live blob:
778
+ // URL for this mockup, and the SDK revokes it the moment the next
779
+ // frame for the same mockup (the first cropped one) arrives. A
780
+ // lazily-loaded background would then fail with
781
+ // E_OVERLAY_FRAME_LOAD and kill cropped delivery for the session
782
+ // (observed: every edit cost 3 extra full renders). A decoded
783
+ // HTMLImageElement keeps its pixels after the URL is revoked.
784
+ const imgPromise = loadImageElement(result.imageUrl);
785
+ imgPromise.catch(() => {
786
+ /* surfaced by runComposite when it awaits the promise */
787
+ });
788
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl, imgPromise });
789
+ }
790
+ // Full frames re-deliver on every run (matches pre-crop behavior —
791
+ // consumers dedupe by URL; delivery also clears pending state).
792
+ displayable.push(result);
793
+ } else if (firstSight && sessionLive) {
794
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
795
+ compositeQueueRef.current.set(result.mockupId, result);
796
+ pumpCompositeQueue(result.mockupId);
797
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
798
+ // Already-composited frame re-observed while the mockup is marked
799
+ // pending (e.g. a render that resolved to the identical frame).
800
+ // Re-deliver the composited URL so pending state can't stick.
801
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
802
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
534
803
  }
535
804
  });
536
805
 
537
- // Pipeline-settled check
538
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
539
- hasBlobInFlightRef.current = false;
540
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
541
- }
542
- }, [rawMockupResults]);
806
+ deliverResults(displayable);
807
+ maybeFlipCrop();
808
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
543
809
 
544
810
  // Wrap sendCanvasBlob to track when blobs are sent
545
811
  const sendCanvasBlob = useCallback(
@@ -620,9 +886,12 @@ export function RealtimeProvider({
620
886
  lastSentBgRef.current = undefined;
621
887
  lastSentSpreadCropsRef.current = "null";
622
888
  lastSentColorsRef.current = {};
889
+ // A new session must re-seed backgrounds before cropped delivery.
890
+ invalidateBackgrounds();
891
+ lastConfigBaseRef.current = null;
623
892
  };
624
893
  }
625
- }, [isEnabled, connect, disconnect, wsUrl]);
894
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
626
895
 
627
896
  // Clear pending state when mockup results arrive
628
897
  useEffect(() => {
@@ -664,6 +933,14 @@ export function RealtimeProvider({
664
933
  return; // Nothing changed, skip
665
934
  }
666
935
 
936
+ // Variant/bg changes change the SCENE — every frozen cropped-delivery
937
+ // background is stale. Drop back to full-frame delivery; the flip re-arms
938
+ // once every mockup has re-seeded. (Spread-crop changes only remap
939
+ // artwork inside the product region — backgrounds stay valid.)
940
+ if (variantChanged || bgChanged) {
941
+ invalidateBackgrounds();
942
+ }
943
+
667
944
  // Get mockup IDs from product.mockups
668
945
  const mockupIds: string[] = [];
669
946
  if (product.mockups && Array.isArray(product.mockups)) {
@@ -710,7 +987,15 @@ export function RealtimeProvider({
710
987
  });
711
988
  }
712
989
 
713
- sendConfigRaw(configPayload);
990
+ // Remember the crop-less payload so delivery flips can re-send it, then
991
+ // send — preserving cropped delivery across re-sends that kept the scene
992
+ // (e.g. spread-crop changes).
993
+ lastConfigBaseRef.current = configPayload as unknown as Record<string, unknown>;
994
+ sendConfigRaw(
995
+ (croppedActiveRef.current
996
+ ? { ...configPayload, overlayCrop: "auto" as const }
997
+ : configPayload) as Parameters<typeof sendConfigRaw>[0]
998
+ );
714
999
 
715
1000
  configSentRef.current = true;
716
1001
  lastSentVariantIdRef.current = variantId;
@@ -735,6 +1020,7 @@ export function RealtimeProvider({
735
1020
  productContext?.selection,
736
1021
  sendConfigRaw,
737
1022
  sendColorBlobTracked,
1023
+ invalidateBackgrounds,
738
1024
  mockupWidth,
739
1025
  mockupBg,
740
1026
  spreadCropsVersion,
@@ -772,6 +1058,23 @@ export function RealtimeProvider({
772
1058
 
773
1059
  // Canvas integration uses @snowcone-app/canvas with sendCanvasBlob directly
774
1060
 
1061
+ // Guard cropped delivery on priority requests: a mockup that has never
1062
+ // rendered has no frozen background to composite onto, so drop the session
1063
+ // to full-frame first — its full render seeds the background and the flip
1064
+ // re-arms automatically once every requested mockup is seeded.
1065
+ const updateMockupIdsGuarded = useCallback(
1066
+ (mockupIds: string[]) => {
1067
+ if (
1068
+ croppedActiveRef.current &&
1069
+ mockupIds.some((id) => !backgroundsRef.current.has(id))
1070
+ ) {
1071
+ unflipCrop();
1072
+ }
1073
+ return updateMockupIdsRaw(mockupIds);
1074
+ },
1075
+ [updateMockupIdsRaw, unflipCrop]
1076
+ );
1077
+
775
1078
  // Wrap updatePlacementSettings to also track in ref (for config re-sends)
776
1079
  const updatePlacementSettingsWrapped = useCallback(
777
1080
  (settings: Record<string, any>) => {
@@ -1052,39 +1355,6 @@ export function RealtimeProvider({
1052
1355
  return pendingMockupIdsRef.current.has(mockupId);
1053
1356
  }, []);
1054
1357
 
1055
- /**
1056
- * Register a blob URL with lifecycle management
1057
- */
1058
- const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
1059
- let url: string;
1060
-
1061
- if (blobOrUrl instanceof Blob) {
1062
- // Enforce blob URL limit before creating new one
1063
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
1064
- // Revoke oldest blob URL (first entry in Set maintains insertion order)
1065
- const oldest = registeredBlobUrlsRef.current.values().next().value;
1066
- if (oldest) {
1067
- URL.revokeObjectURL(oldest);
1068
- registeredBlobUrlsRef.current.delete(oldest);
1069
-
1070
- // Also remove from URL cache if present
1071
- mockupUrlCacheRef.current.forEach((value, key) => {
1072
- if (value.url === oldest) {
1073
- mockupUrlCacheRef.current.delete(key);
1074
- }
1075
- });
1076
- }
1077
- }
1078
-
1079
- url = URL.createObjectURL(blobOrUrl);
1080
- registeredBlobUrlsRef.current.add(url);
1081
- } else {
1082
- url = blobOrUrl;
1083
- }
1084
-
1085
- return url;
1086
- }, []);
1087
-
1088
1358
  // Store subscription functions in refs so they remain stable across context recreations
1089
1359
  // This prevents race conditions where notifications are lost during re-subscription
1090
1360
  const subscribeMockupResultsRef = useRef(subscribeMockupResults);
@@ -1179,7 +1449,7 @@ export function RealtimeProvider({
1179
1449
  disableRealtime,
1180
1450
  sendCanvasBlob,
1181
1451
  sendCanvasState,
1182
- updateMockupIds: updateMockupIdsRaw,
1452
+ updateMockupIds: updateMockupIdsGuarded,
1183
1453
  updatePlacementSettings: updatePlacementSettingsWrapped,
1184
1454
  setSpreadCrops,
1185
1455
  getMockupResultsImmediate: () => mockupResultsRef.current,
@@ -1216,7 +1486,7 @@ export function RealtimeProvider({
1216
1486
  disableRealtime,
1217
1487
  sendCanvasBlob,
1218
1488
  sendCanvasState,
1219
- updateMockupIdsRaw,
1489
+ updateMockupIdsGuarded,
1220
1490
  updatePlacementSettingsWrapped,
1221
1491
  setSpreadCrops,
1222
1492
  stableSubscribeMockupResults,