@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cropped-frame delivery state machine (RealtimeProvider internals).
|
|
3
|
+
*
|
|
4
|
+
* The sdk hook and the composite engine are mocked; these tests pin the
|
|
5
|
+
* provider's money-path invariants:
|
|
6
|
+
* - the session flips to overlayCrop:'auto' only after EVERY configured
|
|
7
|
+
* mockup has a frozen full-frame background
|
|
8
|
+
* - cropped frames are composited and consumers only ever see full-frame
|
|
9
|
+
* URLs (blob: URLs from the composite)
|
|
10
|
+
* - any composite failure falls back to full-frame delivery for the session
|
|
11
|
+
* and re-renders the affected mockup so the edit isn't lost
|
|
12
|
+
* - priority requests for never-rendered mockups drop the session back to
|
|
13
|
+
* full-frame before rendering
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { render, waitFor, act } from "@testing-library/react";
|
|
18
|
+
import {
|
|
19
|
+
RealtimeProvider,
|
|
20
|
+
useRealtime,
|
|
21
|
+
type RealtimeContextValue,
|
|
22
|
+
} from "../RealtimeProvider";
|
|
23
|
+
|
|
24
|
+
const h = vi.hoisted(() => {
|
|
25
|
+
return {
|
|
26
|
+
state: {
|
|
27
|
+
results: [] as Array<Record<string, unknown>>,
|
|
28
|
+
},
|
|
29
|
+
sendConfig: vi.fn(),
|
|
30
|
+
updateMockupIds: vi.fn(),
|
|
31
|
+
compositeCroppedFrame: vi.fn(),
|
|
32
|
+
loadImageElement: vi.fn(),
|
|
33
|
+
// Stable identities across renders — the real hook returns stable
|
|
34
|
+
// callbacks; fresh fns per render would churn the provider's effects.
|
|
35
|
+
sendCanvasBlob: vi.fn(),
|
|
36
|
+
sendCanvasState: vi.fn(),
|
|
37
|
+
sendColorBlob: vi.fn(),
|
|
38
|
+
updatePlacementSettings: vi.fn(),
|
|
39
|
+
connect: vi.fn(),
|
|
40
|
+
disconnect: vi.fn(),
|
|
41
|
+
flushPendingBlobs: vi.fn(),
|
|
42
|
+
hasPendingBlobs: vi.fn(() => false),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
vi.mock("@snowcone-app/sdk/react", () => ({
|
|
47
|
+
useRealtimeMockup: () => ({
|
|
48
|
+
isConnected: true,
|
|
49
|
+
isConfigured: true,
|
|
50
|
+
renderError: null,
|
|
51
|
+
mockupResults: h.state.results,
|
|
52
|
+
sendCanvasBlob: h.sendCanvasBlob,
|
|
53
|
+
sendCanvasState: h.sendCanvasState,
|
|
54
|
+
sendColorBlob: h.sendColorBlob,
|
|
55
|
+
sendConfig: h.sendConfig,
|
|
56
|
+
updateMockupIds: h.updateMockupIds,
|
|
57
|
+
updatePlacementSettings: h.updatePlacementSettings,
|
|
58
|
+
connect: h.connect,
|
|
59
|
+
disconnect: h.disconnect,
|
|
60
|
+
flushPendingBlobs: h.flushPendingBlobs,
|
|
61
|
+
hasPendingBlobs: h.hasPendingBlobs,
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("@snowcone-app/sdk", () => ({
|
|
66
|
+
resolveVariantId: () => "gv1",
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock("../Product", () => {
|
|
70
|
+
const product = {
|
|
71
|
+
id: "prod1",
|
|
72
|
+
placements: [{ label: "Front", type: "image" }],
|
|
73
|
+
mockups: [{ id: "m1" }, { id: "m2" }],
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
useProductOptional: () => ({ product, selection: {} }),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
vi.mock("../MockupBackground", () => ({
|
|
81
|
+
useMockupBackgroundOptional: () => undefined,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock("../croppedFrameComposite", async (importOriginal) => {
|
|
85
|
+
const actual = await importOriginal<typeof import("../croppedFrameComposite")>();
|
|
86
|
+
return {
|
|
87
|
+
...actual,
|
|
88
|
+
compositeCroppedFrame: h.compositeCroppedFrame,
|
|
89
|
+
loadImageElement: h.loadImageElement,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let capturedCtx: RealtimeContextValue;
|
|
94
|
+
function Capture() {
|
|
95
|
+
capturedCtx = useRealtime();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Harness({ version }: { version: number }) {
|
|
100
|
+
void version; // re-render trigger — the mocked hook returns h.state.results
|
|
101
|
+
return (
|
|
102
|
+
<RealtimeProvider autoEnable>
|
|
103
|
+
<Capture />
|
|
104
|
+
</RealtimeProvider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fullResult(mockupId: string, url: string) {
|
|
109
|
+
return { mockupId, imageUrl: url, renderUrl: url, imageSize: 1000, placement: "Front" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function croppedResult(mockupId: string, url: string) {
|
|
113
|
+
return {
|
|
114
|
+
...fullResult(mockupId, url),
|
|
115
|
+
cropRect: { x: 0.2, y: 0.1, w: 0.5, h: 0.6 },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** sendConfig payloads observed since the last mock reset. */
|
|
120
|
+
function sentConfigs(): Array<Record<string, unknown>> {
|
|
121
|
+
return h.sendConfig.mock.calls.map((c) => c[0] as Record<string, unknown>);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let blobCounter = 0;
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
vi.clearAllMocks();
|
|
128
|
+
h.state.results = [];
|
|
129
|
+
blobCounter = 0;
|
|
130
|
+
h.loadImageElement.mockResolvedValue({ naturalWidth: 1400, naturalHeight: 782 });
|
|
131
|
+
h.compositeCroppedFrame.mockResolvedValue(new Blob(["x"], { type: "image/webp" }));
|
|
132
|
+
// jsdom has no createObjectURL
|
|
133
|
+
globalThis.URL.createObjectURL = vi.fn(() => `blob:composite-${++blobCounter}`);
|
|
134
|
+
globalThis.URL.revokeObjectURL = vi.fn();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
async function seedAndFlip(rerender: (ui: React.ReactElement) => void) {
|
|
138
|
+
h.state.results = [fullResult("m1", "https://r/m1-full")];
|
|
139
|
+
await act(async () => rerender(<Harness version={2} />));
|
|
140
|
+
expect(sentConfigs().some((c) => c.overlayCrop)).toBe(false);
|
|
141
|
+
|
|
142
|
+
h.state.results = [
|
|
143
|
+
fullResult("m1", "https://r/m1-full"),
|
|
144
|
+
fullResult("m2", "https://r/m2-full"),
|
|
145
|
+
];
|
|
146
|
+
await act(async () => rerender(<Harness version={3} />));
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("RealtimeProvider cropped delivery", () => {
|
|
153
|
+
it("flips to overlayCrop:auto only after every configured mockup seeded", async () => {
|
|
154
|
+
const { rerender } = render(<Harness version={1} />);
|
|
155
|
+
// Initial config: no crop
|
|
156
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
157
|
+
expect(sentConfigs()[0].overlayCrop).toBeUndefined();
|
|
158
|
+
await seedAndFlip(rerender);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("composites cropped frames — consumers only ever see full-frame URLs", async () => {
|
|
162
|
+
const { rerender } = render(<Harness version={1} />);
|
|
163
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
164
|
+
await seedAndFlip(rerender);
|
|
165
|
+
|
|
166
|
+
const seen: string[] = [];
|
|
167
|
+
capturedCtx.subscribeMockupResultById("m1", (r) => seen.push(r.imageUrl));
|
|
168
|
+
|
|
169
|
+
h.state.results = [
|
|
170
|
+
croppedResult("m1", "https://r/m1-crop-frame"),
|
|
171
|
+
fullResult("m2", "https://r/m2-full"),
|
|
172
|
+
];
|
|
173
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(seen.some((u) => u.startsWith("blob:composite-"))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
// The raw cropped URL never reaches subscribers or the cache
|
|
179
|
+
expect(seen).not.toContain("https://r/m1-crop-frame");
|
|
180
|
+
expect(capturedCtx.getMockupUrl("m1")).toMatch(/^blob:composite-/);
|
|
181
|
+
expect(h.compositeCroppedFrame).toHaveBeenCalledWith(
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
frameUrl: "https://r/m1-crop-frame",
|
|
184
|
+
rect: { x: 0.2, y: 0.1, w: 0.5, h: 0.6 },
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("falls back to full-frame for the session when compositing fails, and re-renders the edit", async () => {
|
|
190
|
+
const { rerender } = render(<Harness version={1} />);
|
|
191
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
192
|
+
await seedAndFlip(rerender);
|
|
193
|
+
h.sendConfig.mockClear();
|
|
194
|
+
|
|
195
|
+
h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
|
|
196
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
197
|
+
h.state.results = [croppedResult("m1", "https://r/m1-crop-frame")];
|
|
198
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
// Unflipped: config re-sent WITHOUT overlayCrop
|
|
202
|
+
expect(sentConfigs().length).toBeGreaterThan(0);
|
|
203
|
+
expect(sentConfigs().every((c) => !c.overlayCrop)).toBe(true);
|
|
204
|
+
// The lost edit is re-rendered full-frame
|
|
205
|
+
expect(h.updateMockupIds).toHaveBeenCalledWith(["m1"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Kill-switch: even after everything re-seeds, no flip this session
|
|
209
|
+
h.sendConfig.mockClear();
|
|
210
|
+
h.state.results = [
|
|
211
|
+
fullResult("m1", "https://r/m1-full-2"),
|
|
212
|
+
fullResult("m2", "https://r/m2-full-2"),
|
|
213
|
+
];
|
|
214
|
+
await act(async () => rerender(<Harness version={5} />));
|
|
215
|
+
expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(false);
|
|
216
|
+
errSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("drops to full-frame before rendering a never-seeded mockup via updateMockupIds", async () => {
|
|
220
|
+
const { rerender } = render(<Harness version={1} />);
|
|
221
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
222
|
+
await seedAndFlip(rerender);
|
|
223
|
+
h.sendConfig.mockClear();
|
|
224
|
+
|
|
225
|
+
act(() => {
|
|
226
|
+
capturedCtx.updateMockupIds(["m3"]);
|
|
227
|
+
});
|
|
228
|
+
// Config re-sent without crop BEFORE the render request goes out
|
|
229
|
+
expect(sentConfigs().length).toBe(1);
|
|
230
|
+
expect(sentConfigs()[0].overlayCrop).toBeUndefined();
|
|
231
|
+
expect(h.updateMockupIds).toHaveBeenCalledWith(["m3"]);
|
|
232
|
+
|
|
233
|
+
// Seeded mockups don't unflip
|
|
234
|
+
h.sendConfig.mockClear();
|
|
235
|
+
act(() => {
|
|
236
|
+
capturedCtx.updateMockupIds(["m1", "m2"]);
|
|
237
|
+
});
|
|
238
|
+
expect(h.sendConfig).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
allBackgroundsSeeded,
|
|
4
|
+
compositeOffset,
|
|
5
|
+
isFullRect,
|
|
6
|
+
} from "../croppedFrameComposite";
|
|
7
|
+
|
|
8
|
+
describe("isFullRect", () => {
|
|
9
|
+
it("treats a missing rect as full frame", () => {
|
|
10
|
+
expect(isFullRect(undefined)).toBe(true);
|
|
11
|
+
expect(isFullRect(null)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("treats the identity rect as full frame", () => {
|
|
15
|
+
expect(isFullRect({ x: 0, y: 0, w: 1, h: 1 })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("treats any real crop as cropped", () => {
|
|
19
|
+
expect(isFullRect({ x: 0.37, y: 0.1, w: 0.45, h: 0.87 })).toBe(false);
|
|
20
|
+
expect(isFullRect({ x: 0, y: 0, w: 0.999, h: 1 })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("compositeOffset", () => {
|
|
25
|
+
it("recovers the server's integer pixel offsets from fractions", () => {
|
|
26
|
+
// Server cropped at x=518px, y=102px of a 1400×782 frame.
|
|
27
|
+
const rect = { x: 518 / 1400, y: 102 / 782, w: 0.4, h: 0.6 };
|
|
28
|
+
expect(compositeOffset(rect, 1400, 782)).toEqual({ dx: 518, dy: 102 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("survives fraction truncation to 4 decimals within ±1px", () => {
|
|
32
|
+
const rect = { x: 0.37, y: 0.1304, w: 0.4, h: 0.6 };
|
|
33
|
+
const { dx, dy } = compositeOffset(rect, 1400, 782);
|
|
34
|
+
expect(Math.abs(dx - 518)).toBeLessThanOrEqual(1);
|
|
35
|
+
expect(Math.abs(dy - 102)).toBeLessThanOrEqual(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is zero at the origin", () => {
|
|
39
|
+
expect(compositeOffset({ x: 0, y: 0, w: 1, h: 1 }, 1400, 782)).toEqual({
|
|
40
|
+
dx: 0,
|
|
41
|
+
dy: 0,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("allBackgroundsSeeded", () => {
|
|
47
|
+
it("requires every configured mockup", () => {
|
|
48
|
+
const seeded = new Set(["a", "b"]);
|
|
49
|
+
expect(allBackgroundsSeeded(["a", "b"], seeded)).toBe(true);
|
|
50
|
+
expect(allBackgroundsSeeded(["a", "b", "c"], seeded)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("never flips on an empty config", () => {
|
|
54
|
+
expect(allBackgroundsSeeded([], new Set(["a"]))).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cropped-frame compositing for the realtime provider (PDP fast delivery).
|
|
3
|
+
*
|
|
4
|
+
* With `overlayCrop: 'auto'` on the session config, the server ships only the
|
|
5
|
+
* pixels inside the product region (a fraction of the full frame's bytes) and
|
|
6
|
+
* reports the fractional rect it cropped with. The provider composites that
|
|
7
|
+
* frame over the mockup's frozen full-frame background on a canvas, so every
|
|
8
|
+
* consumer keeps receiving plain full-frame URLs — the delivery strategy is
|
|
9
|
+
* invisible outside the provider.
|
|
10
|
+
*
|
|
11
|
+
* The cropped frame is rendered at the same pixel scale as the full frame
|
|
12
|
+
* (crop width in px = rect.w × render width), so compositing is an integer
|
|
13
|
+
* translate + draw at natural size — no scaling, no resampling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface CropRect {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
w: number;
|
|
20
|
+
h: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** WebP quality for composited frames (PNG fallback on Safari is lossless). */
|
|
24
|
+
const COMPOSITE_QUALITY = 0.92;
|
|
25
|
+
|
|
26
|
+
/** A missing rect or the identity rect both mean "this is a full frame". */
|
|
27
|
+
export function isFullRect(rect: CropRect | null | undefined): boolean {
|
|
28
|
+
if (!rect) return true;
|
|
29
|
+
return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pixel offset to draw the cropped frame at. The server derived the fractions
|
|
34
|
+
* from integer pixel offsets at this same render width, so rounding recovers
|
|
35
|
+
* the original integers (worst case ±1px from fraction truncation, inside the
|
|
36
|
+
* crop's padded region).
|
|
37
|
+
*/
|
|
38
|
+
export function compositeOffset(
|
|
39
|
+
rect: CropRect,
|
|
40
|
+
bgWidth: number,
|
|
41
|
+
bgHeight: number
|
|
42
|
+
): { dx: number; dy: number } {
|
|
43
|
+
return {
|
|
44
|
+
dx: Math.round(rect.x * bgWidth),
|
|
45
|
+
dy: Math.round(rect.y * bgHeight),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every mockup the session renders must have a frozen background before the
|
|
50
|
+
* session may flip to cropped delivery (a cropped frame can't seed one). */
|
|
51
|
+
export function allBackgroundsSeeded(
|
|
52
|
+
mockupIds: string[],
|
|
53
|
+
seeded: ReadonlySet<string>
|
|
54
|
+
): boolean {
|
|
55
|
+
return mockupIds.length > 0 && mockupIds.every((id) => seeded.has(id));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load + decode an image for compositing (CORS-clean so the canvas stays
|
|
59
|
+
* untainted — render hosts serve ACAO for the crossorigin display path). */
|
|
60
|
+
export function loadImageElement(url: string): Promise<HTMLImageElement> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const img = new Image();
|
|
63
|
+
img.crossOrigin = "anonymous";
|
|
64
|
+
img.onload = () => {
|
|
65
|
+
// decode() forces full decode off the critical path; a decode rejection
|
|
66
|
+
// (e.g. Safari under memory pressure) still leaves a drawable image.
|
|
67
|
+
if (img.decode) {
|
|
68
|
+
img.decode().then(
|
|
69
|
+
() => resolve(img),
|
|
70
|
+
() => resolve(img)
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
resolve(img);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
img.onerror = () =>
|
|
77
|
+
reject(
|
|
78
|
+
new Error(`E_OVERLAY_FRAME_LOAD: image failed to load: ${url.slice(0, 160)}`)
|
|
79
|
+
);
|
|
80
|
+
img.src = url;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Composite a cropped frame over its full-frame background. Returns an
|
|
86
|
+
* encoded Blob (WebP where the browser supports canvas WebP encode, PNG
|
|
87
|
+
* otherwise per the toBlob spec fallback). Throws typed `E_OVERLAY_*` errors —
|
|
88
|
+
* the provider treats any throw as "disable cropped delivery this session".
|
|
89
|
+
*/
|
|
90
|
+
export async function compositeCroppedFrame(args: {
|
|
91
|
+
background: HTMLImageElement;
|
|
92
|
+
frameUrl: string;
|
|
93
|
+
rect: CropRect;
|
|
94
|
+
/** Reusable canvas to avoid per-frame allocations. */
|
|
95
|
+
canvas?: HTMLCanvasElement;
|
|
96
|
+
}): Promise<Blob> {
|
|
97
|
+
const frame = await loadImageElement(args.frameUrl);
|
|
98
|
+
const width = args.background.naturalWidth;
|
|
99
|
+
const height = args.background.naturalHeight;
|
|
100
|
+
if (!width || !height) {
|
|
101
|
+
throw new Error("E_OVERLAY_BG_EMPTY: background image has no pixels");
|
|
102
|
+
}
|
|
103
|
+
const canvas = args.canvas ?? document.createElement("canvas");
|
|
104
|
+
// Setting dimensions also clears any previous composite.
|
|
105
|
+
canvas.width = width;
|
|
106
|
+
canvas.height = height;
|
|
107
|
+
const ctx = canvas.getContext("2d");
|
|
108
|
+
if (!ctx) {
|
|
109
|
+
throw new Error("E_OVERLAY_NO_2D_CONTEXT: canvas 2d context unavailable");
|
|
110
|
+
}
|
|
111
|
+
ctx.drawImage(args.background, 0, 0);
|
|
112
|
+
const { dx, dy } = compositeOffset(args.rect, width, height);
|
|
113
|
+
// Natural size — pixel-exact overlay, no scaling.
|
|
114
|
+
ctx.drawImage(frame, dx, dy);
|
|
115
|
+
const blob = await new Promise<Blob | null>((resolve) =>
|
|
116
|
+
canvas.toBlob(resolve, "image/webp", COMPOSITE_QUALITY)
|
|
117
|
+
);
|
|
118
|
+
if (!blob) {
|
|
119
|
+
throw new Error("E_OVERLAY_ENCODE_FAILED: canvas.toBlob returned null");
|
|
120
|
+
}
|
|
121
|
+
return blob;
|
|
122
|
+
}
|