@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.
@@ -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;
@@ -127,6 +136,26 @@ export interface RealtimeContextValue {
127
136
  */
128
137
  updatePlacementSettings: (settings: Record<string, { scaleMode?: 'fill' | 'fit'; tiles?: number; alignment?: string }>) => boolean;
129
138
 
139
+ /**
140
+ * Declare spread-mode crop geometry (ADR-0060) for canvases that run
141
+ * combined spread/group artboards: artboard name → artboard dims + piece
142
+ * rects keyed by the product's placement labels. Sent on the realtime
143
+ * config (re-sent when it changes) so the server can slice each rendered
144
+ * spread canvas_state into per-placement images. Pass undefined to clear.
145
+ */
146
+ setSpreadCrops: (
147
+ crops:
148
+ | Record<
149
+ string,
150
+ {
151
+ width: number;
152
+ height: number;
153
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
154
+ }
155
+ >
156
+ | undefined
157
+ ) => void;
158
+
130
159
  // PERFORMANCE: Direct access without state updates
131
160
  getMockupResultsImmediate: () => MockupResult[];
132
161
 
@@ -256,6 +285,18 @@ export interface RealtimeProviderProps {
256
285
  * via a renewable token. Without them, the legacy seal path runs (shim).
257
286
  */
258
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";
259
300
  }
260
301
 
261
302
  /**
@@ -272,6 +313,7 @@ export function RealtimeProvider({
272
313
  autoEnable = false,
273
314
  shop,
274
315
  grantUrl,
316
+ delivery = "cropped",
275
317
  }: RealtimeProviderProps) {
276
318
  const productContext = useProductOptional();
277
319
  // Page background for renders (see MockupBackground) — sent on the config so
@@ -310,6 +352,21 @@ export function RealtimeProvider({
310
352
  // Track last sent variantId to detect when we need to re-send config
311
353
  const lastSentVariantIdRef = useRef<string | null>(null);
312
354
  const lastSentBgRef = useRef<string | undefined>(undefined);
355
+ // Spread crop geometry (ADR-0060) declared via setSpreadCrops. Ref holds
356
+ // the payload; the version state re-runs the config effect on change.
357
+ const spreadCropsRef = useRef<
358
+ | Record<
359
+ string,
360
+ {
361
+ width: number;
362
+ height: number;
363
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
364
+ }
365
+ >
366
+ | undefined
367
+ >(undefined);
368
+ const [spreadCropsVersion, setSpreadCropsVersion] = useState(0);
369
+ const lastSentSpreadCropsRef = useRef<string>("null");
313
370
 
314
371
  // Track last sent colors to prevent duplicates
315
372
  const lastSentColorsRef = useRef<Record<string, string>>({});
@@ -437,74 +494,307 @@ export function RealtimeProvider({
437
494
  // Track which mockupIds are pending (derived from placements)
438
495
  const pendingMockupIdsRef = useRef<Set<string>>(new Set());
439
496
 
440
- // Sync rawMockupResults to ref, update URL cache, and notify subscribers (NO STATE UPDATES)
441
- useEffect(() => {
442
- if (rawMockupResults.length === 0) {
443
- if (mockupResultsRef.current.length > 0) {
444
- mockupResultsRef.current = [];
445
- 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
+ }
446
556
  }
447
- return;
557
+
558
+ url = URL.createObjectURL(blobOrUrl);
559
+ registeredBlobUrlsRef.current.add(url);
560
+ } else {
561
+ url = blobOrUrl;
448
562
  }
449
563
 
450
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}`));
564
+ return url;
565
+ }, []);
566
+
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
+ );
451
583
 
452
- // Update ref immediately
453
- mockupResultsRef.current = rawMockupResults;
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
+ }, []);
454
617
 
455
- // Update centralized URL cache with new results
456
- const now = Date.now();
457
- rawMockupResults.forEach((result) => {
458
- if (result.imageUrl) {
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) => {
459
638
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
460
639
  mockupUrlCacheRef.current.set(result.mockupId, {
461
640
  url: result.imageUrl,
462
- isBlob: false, // Server URLs are not blob URLs
641
+ isBlob: result.imageUrl.startsWith("blob:"),
463
642
  timestamp: now,
464
643
  });
465
-
466
- // Clear pending state for this mockup
467
644
  pendingMockupIdsRef.current.delete(result.mockupId);
468
645
  if (wasPending) {
469
646
  console.log(`[RTP] Cleared pending for ${result.mockupId} — url cached`);
470
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));
471
655
  } else {
472
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
656
+ pendingPlacementsRef.current.clear();
473
657
  }
474
- });
475
658
 
476
- // Clear pending placement state so notifyPendingExport can re-fire on next edit.
477
- // We clear based on result placements when available, otherwise clear all.
478
- const clearedPlacements = new Set<string>();
479
- rawMockupResults.forEach((result) => {
480
- if (result.placement) clearedPlacements.add(result.placement);
481
- });
482
- if (clearedPlacements.size > 0) {
483
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
484
- } else {
485
- 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;
486
755
  }
487
756
 
488
- // Notify subscribers immediately (no throttle needed - no state updates!)
489
- 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]' : ''}`));
490
758
 
491
- // Notify filtered subscribers
759
+ const displayable: SdkMockupResult[] = [];
492
760
  rawMockupResults.forEach((result) => {
493
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
494
- if (subscribers && subscribers.size > 0) {
495
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
496
- subscribers.forEach((cb) => cb(result));
497
- } else {
498
- 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.
777
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl });
778
+ }
779
+ // Full frames re-deliver on every run (matches pre-crop behavior —
780
+ // consumers dedupe by URL; delivery also clears pending state).
781
+ displayable.push(result);
782
+ } else if (firstSight && sessionLive) {
783
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
784
+ compositeQueueRef.current.set(result.mockupId, result);
785
+ pumpCompositeQueue(result.mockupId);
786
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
787
+ // Already-composited frame re-observed while the mockup is marked
788
+ // pending (e.g. a render that resolved to the identical frame).
789
+ // Re-deliver the composited URL so pending state can't stick.
790
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
791
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
499
792
  }
500
793
  });
501
794
 
502
- // Pipeline-settled check
503
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
504
- hasBlobInFlightRef.current = false;
505
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
506
- }
507
- }, [rawMockupResults]);
795
+ deliverResults(displayable);
796
+ maybeFlipCrop();
797
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
508
798
 
509
799
  // Wrap sendCanvasBlob to track when blobs are sent
510
800
  const sendCanvasBlob = useCallback(
@@ -583,10 +873,14 @@ export function RealtimeProvider({
583
873
  configSentRef.current = false;
584
874
  lastSentVariantIdRef.current = null;
585
875
  lastSentBgRef.current = undefined;
876
+ lastSentSpreadCropsRef.current = "null";
586
877
  lastSentColorsRef.current = {};
878
+ // A new session must re-seed backgrounds before cropped delivery.
879
+ invalidateBackgrounds();
880
+ lastConfigBaseRef.current = null;
587
881
  };
588
882
  }
589
- }, [isEnabled, connect, disconnect, wsUrl]);
883
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
590
884
 
591
885
  // Clear pending state when mockup results arrive
592
886
  useEffect(() => {
@@ -619,11 +913,23 @@ export function RealtimeProvider({
619
913
  lastSentVariantIdRef.current !== variantId;
620
914
  // 3. Background changed (theme flip swaps the MockupBackground hex)
621
915
  const bgChanged = configSentRef.current && lastSentBgRef.current !== mockupBg;
916
+ // 4. Spread crop geometry changed (spread-mode editor mounted/unmounted)
917
+ const spreadCropsJson = JSON.stringify(spreadCropsRef.current ?? null);
918
+ const spreadCropsChanged =
919
+ configSentRef.current && lastSentSpreadCropsRef.current !== spreadCropsJson;
622
920
 
623
- if (!isFirstSend && !variantChanged && !bgChanged) {
921
+ if (!isFirstSend && !variantChanged && !bgChanged && !spreadCropsChanged) {
624
922
  return; // Nothing changed, skip
625
923
  }
626
924
 
925
+ // Variant/bg changes change the SCENE — every frozen cropped-delivery
926
+ // background is stale. Drop back to full-frame delivery; the flip re-arms
927
+ // once every mockup has re-seeded. (Spread-crop changes only remap
928
+ // artwork inside the product region — backgrounds stay valid.)
929
+ if (variantChanged || bgChanged) {
930
+ invalidateBackgrounds();
931
+ }
932
+
627
933
  // Get mockup IDs from product.mockups
628
934
  const mockupIds: string[] = [];
629
935
  if (product.mockups && Array.isArray(product.mockups)) {
@@ -646,6 +952,7 @@ export function RealtimeProvider({
646
952
  shop: shop as string,
647
953
  width: mockupWidth,
648
954
  ...(mockupBg ? { bg: mockupBg } : {}),
955
+ ...(spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {}),
649
956
  placementSettings: { ...placementSettingsOverridesRef.current },
650
957
  }
651
958
  : {
@@ -656,6 +963,7 @@ export function RealtimeProvider({
656
963
  seal: "bypass-sig-for-k6-load-test",
657
964
  width: mockupWidth,
658
965
  ...(mockupBg ? { bg: mockupBg } : {}),
966
+ ...(spreadCropsRef.current ? { spreadCrops: spreadCropsRef.current } : {}),
659
967
  placementSettings: { ...placementSettingsOverridesRef.current },
660
968
  };
661
969
 
@@ -668,11 +976,20 @@ export function RealtimeProvider({
668
976
  });
669
977
  }
670
978
 
671
- sendConfigRaw(configPayload);
979
+ // Remember the crop-less payload so delivery flips can re-send it, then
980
+ // send — preserving cropped delivery across re-sends that kept the scene
981
+ // (e.g. spread-crop changes).
982
+ lastConfigBaseRef.current = configPayload as unknown as Record<string, unknown>;
983
+ sendConfigRaw(
984
+ (croppedActiveRef.current
985
+ ? { ...configPayload, overlayCrop: "auto" as const }
986
+ : configPayload) as Parameters<typeof sendConfigRaw>[0]
987
+ );
672
988
 
673
989
  configSentRef.current = true;
674
990
  lastSentVariantIdRef.current = variantId;
675
991
  lastSentBgRef.current = mockupBg;
992
+ lastSentSpreadCropsRef.current = spreadCropsJson;
676
993
 
677
994
  // Send default colors for color placements
678
995
  setTimeout(() => {
@@ -692,8 +1009,10 @@ export function RealtimeProvider({
692
1009
  productContext?.selection,
693
1010
  sendConfigRaw,
694
1011
  sendColorBlobTracked,
1012
+ invalidateBackgrounds,
695
1013
  mockupWidth,
696
1014
  mockupBg,
1015
+ spreadCropsVersion,
697
1016
  ]);
698
1017
 
699
1018
  // Watch for color placement changes
@@ -728,6 +1047,23 @@ export function RealtimeProvider({
728
1047
 
729
1048
  // Canvas integration uses @snowcone-app/canvas with sendCanvasBlob directly
730
1049
 
1050
+ // Guard cropped delivery on priority requests: a mockup that has never
1051
+ // rendered has no frozen background to composite onto, so drop the session
1052
+ // to full-frame first — its full render seeds the background and the flip
1053
+ // re-arms automatically once every requested mockup is seeded.
1054
+ const updateMockupIdsGuarded = useCallback(
1055
+ (mockupIds: string[]) => {
1056
+ if (
1057
+ croppedActiveRef.current &&
1058
+ mockupIds.some((id) => !backgroundsRef.current.has(id))
1059
+ ) {
1060
+ unflipCrop();
1061
+ }
1062
+ return updateMockupIdsRaw(mockupIds);
1063
+ },
1064
+ [updateMockupIdsRaw, unflipCrop]
1065
+ );
1066
+
731
1067
  // Wrap updatePlacementSettings to also track in ref (for config re-sends)
732
1068
  const updatePlacementSettingsWrapped = useCallback(
733
1069
  (settings: Record<string, any>) => {
@@ -737,6 +1073,32 @@ export function RealtimeProvider({
737
1073
  [updatePlacementSettingsRaw]
738
1074
  );
739
1075
 
1076
+ // Declare spread crop geometry (ADR-0060). Stored in a ref; bumping the
1077
+ // version state re-runs the config effect, which re-sends the config when
1078
+ // the serialized geometry actually changed.
1079
+ const setSpreadCrops = useCallback(
1080
+ (
1081
+ crops:
1082
+ | Record<
1083
+ string,
1084
+ {
1085
+ width: number;
1086
+ height: number;
1087
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
1088
+ }
1089
+ >
1090
+ | undefined
1091
+ ) => {
1092
+ const next = crops && Object.keys(crops).length > 0 ? crops : undefined;
1093
+ if (JSON.stringify(next ?? null) === JSON.stringify(spreadCropsRef.current ?? null)) {
1094
+ return;
1095
+ }
1096
+ spreadCropsRef.current = next;
1097
+ setSpreadCropsVersion((v) => v + 1);
1098
+ },
1099
+ []
1100
+ );
1101
+
740
1102
  const enableRealtime = useCallback(() => {
741
1103
  setIsEnabled(true);
742
1104
  }, []);
@@ -982,39 +1344,6 @@ export function RealtimeProvider({
982
1344
  return pendingMockupIdsRef.current.has(mockupId);
983
1345
  }, []);
984
1346
 
985
- /**
986
- * Register a blob URL with lifecycle management
987
- */
988
- const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
989
- let url: string;
990
-
991
- if (blobOrUrl instanceof Blob) {
992
- // Enforce blob URL limit before creating new one
993
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
994
- // Revoke oldest blob URL (first entry in Set maintains insertion order)
995
- const oldest = registeredBlobUrlsRef.current.values().next().value;
996
- if (oldest) {
997
- URL.revokeObjectURL(oldest);
998
- registeredBlobUrlsRef.current.delete(oldest);
999
-
1000
- // Also remove from URL cache if present
1001
- mockupUrlCacheRef.current.forEach((value, key) => {
1002
- if (value.url === oldest) {
1003
- mockupUrlCacheRef.current.delete(key);
1004
- }
1005
- });
1006
- }
1007
- }
1008
-
1009
- url = URL.createObjectURL(blobOrUrl);
1010
- registeredBlobUrlsRef.current.add(url);
1011
- } else {
1012
- url = blobOrUrl;
1013
- }
1014
-
1015
- return url;
1016
- }, []);
1017
-
1018
1347
  // Store subscription functions in refs so they remain stable across context recreations
