@ume-group/contracts 0.2.1

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.
Files changed (57) hide show
  1. package/README.md +37 -0
  2. package/dist/adserving.d.ts +150 -0
  3. package/dist/adserving.d.ts.map +1 -0
  4. package/dist/adserving.js +8 -0
  5. package/dist/campaigns.d.ts +37 -0
  6. package/dist/campaigns.d.ts.map +1 -0
  7. package/dist/campaigns.js +8 -0
  8. package/dist/gausst.d.ts +236 -0
  9. package/dist/gausst.d.ts.map +1 -0
  10. package/dist/gausst.js +307 -0
  11. package/dist/gausst.test.d.ts +2 -0
  12. package/dist/gausst.test.d.ts.map +1 -0
  13. package/dist/gausst.test.js +71 -0
  14. package/dist/index.d.ts +1531 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +1112 -0
  17. package/dist/layer2/index.d.ts +9 -0
  18. package/dist/layer2/index.d.ts.map +1 -0
  19. package/dist/layer2/index.js +10 -0
  20. package/dist/layer2/shaders.d.ts +185 -0
  21. package/dist/layer2/shaders.d.ts.map +1 -0
  22. package/dist/layer2/shaders.js +604 -0
  23. package/dist/layer2/webcam-utils.d.ts +113 -0
  24. package/dist/layer2/webcam-utils.d.ts.map +1 -0
  25. package/dist/layer2/webcam-utils.js +147 -0
  26. package/dist/layer2/webcam-utils.test.d.ts +2 -0
  27. package/dist/layer2/webcam-utils.test.d.ts.map +1 -0
  28. package/dist/layer2/webcam-utils.test.js +18 -0
  29. package/dist/layer2.d.ts +558 -0
  30. package/dist/layer2.d.ts.map +1 -0
  31. package/dist/layer2.js +376 -0
  32. package/dist/layer2.test.d.ts +2 -0
  33. package/dist/layer2.test.d.ts.map +1 -0
  34. package/dist/layer2.test.js +65 -0
  35. package/dist/perspective.d.ts +28 -0
  36. package/dist/perspective.d.ts.map +1 -0
  37. package/dist/perspective.js +157 -0
  38. package/dist/segmentation/MediaPipeSegmenter.d.ts +201 -0
  39. package/dist/segmentation/MediaPipeSegmenter.d.ts.map +1 -0
  40. package/dist/segmentation/MediaPipeSegmenter.js +434 -0
  41. package/dist/segmentation/index.d.ts +5 -0
  42. package/dist/segmentation/index.d.ts.map +1 -0
  43. package/dist/segmentation/index.js +4 -0
  44. package/dist/webcam/GarbageMatteDragManager.d.ts +63 -0
  45. package/dist/webcam/GarbageMatteDragManager.d.ts.map +1 -0
  46. package/dist/webcam/GarbageMatteDragManager.js +183 -0
  47. package/dist/webcam/WebcamStreamManager.d.ts +103 -0
  48. package/dist/webcam/WebcamStreamManager.d.ts.map +1 -0
  49. package/dist/webcam/WebcamStreamManager.js +356 -0
  50. package/dist/webcam/index.d.ts +5 -0
  51. package/dist/webcam/index.d.ts.map +1 -0
  52. package/dist/webcam/index.js +2 -0
  53. package/openapi/admetise.yaml +632 -0
  54. package/openapi/includu.yaml +621 -0
  55. package/openapi/integration.yaml +372 -0
  56. package/openapi/shared/schemas.yaml +227 -0
  57. package/package.json +53 -0
