@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.
- package/README.md +37 -0
- package/dist/adserving.d.ts +150 -0
- package/dist/adserving.d.ts.map +1 -0
- package/dist/adserving.js +8 -0
- package/dist/campaigns.d.ts +37 -0
- package/dist/campaigns.d.ts.map +1 -0
- package/dist/campaigns.js +8 -0
- package/dist/gausst.d.ts +236 -0
- package/dist/gausst.d.ts.map +1 -0
- package/dist/gausst.js +307 -0
- package/dist/gausst.test.d.ts +2 -0
- package/dist/gausst.test.d.ts.map +1 -0
- package/dist/gausst.test.js +71 -0
- package/dist/index.d.ts +1531 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1112 -0
- package/dist/layer2/index.d.ts +9 -0
- package/dist/layer2/index.d.ts.map +1 -0
- package/dist/layer2/index.js +10 -0
- package/dist/layer2/shaders.d.ts +185 -0
- package/dist/layer2/shaders.d.ts.map +1 -0
- package/dist/layer2/shaders.js +604 -0
- package/dist/layer2/webcam-utils.d.ts +113 -0
- package/dist/layer2/webcam-utils.d.ts.map +1 -0
- package/dist/layer2/webcam-utils.js +147 -0
- package/dist/layer2/webcam-utils.test.d.ts +2 -0
- package/dist/layer2/webcam-utils.test.d.ts.map +1 -0
- package/dist/layer2/webcam-utils.test.js +18 -0
- package/dist/layer2.d.ts +558 -0
- package/dist/layer2.d.ts.map +1 -0
- package/dist/layer2.js +376 -0
- package/dist/layer2.test.d.ts +2 -0
- package/dist/layer2.test.d.ts.map +1 -0
- package/dist/layer2.test.js +65 -0
- package/dist/perspective.d.ts +28 -0
- package/dist/perspective.d.ts.map +1 -0
- package/dist/perspective.js +157 -0
- package/dist/segmentation/MediaPipeSegmenter.d.ts +201 -0
- package/dist/segmentation/MediaPipeSegmenter.d.ts.map +1 -0
- package/dist/segmentation/MediaPipeSegmenter.js +434 -0
- package/dist/segmentation/index.d.ts +5 -0
- package/dist/segmentation/index.d.ts.map +1 -0
- package/dist/segmentation/index.js +4 -0
- package/dist/webcam/GarbageMatteDragManager.d.ts +63 -0
- package/dist/webcam/GarbageMatteDragManager.d.ts.map +1 -0
- package/dist/webcam/GarbageMatteDragManager.js +183 -0
- package/dist/webcam/WebcamStreamManager.d.ts +103 -0
- package/dist/webcam/WebcamStreamManager.d.ts.map +1 -0
- package/dist/webcam/WebcamStreamManager.js +356 -0
- package/dist/webcam/index.d.ts +5 -0
- package/dist/webcam/index.d.ts.map +1 -0
- package/dist/webcam/index.js +2 -0
- package/openapi/admetise.yaml +632 -0
- package/openapi/includu.yaml +621 -0
- package/openapi/integration.yaml +372 -0
- package/openapi/shared/schemas.yaml +227 -0
- 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 @@
|
|
|
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
|
+
}
|