1019
1348
  // This prevents race conditions where notifications are lost during re-subscription
1020
1349
  const subscribeMockupResultsRef = useRef(subscribeMockupResults);
@@ -1109,8 +1438,9 @@ export function RealtimeProvider({
1109
1438
  disableRealtime,
1110
1439
  sendCanvasBlob,
1111
1440
  sendCanvasState,
1112
- updateMockupIds: updateMockupIdsRaw,
1441
+ updateMockupIds: updateMockupIdsGuarded,
1113
1442
  updatePlacementSettings: updatePlacementSettingsWrapped,
1443
+ setSpreadCrops,
1114
1444
  getMockupResultsImmediate: () => mockupResultsRef.current,
1115
1445
  subscribeMockupResults: stableSubscribeMockupResults,
1116
1446
  subscribeMockupResultById: stableSubscribeMockupResultById,
@@ -1145,8 +1475,9 @@ export function RealtimeProvider({
1145
1475
  disableRealtime,
1146
1476
  sendCanvasBlob,
1147
1477
  sendCanvasState,
1148
- updateMockupIdsRaw,
1478
+ updateMockupIdsGuarded,
1149
1479
  updatePlacementSettingsWrapped,
1480
+ setSpreadCrops,
1150
1481
  stableSubscribeMockupResults,
1151
1482
  stableSubscribeMockupResultById,
1152
1483
  notifyPendingExport,