@snowcone-app/ui 0.4.9 → 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 +7 -0
- package/dist/index.cjs +335 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -1
- package/dist/index.d.ts +33 -1
- package/dist/index.js +335 -95
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/HeroProductImage.tsx +39 -25
- package/src/composed/carousels/MobileProductCarousel.tsx +38 -0
- package/src/patterns/RealtimeProvider.tsx +341 -82
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +240 -0
- package/src/patterns/__tests__/croppedFrameComposite.test.ts +56 -0
- package/src/patterns/croppedFrameComposite.ts +122 -0
|
@@ -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 {
|
|
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,307 @@ 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
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
557
|
+
|
|
558
|
+
url = URL.createObjectURL(blobOrUrl);
|
|
559
|
+
registeredBlobUrlsRef.current.add(url);
|
|
560
|
+
} else {
|
|
561
|
+
url = blobOrUrl;
|
|
483
562
|
}
|
|
484
563
|
|
|
485
|
-
|
|
564
|
+
return url;
|
|
565
|
+
}, []);
|
|
486
566
|
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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:
|
|
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
|
-
|
|
656
|
+
pendingPlacementsRef.current.clear();
|
|
508
657
|
}
|
|
509
|
-
});
|
|
510
658
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
+
const displayable: SdkMockupResult[] = [];
|
|
527
760
|
rawMockupResults.forEach((result) => {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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 });
|
|
534
792
|
}
|
|
535
793
|
});
|
|
536
794
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
pipelineSettledSubscribersRef.current.forEach((cb) => cb());
|
|
541
|
-
}
|
|
542
|
-
}, [rawMockupResults]);
|
|
795
|
+
deliverResults(displayable);
|
|
796
|
+
maybeFlipCrop();
|
|
797
|
+
}, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
|
|
543
798
|
|
|
544
799
|
// Wrap sendCanvasBlob to track when blobs are sent
|
|
545
800
|
const sendCanvasBlob = useCallback(
|
|
@@ -620,9 +875,12 @@ export function RealtimeProvider({
|
|
|
620
875
|
lastSentBgRef.current = undefined;
|
|
621
876
|
lastSentSpreadCropsRef.current = "null";
|
|
622
877
|
lastSentColorsRef.current = {};
|
|
878
|
+
// A new session must re-seed backgrounds before cropped delivery.
|
|
879
|
+
invalidateBackgrounds();
|
|
880
|
+
lastConfigBaseRef.current = null;
|
|
623
881
|
};
|
|
624
882
|
}
|
|
625
|
-
}, [isEnabled, connect, disconnect, wsUrl]);
|
|
883
|
+
}, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
|
|
626
884
|
|
|
627
885
|
// Clear pending state when mockup results arrive
|
|
628
886
|
useEffect(() => {
|
|
@@ -664,6 +922,14 @@ export function RealtimeProvider({
|
|
|
664
922
|
return; // Nothing changed, skip
|
|
665
923
|
}
|
|
666
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
|
+
|
|
667
933
|
// Get mockup IDs from product.mockups
|
|
668
934
|
const mockupIds: string[] = [];
|
|
669
935
|
if (product.mockups && Array.isArray(product.mockups)) {
|
|
@@ -710,7 +976,15 @@ export function RealtimeProvider({
|
|
|
710
976
|
});
|
|
711
977
|
}
|
|
712
978
|
|
|
713
|
-
|
|
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
|
+
);
|
|
714
988
|
|
|
715
989
|
configSentRef.current = true;
|
|
716
990
|
lastSentVariantIdRef.current = variantId;
|
|
@@ -735,6 +1009,7 @@ export function RealtimeProvider({
|
|
|
735
1009
|
productContext?.selection,
|
|
736
1010
|
sendConfigRaw,
|
|
737
1011
|
sendColorBlobTracked,
|
|
1012
|
+
invalidateBackgrounds,
|
|
738
1013
|
mockupWidth,
|
|
739
1014
|
mockupBg,
|
|
740
1015
|
spreadCropsVersion,
|
|
@@ -772,6 +1047,23 @@ export function RealtimeProvider({
|
|
|
772
1047
|
|
|
773
1048
|
// Canvas integration uses @snowcone-app/canvas with sendCanvasBlob directly
|
|
774
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
|
+
|
|
775
1067
|
// Wrap updatePlacementSettings to also track in ref (for config re-sends)
|
|
776
1068
|
const updatePlacementSettingsWrapped = useCallback(
|
|
777
1069
|
(settings: Record<string, any>) => {
|
|
@@ -1052,39 +1344,6 @@ export function RealtimeProvider({
|
|
|
1052
1344
|
return pendingMockupIdsRef.current.has(mockupId);
|
|
1053
1345
|
}, []);
|
|
1054
1346
|
|
|
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
1347
|
// Store subscription functions in refs so they remain stable across context recreations
|
|
1089
1348
|
// This prevents race conditions where notifications are lost during re-subscription
|
|
1090
1349
|
const subscribeMockupResultsRef = useRef(subscribeMockupResults);
|
|
@@ -1179,7 +1438,7 @@ export function RealtimeProvider({
|
|
|
1179
1438
|
disableRealtime,
|
|
1180
1439
|
sendCanvasBlob,
|
|
1181
1440
|
sendCanvasState,
|
|
1182
|
-
updateMockupIds:
|
|
1441
|
+
updateMockupIds: updateMockupIdsGuarded,
|
|
1183
1442
|
updatePlacementSettings: updatePlacementSettingsWrapped,
|
|
1184
1443
|
setSpreadCrops,
|
|
1185
1444
|
getMockupResultsImmediate: () => mockupResultsRef.current,
|
|
@@ -1216,7 +1475,7 @@ export function RealtimeProvider({
|
|
|
1216
1475
|
disableRealtime,
|
|
1217
1476
|
sendCanvasBlob,
|
|
1218
1477
|
sendCanvasState,
|
|
1219
|
-
|
|
1478
|
+
updateMockupIdsGuarded,
|
|
1220
1479
|
updatePlacementSettingsWrapped,
|
|
1221
1480
|
setSpreadCrops,
|
|
1222
1481
|
stableSubscribeMockupResults,
|