@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 +16 -0
- package/dist/index.cjs +360 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.js +360 -97
- 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 +414 -83
- 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;
|
|
@@ -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
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
557
|
+
|
|
558
|
+
url = URL.createObjectURL(blobOrUrl);
|
|
559
|
+
registeredBlobUrlsRef.current.add(url);
|
|
560
|
+
} else {
|
|
561
|
+
url = blobOrUrl;
|
|
448
562
|
}
|
|
449
563
|
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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:
|
|
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
|
-
|
|
656
|
+
pendingPlacementsRef.current.clear();
|
|
473
657
|
}
|
|
474
|
-
});
|
|
475
658
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
+
const displayable: SdkMockupResult[] = [];
|
|
492
760
|
rawMockupResults.forEach((result) => {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1478
|
+
updateMockupIdsGuarded,
|
|
1149
1479
|
updatePlacementSettingsWrapped,
|
|
1480
|
+
setSpreadCrops,
|
|
1150
1481
|
stableSubscribeMockupResults,
|
|
1151
1482
|
stableSubscribeMockupResultById,
|
|
1152
1483
|
notifyPendingExport,
|