@snowcone-app/ui 0.4.14 → 0.8.0
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 +41 -0
- package/dist/index.cjs +143 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -4
- package/dist/index.d.ts +59 -4
- package/dist/index.js +143 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CanvasIsolationBoundary.tsx +6 -0
- package/src/composed/HeroProductImage.tsx +15 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +2 -1
- package/src/ecommerce/stories/PDPLayout.stories.tsx +2 -1
- package/src/patterns/RealtimeProvider.tsx +84 -3
- package/src/primitives/stories/Accordion.stories.tsx +2 -2
- package/src/services/CanvasExportService.ts +96 -50
- package/src/services/__tests__/CanvasExportService.leadingEdge.test.ts +93 -21
package/dist/index.d.cts
CHANGED
|
@@ -2894,6 +2894,13 @@ interface RealtimeContextValue {
|
|
|
2894
2894
|
* than blanking it or logging console-only.
|
|
2895
2895
|
*/
|
|
2896
2896
|
renderError: RealtimeRenderError | null;
|
|
2897
|
+
/**
|
|
2898
|
+
* Mockups the server refused for this session (`E_MOCKUP_NOT_BLESSED`: the
|
|
2899
|
+
* mockup's scene failed render parity and is served on no path). Keyed by
|
|
2900
|
+
* mockupId; the other mockups keep streaming. Render these as unavailable
|
|
2901
|
+
* — never as a stale or blank image.
|
|
2902
|
+
*/
|
|
2903
|
+
unavailableMockups: ReadonlyMap<string, RealtimeRenderError>;
|
|
2897
2904
|
isPendingMockups: boolean;
|
|
2898
2905
|
canvasBlobsSent: number;
|
|
2899
2906
|
colorBlobsSent: number;
|
|
@@ -3030,6 +3037,16 @@ interface RealtimeProviderProps {
|
|
|
3030
3037
|
* via a renewable token. Without them, the legacy seal path runs (shim).
|
|
3031
3038
|
*/
|
|
3032
3039
|
grantUrl?: string;
|
|
3040
|
+
/**
|
|
3041
|
+
* A grant minted server-side while the page rendered (POST grantUrl during
|
|
3042
|
+
* SSR). Used for the FIRST connection so the socket opens without a
|
|
3043
|
+
* grant round trip (382–566 ms on the PDP, 2026-09-05); renewals and
|
|
3044
|
+
* reconnects go through `grantUrl`. Ignored when already expired.
|
|
3045
|
+
*/
|
|
3046
|
+
initialGrant?: {
|
|
3047
|
+
token: string;
|
|
3048
|
+
expiresAt: number;
|
|
3049
|
+
};
|
|
3033
3050
|
/**
|
|
3034
3051
|
* Frame delivery strategy. `"cropped"` (default, the fast path): once every
|
|
3035
3052
|
* configured mockup has rendered one full frame (frozen internally as its
|
|
@@ -3048,7 +3065,7 @@ interface RealtimeProviderProps {
|
|
|
3048
3065
|
*
|
|
3049
3066
|
* Must be used inside a <Product> provider to access product data.
|
|
3050
3067
|
*/
|
|
3051
|
-
declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
|
|
3068
|
+
declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, initialGrant, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
|
|
3052
3069
|
/**
|
|
3053
3070
|
* Hook to access realtime context
|
|
3054
3071
|
* Throws if not inside RealtimeProvider
|
|
@@ -3625,6 +3642,21 @@ interface CanvasExportServiceConfig {
|
|
|
3625
3642
|
enableRealtime?: () => void;
|
|
3626
3643
|
/** Disable realtime function (called when exiting editor mode) */
|
|
3627
3644
|
disableRealtime?: () => void;
|
|
3645
|
+
/**
|
|
3646
|
+
* Subscribe to "the frame for the last send landed" (RealtimeProvider's
|
|
3647
|
+
* pipeline-settled signal). Drives the one-in-flight send policy: a state
|
|
3648
|
+
* held while a frame is in flight goes out the moment this fires.
|
|
3649
|
+
*/
|
|
3650
|
+
subscribePipelineSettled?: (callback: () => void) => () => void;
|
|
3651
|
+
/**
|
|
3652
|
+
* Pipeline depth: edits allowed in flight before the latest state is held
|
|
3653
|
+
* (default 2). The box's window is ~30 ms of a ~96 ms edit→frame at w4000 —
|
|
3654
|
+
* the rest is wire and decode — so with one in flight the box idles ~70% of
|
|
3655
|
+
* every cycle. Two keeps it busy and never queues on the box while its
|
|
3656
|
+
* window stays under half the round trip (the lab's rt_coalesce says when
|
|
3657
|
+
* it does not).
|
|
3658
|
+
*/
|
|
3659
|
+
maxInFlight?: number;
|
|
3628
3660
|
/**
|
|
3629
3661
|
* Function to clear ImageBitmap cache (memory management for iOS Safari)
|
|
3630
3662
|
* Pass clearImageBitmapCache from @snowcone-app/canvas
|
|
@@ -3655,9 +3687,17 @@ declare class CanvasExportService {
|
|
|
3655
3687
|
private jsonModeSentSuccessfully;
|
|
3656
3688
|
private stateChangeThrottleTimeout;
|
|
3657
3689
|
private stateChangeLastSendTime;
|
|
3658
|
-
/**
|
|
3659
|
-
private
|
|
3660
|
-
|
|
3690
|
+
/** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
|
|
3691
|
+
private inFlightSentAt;
|
|
3692
|
+
private maxInFlight;
|
|
3693
|
+
private subscribePipelineSettled;
|
|
3694
|
+
private unsubscribePipelineSettled;
|
|
3695
|
+
/**
|
|
3696
|
+
* Safety net for the one-in-flight policy: a held state never waits longer
|
|
3697
|
+
* than this for the in-flight frame (a swallowed render error would
|
|
3698
|
+
* otherwise hold the pipeline forever), and a change arriving while a
|
|
3699
|
+
* frame has been in flight this long is sent regardless.
|
|
3700
|
+
*/
|
|
3661
3701
|
private static MAX_WAIT_MS;
|
|
3662
3702
|
private lastExportedArtwork;
|
|
3663
3703
|
private initialArtwork;
|
|
@@ -3728,6 +3768,19 @@ declare class CanvasExportService {
|
|
|
3728
3768
|
*/
|
|
3729
3769
|
private stateChangeCallCount;
|
|
3730
3770
|
handleStateChange: (state: any) => void;
|
|
3771
|
+
/** A send landed (every mockup of it): free its slot, release the held state if any. */
|
|
3772
|
+
private onPipelineSettled;
|
|
3773
|
+
/**
|
|
3774
|
+
* The pointer was released (drag end, slider release, key up). Send the held
|
|
3775
|
+
* state NOW instead of waiting for the in-flight frame: the server coalesces
|
|
3776
|
+
* (latest wins), and the "let go" frame is the one the shopper waits for.
|
|
3777
|
+
* A no-op when nothing is held, so it is safe on every pointerup.
|
|
3778
|
+
*/
|
|
3779
|
+
handleInteractionEnd: () => void;
|
|
3780
|
+
private armFlushTimer;
|
|
3781
|
+
/** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
|
|
3782
|
+
private dropStalledSends;
|
|
3783
|
+
private clearFlushTimer;
|
|
3731
3784
|
private sendStateNow;
|
|
3732
3785
|
/**
|
|
3733
3786
|
* Reset the service state (e.g., when unmounting)
|
|
@@ -3791,6 +3844,8 @@ declare function useIsolatedCanvas(): {
|
|
|
3791
3844
|
handleExport: (exports: Record<string, string | Blob>) => Promise<void>;
|
|
3792
3845
|
/** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
|
|
3793
3846
|
handleStateChange: (state: any) => void;
|
|
3847
|
+
/** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
|
|
3848
|
+
handleInteractionEnd: () => void;
|
|
3794
3849
|
/** Call when entering editor mode */
|
|
3795
3850
|
onEnterEditorMode: () => void;
|
|
3796
3851
|
/** Call when exiting editor mode */
|
package/dist/index.d.ts
CHANGED
|
@@ -2894,6 +2894,13 @@ interface RealtimeContextValue {
|
|
|
2894
2894
|
* than blanking it or logging console-only.
|
|
2895
2895
|
*/
|
|
2896
2896
|
renderError: RealtimeRenderError | null;
|
|
2897
|
+
/**
|
|
2898
|
+
* Mockups the server refused for this session (`E_MOCKUP_NOT_BLESSED`: the
|
|
2899
|
+
* mockup's scene failed render parity and is served on no path). Keyed by
|
|
2900
|
+
* mockupId; the other mockups keep streaming. Render these as unavailable
|
|
2901
|
+
* — never as a stale or blank image.
|
|
2902
|
+
*/
|
|
2903
|
+
unavailableMockups: ReadonlyMap<string, RealtimeRenderError>;
|
|
2897
2904
|
isPendingMockups: boolean;
|
|
2898
2905
|
canvasBlobsSent: number;
|
|
2899
2906
|
colorBlobsSent: number;
|
|
@@ -3030,6 +3037,16 @@ interface RealtimeProviderProps {
|
|
|
3030
3037
|
* via a renewable token. Without them, the legacy seal path runs (shim).
|
|
3031
3038
|
*/
|
|
3032
3039
|
grantUrl?: string;
|
|
3040
|
+
/**
|
|
3041
|
+
* A grant minted server-side while the page rendered (POST grantUrl during
|
|
3042
|
+
* SSR). Used for the FIRST connection so the socket opens without a
|
|
3043
|
+
* grant round trip (382–566 ms on the PDP, 2026-09-05); renewals and
|
|
3044
|
+
* reconnects go through `grantUrl`. Ignored when already expired.
|
|
3045
|
+
*/
|
|
3046
|
+
initialGrant?: {
|
|
3047
|
+
token: string;
|
|
3048
|
+
expiresAt: number;
|
|
3049
|
+
};
|
|
3033
3050
|
/**
|
|
3034
3051
|
* Frame delivery strategy. `"cropped"` (default, the fast path): once every
|
|
3035
3052
|
* configured mockup has rendered one full frame (frozen internally as its
|
|
@@ -3048,7 +3065,7 @@ interface RealtimeProviderProps {
|
|
|
3048
3065
|
*
|
|
3049
3066
|
* Must be used inside a <Product> provider to access product data.
|
|
3050
3067
|
*/
|
|
3051
|
-
declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
|
|
3068
|
+
declare function RealtimeProvider({ children, wsUrl, mockupWidth, autoEnable, shop, grantUrl, initialGrant, delivery, }: RealtimeProviderProps): react_jsx_runtime.JSX.Element;
|
|
3052
3069
|
/**
|
|
3053
3070
|
* Hook to access realtime context
|
|
3054
3071
|
* Throws if not inside RealtimeProvider
|
|
@@ -3625,6 +3642,21 @@ interface CanvasExportServiceConfig {
|
|
|
3625
3642
|
enableRealtime?: () => void;
|
|
3626
3643
|
/** Disable realtime function (called when exiting editor mode) */
|
|
3627
3644
|
disableRealtime?: () => void;
|
|
3645
|
+
/**
|
|
3646
|
+
* Subscribe to "the frame for the last send landed" (RealtimeProvider's
|
|
3647
|
+
* pipeline-settled signal). Drives the one-in-flight send policy: a state
|
|
3648
|
+
* held while a frame is in flight goes out the moment this fires.
|
|
3649
|
+
*/
|
|
3650
|
+
subscribePipelineSettled?: (callback: () => void) => () => void;
|
|
3651
|
+
/**
|
|
3652
|
+
* Pipeline depth: edits allowed in flight before the latest state is held
|
|
3653
|
+
* (default 2). The box's window is ~30 ms of a ~96 ms edit→frame at w4000 —
|
|
3654
|
+
* the rest is wire and decode — so with one in flight the box idles ~70% of
|
|
3655
|
+
* every cycle. Two keeps it busy and never queues on the box while its
|
|
3656
|
+
* window stays under half the round trip (the lab's rt_coalesce says when
|
|
3657
|
+
* it does not).
|
|
3658
|
+
*/
|
|
3659
|
+
maxInFlight?: number;
|
|
3628
3660
|
/**
|
|
3629
3661
|
* Function to clear ImageBitmap cache (memory management for iOS Safari)
|
|
3630
3662
|
* Pass clearImageBitmapCache from @snowcone-app/canvas
|
|
@@ -3655,9 +3687,17 @@ declare class CanvasExportService {
|
|
|
3655
3687
|
private jsonModeSentSuccessfully;
|
|
3656
3688
|
private stateChangeThrottleTimeout;
|
|
3657
3689
|
private stateChangeLastSendTime;
|
|
3658
|
-
/**
|
|
3659
|
-
private
|
|
3660
|
-
|
|
3690
|
+
/** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
|
|
3691
|
+
private inFlightSentAt;
|
|
3692
|
+
private maxInFlight;
|
|
3693
|
+
private subscribePipelineSettled;
|
|
3694
|
+
private unsubscribePipelineSettled;
|
|
3695
|
+
/**
|
|
3696
|
+
* Safety net for the one-in-flight policy: a held state never waits longer
|
|
3697
|
+
* than this for the in-flight frame (a swallowed render error would
|
|
3698
|
+
* otherwise hold the pipeline forever), and a change arriving while a
|
|
3699
|
+
* frame has been in flight this long is sent regardless.
|
|
3700
|
+
*/
|
|
3661
3701
|
private static MAX_WAIT_MS;
|
|
3662
3702
|
private lastExportedArtwork;
|
|
3663
3703
|
private initialArtwork;
|
|
@@ -3728,6 +3768,19 @@ declare class CanvasExportService {
|
|
|
3728
3768
|
*/
|
|
3729
3769
|
private stateChangeCallCount;
|
|
3730
3770
|
handleStateChange: (state: any) => void;
|
|
3771
|
+
/** A send landed (every mockup of it): free its slot, release the held state if any. */
|
|
3772
|
+
private onPipelineSettled;
|
|
3773
|
+
/**
|
|
3774
|
+
* The pointer was released (drag end, slider release, key up). Send the held
|
|
3775
|
+
* state NOW instead of waiting for the in-flight frame: the server coalesces
|
|
3776
|
+
* (latest wins), and the "let go" frame is the one the shopper waits for.
|
|
3777
|
+
* A no-op when nothing is held, so it is safe on every pointerup.
|
|
3778
|
+
*/
|
|
3779
|
+
handleInteractionEnd: () => void;
|
|
3780
|
+
private armFlushTimer;
|
|
3781
|
+
/** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
|
|
3782
|
+
private dropStalledSends;
|
|
3783
|
+
private clearFlushTimer;
|
|
3731
3784
|
private sendStateNow;
|
|
3732
3785
|
/**
|
|
3733
3786
|
* Reset the service state (e.g., when unmounting)
|
|
@@ -3791,6 +3844,8 @@ declare function useIsolatedCanvas(): {
|
|
|
3791
3844
|
handleExport: (exports: Record<string, string | Blob>) => Promise<void>;
|
|
3792
3845
|
/** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
|
|
3793
3846
|
handleStateChange: (state: any) => void;
|
|
3847
|
+
/** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
|
|
3848
|
+
handleInteractionEnd: () => void;
|
|
3794
3849
|
/** Call when entering editor mode */
|
|
3795
3850
|
onEnterEditorMode: () => void;
|
|
3796
3851
|
/** Call when exiting editor mode */
|
package/dist/index.js
CHANGED
|
@@ -39,7 +39,7 @@ var brand_default = {
|
|
|
39
39
|
display_name: "Snowcone",
|
|
40
40
|
tagline: "Your art, on everything.",
|
|
41
41
|
seo_title: "Snowcone \u2014 Your art, on everything.",
|
|
42
|
-
legal_entity: "
|
|
42
|
+
legal_entity: "Art Shop AI LLC",
|
|
43
43
|
lang: "en",
|
|
44
44
|
wordmark: "/assets/wordmark-en.svg"
|
|
45
45
|
},
|
|
@@ -49,7 +49,7 @@ var brand_default = {
|
|
|
49
49
|
display_name: "\u96EA\u7ED8 (Snowcone)",
|
|
50
50
|
tagline: "\u5728\u96EA\u767D\u4E4B\u4E0A\uFF0C\u7ED8\u51FA\u4F60\u7684\u8272\u5F69\u3002",
|
|
51
51
|
seo_title: "\u96EA\u7ED8 (Snowcone) \u2014 \u5728\u96EA\u767D\u4E4B\u4E0A\uFF0C\u7ED8\u51FA\u4F60\u7684\u8272\u5F69",
|
|
52
|
-
legal_entity: "
|
|
52
|
+
legal_entity: "Art Shop AI LLC",
|
|
53
53
|
lang: "zh",
|
|
54
54
|
wordmark: "/assets/wordmark-zh.svg"
|
|
55
55
|
},
|
|
@@ -59,7 +59,7 @@ var brand_default = {
|
|
|
59
59
|
display_name: "Snowcone",
|
|
60
60
|
tagline: "Tu arte, en todo.",
|
|
61
61
|
seo_title: "Snowcone \u2014 Tu arte, en todo.",
|
|
62
|
-
legal_entity: "
|
|
62
|
+
legal_entity: "Art Shop AI LLC",
|
|
63
63
|
lang: "es",
|
|
64
64
|
wordmark: "/assets/wordmark-en.svg"
|
|
65
65
|
},
|
|
@@ -69,7 +69,7 @@ var brand_default = {
|
|
|
69
69
|
display_name: "Snowcone",
|
|
70
70
|
tagline: "Votre art, sur tout.",
|
|
71
71
|
seo_title: "Snowcone \u2014 Votre art, sur tout.",
|
|
72
|
-
legal_entity: "
|
|
72
|
+
legal_entity: "Art Shop AI LLC",
|
|
73
73
|
lang: "fr",
|
|
74
74
|
wordmark: "/assets/wordmark-en.svg"
|
|
75
75
|
}
|
|
@@ -5648,13 +5648,18 @@ function RealtimeProvider({
|
|
|
5648
5648
|
autoEnable = false,
|
|
5649
5649
|
shop,
|
|
5650
5650
|
grantUrl,
|
|
5651
|
+
initialGrant,
|
|
5651
5652
|
delivery = "cropped"
|
|
5652
5653
|
}) {
|
|
5653
5654
|
const productContext = useProductOptional();
|
|
5654
5655
|
const mockupBg = useMockupBackgroundOptional();
|
|
5655
5656
|
const [isEnabled, setIsEnabled] = useState17(autoEnable);
|
|
5656
5657
|
const useSessionGrant = Boolean(shop && grantUrl);
|
|
5658
|
+
const initialGrantRef = useRef12(initialGrant);
|
|
5657
5659
|
const getToken = useCallback10(async () => {
|
|
5660
|
+
const minted = initialGrantRef.current;
|
|
5661
|
+
initialGrantRef.current = void 0;
|
|
5662
|
+
if (minted && minted.expiresAt * 1e3 - Date.now() > 3e4) return minted;
|
|
5658
5663
|
const res = await fetch(grantUrl, {
|
|
5659
5664
|
method: "POST",
|
|
5660
5665
|
headers: { "Content-Type": "application/json" },
|
|
@@ -5677,6 +5682,9 @@ function RealtimeProvider({
|
|
|
5677
5682
|
const [spreadCropsVersion, setSpreadCropsVersion] = useState17(0);
|
|
5678
5683
|
const lastSentSpreadCropsRef = useRef12("null");
|
|
5679
5684
|
const lastSentColorsRef = useRef12({});
|
|
5685
|
+
const [unavailableMockups, setUnavailableMockups] = useState17(
|
|
5686
|
+
() => /* @__PURE__ */ new Map()
|
|
5687
|
+
);
|
|
5680
5688
|
const realtimeMockup = useRealtimeMockup2({
|
|
5681
5689
|
wsUrl,
|
|
5682
5690
|
getToken: useSessionGrant ? getToken : void 0,
|
|
@@ -5706,6 +5714,10 @@ function RealtimeProvider({
|
|
|
5706
5714
|
},
|
|
5707
5715
|
onError: (error, detail) => {
|
|
5708
5716
|
console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
|
|
5717
|
+
if (detail.mockupId) {
|
|
5718
|
+
const id = detail.mockupId;
|
|
5719
|
+
setUnavailableMockups((prev) => prev.get(id)?.code === detail.code ? prev : new Map(prev).set(id, detail));
|
|
5720
|
+
}
|
|
5709
5721
|
}
|
|
5710
5722
|
});
|
|
5711
5723
|
const subscribeRTCTiming = useCallback10((_callback) => {
|
|
@@ -5726,7 +5738,8 @@ function RealtimeProvider({
|
|
|
5726
5738
|
connect,
|
|
5727
5739
|
disconnect,
|
|
5728
5740
|
flushPendingBlobs,
|
|
5729
|
-
hasPendingBlobs
|
|
5741
|
+
hasPendingBlobs,
|
|
5742
|
+
getLastSentVersion
|
|
5730
5743
|
} = realtimeMockup;
|
|
5731
5744
|
const mockupResultsRef = useRef12([]);
|
|
5732
5745
|
const mockupSubscribersRef = useRef12(
|
|
@@ -5748,6 +5761,8 @@ function RealtimeProvider({
|
|
|
5748
5761
|
const blobQueuedGenRef = useRef12(0);
|
|
5749
5762
|
const hasBlobInFlightRef = useRef12(false);
|
|
5750
5763
|
const pipelineResetCooldownRef = useRef12(false);
|
|
5764
|
+
const dueByVersionRef = useRef12(/* @__PURE__ */ new Map());
|
|
5765
|
+
const configuredMockupIdsRef = useRef12([]);
|
|
5751
5766
|
const mockupUrlCacheRef = useRef12(/* @__PURE__ */ new Map());
|
|
5752
5767
|
const registeredBlobUrlsRef = useRef12(/* @__PURE__ */ new Set());
|
|
5753
5768
|
const pendingPlacementsRef = useRef12(/* @__PURE__ */ new Set());
|
|
@@ -5866,7 +5881,29 @@ function RealtimeProvider({
|
|
|
5866
5881
|
console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
|
|
5867
5882
|
}
|
|
5868
5883
|
});
|
|
5869
|
-
|
|
5884
|
+
let settledVersions = 0;
|
|
5885
|
+
const due = dueByVersionRef.current;
|
|
5886
|
+
results.forEach((result) => {
|
|
5887
|
+
const v = result.requestVersion;
|
|
5888
|
+
if (v === void 0) return;
|
|
5889
|
+
const entry = due.get(v);
|
|
5890
|
+
if (!entry) return;
|
|
5891
|
+
if (entry.due <= 1) {
|
|
5892
|
+
due.delete(v);
|
|
5893
|
+
settledVersions += 1;
|
|
5894
|
+
} else {
|
|
5895
|
+
entry.due -= 1;
|
|
5896
|
+
}
|
|
5897
|
+
});
|
|
5898
|
+
due.forEach((entry, v) => {
|
|
5899
|
+
if (now - entry.at > 1e4) due.delete(v);
|
|
5900
|
+
});
|
|
5901
|
+
if (settledVersions > 0) {
|
|
5902
|
+
if (!pipelineResetCooldownRef.current) {
|
|
5903
|
+
hasBlobInFlightRef.current = false;
|
|
5904
|
+
for (let i = 0; i < settledVersions; i++) pipelineSettledSubscribersRef.current.forEach((cb) => cb());
|
|
5905
|
+
}
|
|
5906
|
+
} else if (due.size === 0 && hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
|
|
5870
5907
|
hasBlobInFlightRef.current = false;
|
|
5871
5908
|
pipelineSettledSubscribersRef.current.forEach((cb) => cb());
|
|
5872
5909
|
}
|
|
@@ -5995,11 +6032,17 @@ function RealtimeProvider({
|
|
|
5995
6032
|
canvasBlobsSentRef.current += 1;
|
|
5996
6033
|
lastBlobSentTimeRef.current = (/* @__PURE__ */ new Date()).toLocaleTimeString();
|
|
5997
6034
|
isPendingMockupsRef.current = true;
|
|
6035
|
+
const version = getLastSentVersion();
|
|
6036
|
+
if (version > 0) {
|
|
6037
|
+
const configured = configuredMockupIdsRef.current.length;
|
|
6038
|
+
const expected = Math.max(1, Math.min(configured || Infinity, mockupCount || Infinity));
|
|
6039
|
+
dueByVersionRef.current.set(version, { due: Number.isFinite(expected) ? expected : 1, at: Date.now() });
|
|
6040
|
+
}
|
|
5998
6041
|
pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
|
|
5999
6042
|
}
|
|
6000
6043
|
return result;
|
|
6001
6044
|
},
|
|
6002
|
-
[sendCanvasStateRaw]
|
|
6045
|
+
[sendCanvasStateRaw, getLastSentVersion]
|
|
6003
6046
|
);
|
|
6004
6047
|
const sendColorBlobTracked = useCallback10(
|
|
6005
6048
|
(placement, color) => {
|
|
@@ -6019,6 +6062,8 @@ function RealtimeProvider({
|
|
|
6019
6062
|
return () => {
|
|
6020
6063
|
disconnect();
|
|
6021
6064
|
configSentRef.current = false;
|
|
6065
|
+
dueByVersionRef.current.clear();
|
|
6066
|
+
setUnavailableMockups((prev) => prev.size ? /* @__PURE__ */ new Map() : prev);
|
|
6022
6067
|
lastSentVariantIdRef.current = null;
|
|
6023
6068
|
lastSentBgRef.current = void 0;
|
|
6024
6069
|
lastSentSpreadCropsRef.current = "null";
|
|
@@ -6062,6 +6107,7 @@ function RealtimeProvider({
|
|
|
6062
6107
|
}
|
|
6063
6108
|
});
|
|
6064
6109
|
}
|
|
6110
|
+
configuredMockupIdsRef.current = mockupIds;
|
|
6065
6111
|
const configPayload = useSessionGrant ? {
|
|
6066
6112
|
productId,
|
|
6067
6113
|
mockupIds,
|
|
@@ -6277,6 +6323,7 @@ function RealtimeProvider({
|
|
|
6277
6323
|
() => {
|
|
6278
6324
|
pipelineGenRef.current++;
|
|
6279
6325
|
hasBlobInFlightRef.current = false;
|
|
6326
|
+
dueByVersionRef.current.clear();
|
|
6280
6327
|
pipelineResetCooldownRef.current = true;
|
|
6281
6328
|
requestAnimationFrame(() => {
|
|
6282
6329
|
pipelineResetCooldownRef.current = false;
|
|
@@ -6415,6 +6462,7 @@ function RealtimeProvider({
|
|
|
6415
6462
|
isConnected,
|
|
6416
6463
|
isConfigured,
|
|
6417
6464
|
renderError,
|
|
6465
|
+
unavailableMockups,
|
|
6418
6466
|
// Read from ref - this is a snapshot at render time, NOT reactive
|
|
6419
6467
|
// For reactive updates, use getMockupResultsImmediate() or subscribe functions
|
|
6420
6468
|
get mockupResults() {
|
|
@@ -6460,6 +6508,7 @@ function RealtimeProvider({
|
|
|
6460
6508
|
isConnected,
|
|
6461
6509
|
isConfigured,
|
|
6462
6510
|
renderError,
|
|
6511
|
+
unavailableMockups,
|
|
6463
6512
|
// mockupResults removed from deps - it's a getter that reads from ref
|
|
6464
6513
|
canvasExportSize,
|
|
6465
6514
|
mockupWidth,
|
|
@@ -8308,9 +8357,18 @@ var CanvasExportService = class _CanvasExportService {
|
|
|
8308
8357
|
// Debounce state for handleStateChange
|
|
8309
8358
|
stateChangeThrottleTimeout = null;
|
|
8310
8359
|
stateChangeLastSendTime = 0;
|
|
8311
|
-
/**
|
|
8312
|
-
|
|
8313
|
-
|
|
8360
|
+
/** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
|
|
8361
|
+
inFlightSentAt = [];
|
|
8362
|
+
maxInFlight = 2;
|
|
8363
|
+
// CanvasExportServiceConfig.maxInFlight
|
|
8364
|
+
subscribePipelineSettled = null;
|
|
8365
|
+
unsubscribePipelineSettled = null;
|
|
8366
|
+
/**
|
|
8367
|
+
* Safety net for the one-in-flight policy: a held state never waits longer
|
|
8368
|
+
* than this for the in-flight frame (a swallowed render error would
|
|
8369
|
+
* otherwise hold the pipeline forever), and a change arriving while a
|
|
8370
|
+
* frame has been in flight this long is sent regardless.
|
|
8371
|
+
*/
|
|
8314
8372
|
static MAX_WAIT_MS = 1e3;
|
|
8315
8373
|
// Internal state for tracking exports
|
|
8316
8374
|
lastExportedArtwork = null;
|
|
@@ -8352,6 +8410,14 @@ var CanvasExportService = class _CanvasExportService {
|
|
|
8352
8410
|
if (config.disableRealtime !== void 0) {
|
|
8353
8411
|
this.disableRealtime = config.disableRealtime;
|
|
8354
8412
|
}
|
|
8413
|
+
if (config.maxInFlight !== void 0) {
|
|
8414
|
+
this.maxInFlight = config.maxInFlight;
|
|
8415
|
+
}
|
|
8416
|
+
if (config.subscribePipelineSettled !== void 0 && config.subscribePipelineSettled !== this.subscribePipelineSettled) {
|
|
8417
|
+
this.unsubscribePipelineSettled?.();
|
|
8418
|
+
this.subscribePipelineSettled = config.subscribePipelineSettled;
|
|
8419
|
+
this.unsubscribePipelineSettled = config.subscribePipelineSettled?.(this.onPipelineSettled) ?? null;
|
|
8420
|
+
}
|
|
8355
8421
|
if (config.clearImageBitmapCache !== void 0) {
|
|
8356
8422
|
this.clearImageBitmapCache = config.clearImageBitmapCache;
|
|
8357
8423
|
}
|
|
@@ -8555,42 +8621,62 @@ var CanvasExportService = class _CanvasExportService {
|
|
|
8555
8621
|
console.log(`[CES] handleStateChange #${callId}: not ready (realtime=${this.isRealtimeEnabled} sendState=${!!this.sendCanvasState} serialize=${!!this.serializeStateForServer}) \u2014 state cached`);
|
|
8556
8622
|
return;
|
|
8557
8623
|
}
|
|
8558
|
-
|
|
8559
|
-
const
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
if (isActiveDrag && timeSinceLastSend >= maxWaitMs) {
|
|
8564
|
-
console.log(`[CES] handleStateChange #${callId}: MAX-WAIT FLUSH (${timeSinceLastSend}ms >= ${maxWaitMs}ms)`);
|
|
8565
|
-
if (this.stateChangeThrottleTimeout) {
|
|
8566
|
-
clearTimeout(this.stateChangeThrottleTimeout);
|
|
8567
|
-
this.stateChangeThrottleTimeout = null;
|
|
8568
|
-
}
|
|
8624
|
+
this.dropStalledSends(now);
|
|
8625
|
+
const inFlight = this.inFlightSentAt.length;
|
|
8626
|
+
if (inFlight < this.maxInFlight) {
|
|
8627
|
+
console.log(`[CES] handleStateChange #${callId}: SEND now (${inFlight} in flight)`);
|
|
8628
|
+
this.clearFlushTimer();
|
|
8569
8629
|
this.sendStateNow(state);
|
|
8570
8630
|
return;
|
|
8571
8631
|
}
|
|
8572
|
-
const
|
|
8573
|
-
|
|
8574
|
-
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
clearTimeout(this.stateChangeThrottleTimeout);
|
|
8632
|
+
const oldestMs = now - this.inFlightSentAt[0];
|
|
8633
|
+
console.log(`[CES] handleStateChange #${callId}: HELD (${inFlight} in flight, oldest ${oldestMs}ms) \u2014 sends when one lands`);
|
|
8634
|
+
this.armFlushTimer(_CanvasExportService.MAX_WAIT_MS - oldestMs);
|
|
8635
|
+
};
|
|
8636
|
+
/** A send landed (every mockup of it): free its slot, release the held state if any. */
|
|
8637
|
+
onPipelineSettled = () => {
|
|
8638
|
+
this.inFlightSentAt.shift();
|
|
8639
|
+
this.clearFlushTimer();
|
|
8640
|
+
if (this.pendingCanvasState && this.isRealtimeEnabled) {
|
|
8641
|
+
console.log(`[CES] pipeline settled \u2192 sending held state`);
|
|
8642
|
+
this.sendStateNow(this.pendingCanvasState);
|
|
8584
8643
|
}
|
|
8585
|
-
|
|
8644
|
+
};
|
|
8645
|
+
/**
|
|
8646
|
+
* The pointer was released (drag end, slider release, key up). Send the held
|
|
8647
|
+
* state NOW instead of waiting for the in-flight frame: the server coalesces
|
|
8648
|
+
* (latest wins), and the "let go" frame is the one the shopper waits for.
|
|
8649
|
+
* A no-op when nothing is held, so it is safe on every pointerup.
|
|
8650
|
+
*/
|
|
8651
|
+
handleInteractionEnd = () => {
|
|
8652
|
+
if (!this.pendingCanvasState || !this.isRealtimeEnabled || !this.sendCanvasState || !this.serializeStateForServer) return;
|
|
8653
|
+
if (this.inFlightSentAt.length < this.maxInFlight) return;
|
|
8654
|
+
console.log(`[CES] interaction end \u2192 flushing held state`);
|
|
8655
|
+
this.clearFlushTimer();
|
|
8656
|
+
this.sendStateNow(this.pendingCanvasState);
|
|
8657
|
+
};
|
|
8658
|
+
armFlushTimer(ms) {
|
|
8659
|
+
this.clearFlushTimer();
|
|
8586
8660
|
this.stateChangeThrottleTimeout = setTimeout(() => {
|
|
8587
8661
|
this.stateChangeThrottleTimeout = null;
|
|
8588
|
-
console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
|
|
8589
8662
|
if (this.pendingCanvasState) {
|
|
8663
|
+
console.log(`[CES] flush timer \u2192 sending held state (no frame landed within ${_CanvasExportService.MAX_WAIT_MS}ms)`);
|
|
8664
|
+
this.dropStalledSends(Date.now());
|
|
8590
8665
|
this.sendStateNow(this.pendingCanvasState);
|
|
8591
8666
|
}
|
|
8592
|
-
},
|
|
8593
|
-
}
|
|
8667
|
+
}, Math.max(0, ms));
|
|
8668
|
+
}
|
|
8669
|
+
/** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
|
|
8670
|
+
dropStalledSends(now) {
|
|
8671
|
+
if (this.inFlightSentAt.length === 0) return;
|
|
8672
|
+
this.inFlightSentAt = this.inFlightSentAt.filter((t) => now - t < _CanvasExportService.MAX_WAIT_MS);
|
|
8673
|
+
}
|
|
8674
|
+
clearFlushTimer() {
|
|
8675
|
+
if (this.stateChangeThrottleTimeout) {
|
|
8676
|
+
clearTimeout(this.stateChangeThrottleTimeout);
|
|
8677
|
+
this.stateChangeThrottleTimeout = null;
|
|
8678
|
+
}
|
|
8679
|
+
}
|
|
8594
8680
|
sendStateNow(state) {
|
|
8595
8681
|
if (!this.sendCanvasState || !this.serializeStateForServer) {
|
|
8596
8682
|
console.log(`[CES] sendStateNow SKIPPED: sendCanvasState=${!!this.sendCanvasState} serializeStateForServer=${!!this.serializeStateForServer}`);
|
|
@@ -8615,6 +8701,7 @@ var CanvasExportService = class _CanvasExportService {
|
|
|
8615
8701
|
if (sent) {
|
|
8616
8702
|
this.pendingCanvasState = null;
|
|
8617
8703
|
this.jsonModeSentSuccessfully = true;
|
|
8704
|
+
this.inFlightSentAt.push(Date.now());
|
|
8618
8705
|
console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
|
|
8619
8706
|
} else {
|
|
8620
8707
|
console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) \u2014 state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
|
|
@@ -8636,6 +8723,7 @@ var CanvasExportService = class _CanvasExportService {
|
|
|
8636
8723
|
this.stateChangeThrottleTimeout = null;
|
|
8637
8724
|
}
|
|
8638
8725
|
this.stateChangeLastSendTime = 0;
|
|
8726
|
+
this.inFlightSentAt = [];
|
|
8639
8727
|
this.jsonModeSentSuccessfully = false;
|
|
8640
8728
|
this.pendingCanvasState = null;
|
|
8641
8729
|
if (this.clearImageBitmapCache) {
|
|
@@ -8671,7 +8759,10 @@ function CanvasIsolationConfigurator({
|
|
|
8671
8759
|
// Wire CES's "I sent nothing" signal to the realtime context's
|
|
8672
8760
|
// cancellation broadcaster. Roll back any optimistic
|
|
8673
8761
|
// notifyPendingExport that didn't end up producing a real export.
|
|
8674
|
-
notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled
|
|
8762
|
+
notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled,
|
|
8763
|
+
// One-in-flight send policy: the held state goes out when the last
|
|
8764
|
+
// send's frame lands.
|
|
8765
|
+
subscribePipelineSettled: realtimeContext?.subscribePipelineSettled
|
|
8675
8766
|
});
|
|
8676
8767
|
}, [
|
|
8677
8768
|
realtimeContext?.sendCanvasBlob,
|
|
@@ -8683,6 +8774,7 @@ function CanvasIsolationConfigurator({
|
|
|
8683
8774
|
realtimeContext?.enableRealtime,
|
|
8684
8775
|
realtimeContext?.disableRealtime,
|
|
8685
8776
|
realtimeContext?.notifyPendingExportCancelled,
|
|
8777
|
+
realtimeContext?.subscribePipelineSettled,
|
|
8686
8778
|
placementNameProp,
|
|
8687
8779
|
onArtworkChange,
|
|
8688
8780
|
clearImageBitmapCache
|
|
@@ -8728,6 +8820,8 @@ function useIsolatedCanvas() {
|
|
|
8728
8820
|
handleExport: canvasExportService.handleExport,
|
|
8729
8821
|
/** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
|
|
8730
8822
|
handleStateChange: canvasExportService.handleStateChange,
|
|
8823
|
+
/** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
|
|
8824
|
+
handleInteractionEnd: canvasExportService.handleInteractionEnd,
|
|
8731
8825
|
/** Call when entering editor mode */
|
|
8732
8826
|
onEnterEditorMode: () => canvasExportService.onEnterEditorMode(),
|
|
8733
8827
|
/** Call when exiting editor mode */
|
|
@@ -10019,6 +10113,7 @@ var HeroProductImage = memo3(function HeroProductImage2({
|
|
|
10019
10113
|
const actualMockupId = useMemo14(() => {
|
|
10020
10114
|
return resolveMockupId(context, actualGvid, mockupId);
|
|
10021
10115
|
}, [mockupId, actualGvid, context]);
|
|
10116
|
+
const unavailable = actualMockupId ? realtimeContext?.unavailableMockups?.get(actualMockupId) ?? null : null;
|
|
10022
10117
|
const artworkSrc = artwork?.src;
|
|
10023
10118
|
const selectedArtworkSrc = designContext?.selectedArtwork?.src;
|
|
10024
10119
|
const effectiveArtworkSrc = artworkSrc || selectedArtworkSrc;
|
|
@@ -10467,7 +10562,17 @@ var HeroProductImage = memo3(function HeroProductImage2({
|
|
|
10467
10562
|
className: `relative overflow-hidden ${className || ""}`,
|
|
10468
10563
|
style,
|
|
10469
10564
|
"data-hero-image": "true",
|
|
10565
|
+
"data-mockup-unavailable": unavailable ? unavailable.code : void 0,
|
|
10470
10566
|
children: [
|
|
10567
|
+
unavailable && /* @__PURE__ */ jsx61(
|
|
10568
|
+
"div",
|
|
10569
|
+
{
|
|
10570
|
+
role: "status",
|
|
10571
|
+
title: unavailable.message,
|
|
10572
|
+
className: "absolute inset-0 z-20 flex items-center justify-center bg-white/80 text-sm text-neutral-700",
|
|
10573
|
+
children: "Mockup unavailable"
|
|
10574
|
+
}
|
|
10575
|
+
),
|
|
10471
10576
|
renderedUrl && !imgFailed && /* @__PURE__ */ jsx61(
|
|
10472
10577
|
"img",
|
|
10473
10578
|
{
|