package/dist/layer2.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Layer 2: 3D Composite Types
3
+ *
4
+ * Type definitions for the 3D composite layer that enables:
5
+ * - 3D Space Ads: GAM ad templates positioned in 3D space
6
+ * - Participation: Webcam composite with chroma keying
7
+ *
8
+ * Uses the Gausst coordinate system from Patent US8761580B2
9
+ */
10
+ export const DEFAULT_CAMERA_SETTINGS = {
11
+ fov: 60, // Vertical FOV - Gausst default (horizontal ~82°, diagonal ~95°, matches typical phone cameras)
12
+ position: [0, 1.6, 0], // Gausst: Camera at origin, eye level (1.6m)
13
+ rotation: [-5, 0, 0], // -5° tilt (slightly downward) — centers webcam plane at [0,0,-3.5] on screen
14
+ lookAt: [0, 1.6, -10] // Gausst: Looking at negative Z (objects in front of camera)
15
+ };
16
+ export const DEFAULT_AD_3D_PLACEMENT = {
17
+ position: [0, 1, -3], // Gausst: 3m in front of camera (negative Z), 1m above floor
18
+ rotation: [0, 0, 0],
19
+ scale: 1.0,
20
+ materialType: 'unlit'
21
+ };
22
+ export const DEFAULT_AI_SEGMENTATION_SETTINGS = {
23
+ model: 'mediapipe',
24
+ threshold: 0.7, // Higher threshold = tighter person mask, less background bleed
25
+ edgeBlur: 3, // Balanced for real-time (confidence masks provide natural smoothing)
26
+ flipHorizontal: true,
27
+ lowLatency: true, // Default: real-time response prioritized
28
+ quality: 'fast' // Default: selfie_segmenter for close-up
29
+ };
30
+ export const DEFAULT_HSV_WINDOW_SETTINGS = {
31
+ enabled: false,
32
+ minHue: 60, // Yellow-ish green
33
+ maxHue: 180, // Cyan-ish
34
+ minSaturation: 0.2,
35
+ maxSaturation: 1.0,
36
+ minValue: 0.2,
37
+ maxValue: 1.0
38
+ };
39
+ export const DEFAULT_CHROMA_KEY_SETTINGS = {
40
+ color: 'custom',
41
+ customColor: '#00B140', // Standard chroma key green (slightly darker than pure green for better keying)
42
+ tolerance: 0.35,
43
+ spillSuppression: 0.25,
44
+ edgeSoftness: 0.08
45
+ };
46
+ export const DEFAULT_BACKGROUND_REMOVAL = {
47
+ method: 'ai', // AI is the default - no green screen needed!
48
+ aiSettings: DEFAULT_AI_SEGMENTATION_SETTINGS,
49
+ chromaKeySettings: DEFAULT_CHROMA_KEY_SETTINGS
50
+ };
51
+ export const DEFAULT_COLOR_MATCH_SETTINGS = {
52
+ brightness: 0,
53
+ contrast: 0,
54
+ saturation: 0,
55
+ temperature: 0
56
+ };
57
+ export const DEFAULT_FEATHER_EDGES = {
58
+ top: 0,
59
+ right: 0,
60
+ bottom: 0,
61
+ left: 0
62
+ };
63
+ /**
64
+ * Default garbage matte settings - full frame, no masking.
65
+ */
66
+ export const DEFAULT_GARBAGE_MATTE_SETTINGS = {
67
+ mode: 'off',
68
+ rectangle: {
69
+ topLeft: { x: 0, y: 0 },
70
+ bottomRight: { x: 1, y: 1 }
71
+ },
72
+ polygons: [],
73
+ feather: 0,
74
+ invert: false
75
+ };
76
+ // Default reference height for webcam composite (170cm = average adult height)
77
+ const DEFAULT_REFERENCE_HEIGHT = 170;
78
+ // Silhouette is 85% of plane height
79
+ const DEFAULT_SILHOUETTE_RATIO = 0.85;
80
+ /**
81
+ * Minimum capture height in cm for generous framing.
82
+ * The webcam plane is always at least this tall, allowing taller people
83
+ * to walk into frame without reconfiguration. Background removal
84
+ * makes the extra space transparent.
85
+ */
86
+ export const MIN_CAPTURE_HEIGHT_CM = 210;
87
+ export const DEFAULT_WEBCAM_COMPOSITE = {
88
+ enabled: true,
89
+ position: [0, 0, -3.5], // Gausst: 3.5m in front of camera (negative Z), feet at floor (Y=0)
90
+ rotation: [0, 0, 0],
91
+ // Scale = PLANE HEIGHT in meters (170cm person / 0.85 = 2.0m plane)
92
+ scale: (DEFAULT_REFERENCE_HEIGHT / 100) / DEFAULT_SILHOUETTE_RATIO,
93
+ referenceHeight: DEFAULT_REFERENCE_HEIGHT, // 170cm - average adult height
94
+ framingType: 'full', // Full body by default
95
+ defaultMethod: 'none' // Position first, then enable background removal
96
+ };
97
+ export const DEFAULT_WEBCAM_RUNTIME_STATE = {
98
+ deviceId: undefined,
99
+ audioDeviceId: undefined,
100
+ method: 'none', // Position first, then enable background removal
101
+ aiSettings: DEFAULT_AI_SEGMENTATION_SETTINGS,
102
+ chromaKeySettings: DEFAULT_CHROMA_KEY_SETTINGS,
103
+ colorCorrection: DEFAULT_COLOR_MATCH_SETTINGS,
104
+ isActive: false,
105
+ processingEnabled: false, // No processing until method is changed from 'none'
106
+ mirrored: true // Default: mirrored (selfie mode) for user-facing cameras
107
+ };
108
+ /**
109
+ * Create a WebcamRuntimeState initialized from a WebcamComposite template.
110
+ * Uses the template's defaultMethod, defaultAiSettings, and defaultChromaKeySettings as starting values.
111
+ */
112
+ export function createRuntimeStateFromTemplate(template) {
113
+ return {
114
+ ...DEFAULT_WEBCAM_RUNTIME_STATE,
115
+ method: template.defaultMethod,
116
+ aiSettings: {
117
+ ...DEFAULT_AI_SEGMENTATION_SETTINGS,
118
+ ...(template.defaultAiSettings ?? {})
119
+ },
120
+ chromaKeySettings: {
121
+ ...DEFAULT_CHROMA_KEY_SETTINGS,
122
+ ...(template.defaultChromaKeySettings ?? {})
123
+ }
124
+ };
125
+ }
126
+ export const DEFAULT_LAYER2_SCENE = {
127
+ camera: DEFAULT_CAMERA_SETTINGS,
128
+ ads: [],
129
+ participation: undefined,
130
+ advancedSceneJson: undefined
131
+ };
132
+ // =============================================================================
133
+ // Helper Functions
134
+ // =============================================================================
135
+ /**
136
+ * Generate a unique ID for Layer 2 objects
137
+ */
138
+ export function generateLayer2Id(prefix) {
139
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
140
+ }
141
+ /**
142
+ * Create a new 3D ad placement with defaults
143
+ */
144
+ export function createAd3DPlacement(templateId, startFrame, endFrame, overrides) {
145
+ return {
146
+ id: generateLayer2Id('ad3d'),
147
+ templateId,
148
+ startFrame,
149
+ endFrame,
150
+ // Deep copy arrays to prevent shared reference mutation
151
+ position: [...DEFAULT_AD_3D_PLACEMENT.position],
152
+ rotation: [...DEFAULT_AD_3D_PLACEMENT.rotation],
153
+ scale: DEFAULT_AD_3D_PLACEMENT.scale,
154
+ materialType: DEFAULT_AD_3D_PLACEMENT.materialType,
155
+ ...overrides
156
+ };
157
+ }
158
+ /**
159
+ * Silhouette occupies 85% of plane height (leaves 15% headroom).
160
+ * This constant is shared between here and Layer2SceneContent.svelte.
161
+ */
162
+ const SILHOUETTE_HEIGHT_RATIO = 0.85;
163
+ /**
164
+ * Create a new webcam composite template with defaults.
165
+ * Note: This creates only the template data. Runtime state (keying, color)
166
+ * is created separately using createRuntimeStateFromTemplate().
167
+ *
168
+ * Scale represents the PLANE HEIGHT in meters (Gausst coordinate system).
169
+ * For a 170cm person: scale = 1.70 / 0.85 = 2.0 (plane is 2.0m tall)
170
+ */
171
+ export function createWebcamComposite(startFrame, endFrame, overrides) {
172
+ // Calculate scale (plane height in meters) from referenceHeight
173
+ // Plane height = person height / silhouette ratio (person is 85% of plane)
174
+ const referenceHeight = overrides?.referenceHeight ?? DEFAULT_WEBCAM_COMPOSITE.referenceHeight;
175
+ const calculatedScale = (referenceHeight / 100) / SILHOUETTE_HEIGHT_RATIO;
176
+ // Only use calculated scale if scale is NOT explicitly provided in overrides
177
+ const finalScale = overrides?.scale !== undefined ? overrides.scale : calculatedScale;
178
+ return {
179
+ id: generateLayer2Id('webcam'),
180
+ startFrame,
181
+ endFrame,
182
+ enabled: DEFAULT_WEBCAM_COMPOSITE.enabled,
183
+ // Deep copy arrays to prevent shared reference mutation
184
+ position: [...DEFAULT_WEBCAM_COMPOSITE.position],
185
+ rotation: [...DEFAULT_WEBCAM_COMPOSITE.rotation],
186
+ referenceHeight: DEFAULT_WEBCAM_COMPOSITE.referenceHeight,
187
+ framingType: DEFAULT_WEBCAM_COMPOSITE.framingType,
188
+ defaultMethod: DEFAULT_WEBCAM_COMPOSITE.defaultMethod,
189
+ ...overrides,
190
+ // Always apply calculated scale last (unless explicitly overridden)
191
+ scale: finalScale
192
+ };
193
+ }
194
+ /**
195
+ * Check if an ad is visible at a given frame
196
+ */
197
+ export function isAdVisibleAtFrame(ad, frame) {
198
+ return frame >= ad.startFrame && frame < ad.endFrame;
199
+ }
200
+ /**
201
+ * Check if webcam composite is visible at a given frame
202
+ */
203
+ export function isWebcamVisibleAtFrame(webcam, frame) {
204
+ if (!webcam || !webcam.enabled)
205
+ return false;
206
+ return frame >= webcam.startFrame && frame < webcam.endFrame;
207
+ }
208
+ /**
209
+ * Get all visible 3D ads at a given frame
210
+ */
211
+ export function getVisibleAdsAtFrame(scene, frame) {
212
+ return scene.ads.filter((ad) => isAdVisibleAtFrame(ad, frame));
213
+ }
214
+ /**
215
+ * Calculate the scale factor for a participant based on their height
216
+ * relative to the reference height set by the content creator.
217
+ *
218
+ * @param referenceHeight - Height in cm that the scene is calibrated for
219
+ * @param participantHeight - Actual participant height in cm
220
+ * @returns Scale factor to apply (1.0 = same height as reference)
221
+ *
222
+ * @example
223
+ * // Content creator is 180cm, participant is 160cm
224
+ * calculateParticipantScale(180, 160) // Returns 0.889 (participant appears shorter)
225
+ *
226
+ * // Content creator is 160cm, participant is 180cm
227
+ * calculateParticipantScale(160, 180) // Returns 1.125 (participant appears taller)
228
+ */
229
+ export function calculateParticipantScale(referenceHeight, participantHeight) {
230
+ if (referenceHeight <= 0)
231
+ return 1.0;
232
+ return participantHeight / referenceHeight;
233
+ }
234
+ // =============================================================================
235
+ // Multi-Camera Shot Resolution Functions
236
+ // =============================================================================
237
+ /**
238
+ * Generate a unique ID for shot system objects
239
+ */
240
+ export function generateShotId(prefix = 'shot') {
241
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
242
+ }
243
+ /**
244
+ * Find which shot preset is active at a given frame.
245
+ * Returns null if multicam is disabled or no segment covers this frame.
246
+ */
247
+ export function getActiveShotPreset(scene, frame) {
248
+ if (!scene.multicamEnabled || !scene.shotSegments || !scene.shotPresets)
249
+ return null;
250
+ const segment = scene.shotSegments.find(seg => frame >= seg.startFrame && frame < seg.endFrame);
251
+ if (!segment)
252
+ return null;
253
+ return scene.shotPresets.find(p => p.id === segment.presetId) ?? null;
254
+ }
255
+ /**
256
+ * Get the camera settings to use at a given frame.
257
+ * When multicam is enabled and a shot preset covers this frame, returns that preset's camera.
258
+ * Otherwise returns the scene's default camera.
259
+ */
260
+ export function getActiveCamera(scene, frame) {
261
+ const preset = getActiveShotPreset(scene, frame);
262
+ return preset?.camera ?? scene.camera ?? DEFAULT_CAMERA_SETTINGS;
263
+ }
264
+ /**
265
+ * Resolve an ad's transform for the current frame.
266
+ * If a shot-specific override exists, use it. Otherwise use the ad's default transform.
267
+ * When multicam is enabled and no shot covers the frame, the ad is hidden.
268
+ */
269
+ export function getAdTransformAtFrame(ad, scene, frame) {
270
+ const preset = getActiveShotPreset(scene, frame);
271
+ if (preset && ad.shotTransforms?.[preset.id]) {
272
+ return ad.shotTransforms[preset.id];
273
+ }
274
+ // Multicam enabled but no shot at this frame — hide
275
+ if (scene.multicamEnabled && !preset) {
276
+ return {
277
+ position: ad.position,
278
+ rotation: ad.rotation,
279
+ scale: ad.scale,
280
+ visible: false
281
+ };
282
+ }
283
+ // Default: use the ad's own transform, always visible
284
+ return {
285
+ position: ad.position,
286
+ rotation: ad.rotation,
287
+ scale: ad.scale,
288
+ visible: true
289
+ };
290
+ }
291
+ /**
292
+ * Resolve a webcam composite's transform for the current frame.
293
+ * If a shot-specific override exists, use it. Otherwise use the webcam's default transform.
294
+ * When multicam is enabled and no shot covers the frame, the webcam is hidden.
295
+ */
296
+ export function getWebcamTransformAtFrame(webcam, scene, frame) {
297
+ const preset = getActiveShotPreset(scene, frame);
298
+ if (preset && webcam.shotTransforms?.[preset.id]) {
299
+ return webcam.shotTransforms[preset.id];
300
+ }
301
+ // Multicam enabled but no shot at this frame — hide
302
+ if (scene.multicamEnabled && !preset) {
303
+ return {
304
+ position: webcam.position,
305
+ rotation: webcam.rotation,
306
+ scale: webcam.scale,
307
+ visible: false
308
+ };
309
+ }
310
+ // Default: use the webcam's own transform, always visible
311
+ return {
312
+ position: webcam.position,
313
+ rotation: webcam.rotation,
314
+ scale: webcam.scale,
315
+ visible: true
316
+ };
317
+ }
318
+ /**
319
+ * Validate and auto-fix shot segments for overlap and bound issues.
320
+ *
321
+ * - Minor overlaps (≤ 2 frames): auto-fixed by snapping earlier segment's endFrame
322
+ * - Major overlaps (> 2 frames): reported in `overlaps[]` for user resolution
323
+ * - Invalid bounds (startFrame >= endFrame, startFrame < 0): auto-fixed
324
+ *
325
+ * Called at import boundaries (tracking import, Advanced Mode sync, JSON load),
326
+ * NOT during drag (drag handler already clamps per-frame).
327
+ */
328
+ export function validateAndFixSegments(segments) {
329
+ if (segments.length === 0)
330
+ return { valid: true, segments: [] };
331
+ // Fix individual segment bounds first
332
+ const fixed = segments.map((s) => {
333
+ const correctedStart = Math.max(0, s.startFrame);
334
+ return {
335
+ ...s,
336
+ startFrame: correctedStart,
337
+ endFrame: Math.max(correctedStart + 1, s.endFrame)
338
+ };
339
+ });
340
+ // Sort by startFrame for overlap detection
341
+ const sorted = [...fixed].sort((a, b) => a.startFrame - b.startFrame);
342
+ const overlaps = [];
343
+ for (let i = 0; i < sorted.length - 1; i++) {
344
+ const overlapFrames = sorted[i].endFrame - sorted[i + 1].startFrame;
345
+ if (overlapFrames > 0) {
346
+ if (overlapFrames <= 2) {
347
+ // Minor overlap — auto-fix by snapping earlier segment's end
348
+ sorted[i] = { ...sorted[i], endFrame: sorted[i + 1].startFrame };
349
+ }
350
+ else {
351
+ // Major overlap — report for user resolution
352
+ overlaps.push({
353
+ segA: sorted[i].id,
354
+ segB: sorted[i + 1].id,
355
+ overlapFrames
356
+ });
357
+ }
358
+ }
359
+ }
360
+ return {
361
+ valid: overlaps.length === 0,
362
+ segments: sorted,
363
+ ...(overlaps.length > 0 ? { overlaps } : {})
364
+ };
365
+ }
366
+ /**
367
+ * Validate that all segment presetId references point to existing presets.
368
+ * Returns orphaned segment IDs (presetId not found in presets array).
369
+ */
370
+ export function validatePresetReferences(segments, presets) {
371
+ const presetIds = new Set(presets.map((p) => p.id));
372
+ const orphaned = segments.filter((s) => !presetIds.has(s.presetId)).map((s) => s.id);
373
+ if (orphaned.length === 0)
374
+ return { valid: true };
375
+ return { valid: false, orphanedSegments: orphaned };
376
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=layer2.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layer2.test.d.ts","sourceRoot":"","sources":["../src/layer2.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isAdVisibleAtFrame, isWebcamVisibleAtFrame, calculateParticipantScale, createRuntimeStateFromTemplate, DEFAULT_AI_SEGMENTATION_SETTINGS, DEFAULT_CHROMA_KEY_SETTINGS } from './layer2';
3
+ const mockAd = {
4
+ id: 'test-ad',
5
+ templateId: 'tpl-1',
6
+ startFrame: 10,
7
+ endFrame: 50,
8
+ position: [0, 1, -3],
9
+ rotation: [0, 0, 0],
10
+ scale: 1,
11
+ materialType: 'unlit'
12
+ };
13
+ const mockWebcam = {
14
+ id: 'test-webcam',
15
+ enabled: true,
16
+ startFrame: 0,
17
+ endFrame: 100,
18
+ position: [0, 0, -3.5],
19
+ rotation: [0, 0, 0],
20
+ scale: 2.0,
21
+ referenceHeight: 170,
22
+ framingType: 'full',
23
+ defaultMethod: 'chromaKey',
24
+ defaultChromaKeySettings: {
25
+ tolerance: 0.5,
26
+ color: 'green'
27
+ }
28
+ };
29
+ describe('isAdVisibleAtFrame', () => {
30
+ it('startFrame is inclusive', () => {
31
+ expect(isAdVisibleAtFrame(mockAd, 10)).toBe(true);
32
+ });
33
+ it('endFrame is exclusive', () => {
34
+ expect(isAdVisibleAtFrame(mockAd, 50)).toBe(false);
35
+ });
36
+ });
37
+ describe('isWebcamVisibleAtFrame', () => {
38
+ it('returns false for undefined webcam', () => {
39
+ expect(isWebcamVisibleAtFrame(undefined, 5)).toBe(false);
40
+ });
41
+ it('returns false for disabled webcam', () => {
42
+ const disabled = { ...mockWebcam, enabled: false };
43
+ expect(isWebcamVisibleAtFrame(disabled, 5)).toBe(false);
44
+ });
45
+ });
46
+ describe('calculateParticipantScale', () => {
47
+ it('shorter participant returns < 1.0', () => {
48
+ const scale = calculateParticipantScale(180, 160);
49
+ expect(scale).toBeCloseTo(0.889, 3);
50
+ });
51
+ it('zero reference height returns 1.0', () => {
52
+ expect(calculateParticipantScale(0, 170)).toBe(1.0);
53
+ });
54
+ });
55
+ describe('createRuntimeStateFromTemplate', () => {
56
+ it('propagates defaultMethod and defaultChromaKeySettings', () => {
57
+ const runtime = createRuntimeStateFromTemplate(mockWebcam);
58
+ expect(runtime.method).toBe('chromaKey');
59
+ expect(runtime.chromaKeySettings.tolerance).toBe(0.5);
60
+ expect(runtime.chromaKeySettings.color).toBe('green');
61
+ // Non-overridden fields should be defaults
62
+ expect(runtime.chromaKeySettings.spillSuppression).toBe(DEFAULT_CHROMA_KEY_SETTINGS.spillSuppression);
63
+ expect(runtime.aiSettings).toEqual(DEFAULT_AI_SEGMENTATION_SETTINGS);
64
+ });
65
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Perspective Transform Utilities
3
+ *
4
+ * Computes CSS matrix3d() transforms from corner points for
5
+ * perspective-correct overlay rendering.
6
+ *
7
+ * @see https://github.com/uMe-Group/Includu/issues/58
8
+ */
9
+ import type { CornerPoints } from './index';
10
+ /**
11
+ * Compute a CSS matrix3d transform from source to destination quad
12
+ *
13
+ * This implements the homography calculation to map a unit square
14
+ * to an arbitrary quadrilateral defined by four corner points.
15
+ *
16
+ * @param srcWidth - Source element width in pixels
17
+ * @param srcHeight - Source element height in pixels
18
+ * @param corners - Destination corner points (normalized 0-1)
19
+ * @param containerWidth - Container width in pixels
20
+ * @param containerHeight - Container height in pixels
21
+ * @returns CSS matrix3d() string or null if no transform needed
22
+ */
23
+ export declare function computeMatrix3d(srcWidth: number, srcHeight: number, corners: CornerPoints, containerWidth: number, containerHeight: number): string | null;
24
+ /**
25
+ * Check if corners form a valid (non-degenerate) quadrilateral
26
+ */
27
+ export declare function isValidQuad(corners: CornerPoints): boolean;
28
+ //# sourceMappingURL=perspective.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"perspective.d.ts","sourceRoot":"","sources":["../src/perspective.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC9B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,YAAY,EACrB,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,MAAM,GACrB,MAAM,GAAG,IAAI,CA8Cf;AAuFD;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAiC1D"}
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Perspective Transform Utilities
3
+ *
4
+ * Computes CSS matrix3d() transforms from corner points for
5
+ * perspective-correct overlay rendering.
6
+ *
7
+ * @see https://github.com/uMe-Group/Includu/issues/58
8
+ */
9
+ /**
10
+ * Compute a CSS matrix3d transform from source to destination quad
11
+ *
12
+ * This implements the homography calculation to map a unit square
13
+ * to an arbitrary quadrilateral defined by four corner points.
14
+ *
15
+ * @param srcWidth - Source element width in pixels
16
+ * @param srcHeight - Source element height in pixels
17
+ * @param corners - Destination corner points (normalized 0-1)
18
+ * @param containerWidth - Container width in pixels
19
+ * @param containerHeight - Container height in pixels
20
+ * @returns CSS matrix3d() string or null if no transform needed
21
+ */
22
+ export function computeMatrix3d(srcWidth, srcHeight, corners, containerWidth, containerHeight) {
23
+ // Convert normalized corners to pixel positions
24
+ const dst = {
25
+ tl: { x: corners.tl.x * containerWidth, y: corners.tl.y * containerHeight },
26
+ tr: { x: corners.tr.x * containerWidth, y: corners.tr.y * containerHeight },
27
+ br: { x: corners.br.x * containerWidth, y: corners.br.y * containerHeight },
28
+ bl: { x: corners.bl.x * containerWidth, y: corners.bl.y * containerHeight }
29
+ };
30
+ // Source corners (element at origin with given dimensions)
31
+ const src = {
32
+ tl: { x: 0, y: 0 },
33
+ tr: { x: srcWidth, y: 0 },
34
+ br: { x: srcWidth, y: srcHeight },
35
+ bl: { x: 0, y: srcHeight }
36
+ };
37
+ // Compute the 3x3 homography matrix
38
+ // Using the adjugate method for 2D perspective transform
39
+ const matrix = computeHomography(src.tl.x, src.tl.y, src.tr.x, src.tr.y, src.br.x, src.br.y, src.bl.x, src.bl.y, dst.tl.x, dst.tl.y, dst.tr.x, dst.tr.y, dst.br.x, dst.br.y, dst.bl.x, dst.bl.y);
40
+ if (!matrix)
41
+ return null;
42
+ // Convert 3x3 homography to 4x4 matrix for CSS matrix3d
43
+ // CSS matrix3d expects column-major order:
44
+ // matrix3d(a1, a2, a3, a4, b1, b2, b3, b4, c1, c2, c3, c4, d1, d2, d3, d4)
45
+ const [h11, h12, h13, h21, h22, h23, h31, h32, h33] = matrix;
46
+ // 3x3 to 4x4 embedding (z = 0 plane)
47
+ const m3d = [
48
+ h11, h21, 0, h31,
49
+ h12, h22, 0, h32,
50
+ 0, 0, 1, 0,
51
+ h13, h23, 0, h33
52
+ ];
53
+ return `matrix3d(${m3d.join(', ')})`;
54
+ }
55
+ /**
56
+ * Compute 3x3 homography matrix that maps one quad to another
57
+ *
58
+ * Uses the Direct Linear Transform (DLT) method with normalization.
59
+ */
60
+ function computeHomography(x0s, y0s, // source top-left
61
+ x1s, y1s, // source top-right
62
+ x2s, y2s, // source bottom-right
63
+ x3s, y3s, // source bottom-left
64
+ x0d, y0d, // dest top-left
65
+ x1d, y1d, // dest top-right
66
+ x2d, y2d, // dest bottom-right
67
+ x3d, y3d // dest bottom-left
68
+ ) {
69
+ // Set up the 8x8 matrix equation Ah = 0
70
+ // Using the standard DLT formulation
71
+ const A = [
72
+ [x0s, y0s, 1, 0, 0, 0, -x0d * x0s, -x0d * y0s],
73
+ [0, 0, 0, x0s, y0s, 1, -y0d * x0s, -y0d * y0s],
74
+ [x1s, y1s, 1, 0, 0, 0, -x1d * x1s, -x1d * y1s],
75
+ [0, 0, 0, x1s, y1s, 1, -y1d * x1s, -y1d * y1s],
76
+ [x2s, y2s, 1, 0, 0, 0, -x2d * x2s, -x2d * y2s],
77
+ [0, 0, 0, x2s, y2s, 1, -y2d * x2s, -y2d * y2s],
78
+ [x3s, y3s, 1, 0, 0, 0, -x3d * x3s, -x3d * y3s],
79
+ [0, 0, 0, x3s, y3s, 1, -y3d * x3s, -y3d * y3s]
80
+ ];
81
+ const b = [x0d, y0d, x1d, y1d, x2d, y2d, x3d, y3d];
82
+ // Solve using Gaussian elimination
83
+ const h = solveLinearSystem(A, b);
84
+ if (!h)
85
+ return null;
86
+ // Return 3x3 matrix as flat array (row-major)
87
+ return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
88
+ }
89
+ /**
90
+ * Solve a linear system Ax = b using Gaussian elimination with partial pivoting
91
+ */
92
+ function solveLinearSystem(A, b) {
93
+ const n = A.length;
94
+ const augmented = A.map((row, i) => [...row, b[i]]);
95
+ // Forward elimination with partial pivoting
96
+ for (let col = 0; col < n; col++) {
97
+ // Find pivot
98
+ let maxRow = col;
99
+ for (let row = col + 1; row < n; row++) {
100
+ if (Math.abs(augmented[row][col]) > Math.abs(augmented[maxRow][col])) {
101
+ maxRow = row;
102
+ }
103
+ }
104
+ // Swap rows
105
+ [augmented[col], augmented[maxRow]] = [augmented[maxRow], augmented[col]];
106
+ // Check for singular matrix
107
+ if (Math.abs(augmented[col][col]) < 1e-10) {
108
+ return null;
109
+ }
110
+ // Eliminate column
111
+ for (let row = col + 1; row < n; row++) {
112
+ const factor = augmented[row][col] / augmented[col][col];
113
+ for (let j = col; j <= n; j++) {
114
+ augmented[row][j] -= factor * augmented[col][j];
115
+ }
116
+ }
117
+ }
118
+ // Back substitution
119
+ const x = new Array(n).fill(0);
120
+ for (let row = n - 1; row >= 0; row--) {
121
+ let sum = augmented[row][n];
122
+ for (let col = row + 1; col < n; col++) {
123
+ sum -= augmented[row][col] * x[col];
124
+ }
125
+ x[row] = sum / augmented[row][row];
126
+ }
127
+ return x;
128
+ }
129
+ /**
130
+ * Check if corners form a valid (non-degenerate) quadrilateral
131
+ */
132
+ export function isValidQuad(corners) {
133
+ // Check that no two corners are at the same position
134
+ const points = [corners.tl, corners.tr, corners.br, corners.bl];
135
+ for (let i = 0; i < points.length; i++) {
136
+ for (let j = i + 1; j < points.length; j++) {
137
+ const dx = points[i].x - points[j].x;
138
+ const dy = points[i].y - points[j].y;
139
+ if (Math.sqrt(dx * dx + dy * dy) < 0.01) {
140
+ return false;
141
+ }
142
+ }
143
+ }
144
+ // Check that the quadrilateral is convex (no self-intersection)
145
+ // by checking the sign of cross products
146
+ const cross = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
147
+ const signs = [
148
+ cross(corners.tl.x, corners.tl.y, corners.tr.x, corners.tr.y, corners.br.x, corners.br.y),
149
+ cross(corners.tr.x, corners.tr.y, corners.br.x, corners.br.y, corners.bl.x, corners.bl.y),
150
+ cross(corners.br.x, corners.br.y, corners.bl.x, corners.bl.y, corners.tl.x, corners.tl.y),
151
+ cross(corners.bl.x, corners.bl.y, corners.tl.x, corners.tl.y, corners.tr.x, corners.tr.y)
152
+ ];
153
+ // All signs should be the same for a convex quad
154
+ const allPositive = signs.every((s) => s > 0);
155
+ const allNegative = signs.every((s) => s < 0);
156
+ return allPositive || allNegative;
157
+ }