@wonderlandengine/ar-provider-zappar 1.0.0 → 1.1.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/README.md CHANGED
@@ -19,6 +19,14 @@ This package depends on `@zappar/zappar`; make sure your bundler is configured
19
19
  to serve the `zcv.wasm` asset as described in the
20
20
  [Zappar installation guide](https://docs.zap.works/universal-ar/javascript/getting-started/installation/).
21
21
 
22
+ For Wonderland Editor publishing and image-target training workflows, see the
23
+ companion package
24
+ [`@wonderlandengine/zappar-publish-plugin`](https://www.npmjs.com/package/@wonderlandengine/zappar-publish-plugin).
25
+
26
+ > **Note:** The provider currently keeps tracking active but does not draw the
27
+ > Zappar camera feed to the Wonderland canvas. If you require a background
28
+ > video, composite it manually in your application.
29
+
22
30
  ## Supported Tracking Modes
23
31
 
24
32
  The Zappar provider implements SLAM, face tracking, and image tracking. These
@@ -26,9 +34,9 @@ map to the standard Wonderland Engine components supplied by
26
34
  `@wonderlandengine/ar-tracking` (`ARSLAMCamera`, `ARFaceTrackingCamera`, and
27
35
  `ARImageTrackingCamera`).
28
36
 
29
- - Face tracking loads Zappar's default model and mesh. Attachment points that
30
- have no exact landmark are approximated to the closest available Zappar
31
- landmark.
37
+ - Face tracking loads Zappar's default model and mesh. Attachment points that
38
+ have no exact landmark are approximated to the closest available Zappar
39
+ landmark.
32
40
 
33
41
  ## Recording a Zappar Sequence (Camera + Motion)
34
42
 
@@ -65,8 +73,8 @@ URL.revokeObjectURL(a.href);
65
73
 
66
74
  Notes:
67
75
 
68
- - Sequence recording captures camera + IMU, so it’s best done on a mobile device.
69
- - You can replay sequences using Zappar’s `SequenceSource` API (preferred over MP4 replay for correct tracking).
76
+ - Sequence recording captures camera + IMU, so it’s best done on a mobile device.
77
+ - You can replay sequences using Zappar’s `SequenceSource` API (preferred over MP4 replay for correct tracking).
70
78
 
71
79
  ### Registering Image Targets
72
80
 
@@ -93,3 +101,10 @@ await provider.registerImageTarget('/static/targets/poster.zpt', posterTarget);
93
101
 
94
102
  Once registered, the provider emits image scanning and tracking events via
95
103
  `ARImageTrackingCamera`.
104
+
105
+ ## Related Packages
106
+
107
+ - [`@wonderlandengine/ar-tracking`](https://www.npmjs.com/package/@wonderlandengine/ar-tracking)
108
+ Core AR abstractions and camera components.
109
+ - [`@wonderlandengine/zappar-publish-plugin`](https://www.npmjs.com/package/@wonderlandengine/zappar-publish-plugin)
110
+ Wonderland Editor plugin for ZapWorks upload/publish and target training.
@@ -1,5 +1,8 @@
1
1
  import { Emitter } from '@wonderlandengine/api';
2
- import { FaceFoundEvent, FaceLoadingEvent, FaceLostEvent, FaceTrackingMode, TrackingMode } from '@wonderlandengine/ar-tracking';
2
+ import { FaceAttachmentPoint, FaceFoundEvent, FaceLoadingEvent, FaceLostEvent, FaceTrackingMode, TrackingMode } from '@wonderlandengine/ar-tracking';
3
+ type AttachmentMapping = Partial<Record<FaceAttachmentPoint, string>>;
4
+ export declare const AttachmentLandmarkKeys: AttachmentMapping;
5
+ export declare function buildFaceLoadingEventFromMesh(maxDetections: number, verticesArray: ArrayLike<number>, indicesArray: ArrayLike<number>, uvsArray: ArrayLike<number>): FaceLoadingEvent;
3
6
  export declare class FaceTracking_Zappar extends TrackingMode implements FaceTrackingMode {
4
7
  private _zappar;
5
8
  private _view?;
@@ -19,6 +22,7 @@ export declare class FaceTracking_Zappar extends TrackingMode implements FaceTra
19
22
  private readonly _scratchPosition;
20
23
  private readonly _scratchRotation;
21
24
  private readonly _scratchScale;
25
+ private readonly _providerRotation;
22
26
  private _loadingEvent;
23
27
  readonly onFaceScanning: Emitter<[event: FaceLoadingEvent]>;
24
28
  readonly onFaceLoading: Emitter<[event: FaceLoadingEvent]>;
@@ -36,5 +40,7 @@ export declare class FaceTracking_Zappar extends TrackingMode implements FaceTra
36
40
  private _handleAnchorNotVisible;
37
41
  private _buildFaceEvent;
38
42
  private _applyCameraPose;
43
+ private _setProjectionMatrixWithEngineRemap;
39
44
  private _anchorNumericId;
40
45
  }
46
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { Emitter } from '@wonderlandengine/api';
2
2
  import { FaceAttachmentPoint, TrackingMode, } from '@wonderlandengine/ar-tracking';
3
3
  import { mat4, quat, vec3 } from 'gl-matrix';
4
- const AttachmentLandmarkKeys = {
4
+ export const AttachmentLandmarkKeys = {
5
5
  [FaceAttachmentPoint.Forehead]: 'NOSE_BRIDGE',
6
6
  [FaceAttachmentPoint.EyeOuterCornerLeft]: 'EYE_LEFT',
7
7
  [FaceAttachmentPoint.EyeOuterCornerRight]: 'EYE_RIGHT',
@@ -27,6 +27,39 @@ const AttachmentLandmarkKeys = {
27
27
  [FaceAttachmentPoint.Chin]: 'CHIN',
28
28
  };
29
29
  const AttachmentList = Object.values(FaceAttachmentPoint);
30
+ const FACE_LOCAL_Y_180 = quat.fromValues(0, 1, 0, 0);
31
+ function convertFaceLocalVectorToProviderSpace(x, y, z) {
32
+ // Zappar face-local basis differs from our provider contract by a 180° yaw.
33
+ return { x: -x, y, z: -z };
34
+ }
35
+ function convertFaceLocalRotationToProviderSpace(rotation) {
36
+ // If local coordinates are rotated by C, then world rotation must become R' = R * C
37
+ // to preserve world-space geometry.
38
+ const out = quat.create();
39
+ quat.multiply(out, rotation, FACE_LOCAL_Y_180);
40
+ quat.normalize(out, out);
41
+ return out;
42
+ }
43
+ export function buildFaceLoadingEventFromMesh(maxDetections, verticesArray, indicesArray, uvsArray) {
44
+ const indices = [];
45
+ for (let i = 0; i < indicesArray.length; i += 3) {
46
+ indices.push({
47
+ a: indicesArray[i],
48
+ b: indicesArray[i + 2],
49
+ c: indicesArray[i + 1],
50
+ });
51
+ }
52
+ const uvs = [];
53
+ for (let i = 0; i < uvsArray.length; i += 2) {
54
+ uvs.push({ u: 1 - uvsArray[i], v: 1 - uvsArray[i + 1] });
55
+ }
56
+ return {
57
+ maxDetections,
58
+ pointsPerDetection: verticesArray.length / 3,
59
+ indices: indices,
60
+ uvs: uvs,
61
+ };
62
+ }
30
63
  export class FaceTracking_Zappar extends TrackingMode {
31
64
  _zappar = null;
32
65
  _view;
@@ -46,6 +79,7 @@ export class FaceTracking_Zappar extends TrackingMode {
46
79
  _scratchPosition = vec3.create();
47
80
  _scratchRotation = quat.create();
48
81
  _scratchScale = vec3.create();
82
+ _providerRotation = quat.create();
49
83
  _loadingEvent = null;
50
84
  onFaceScanning = new Emitter();
51
85
  onFaceLoading = new Emitter();
@@ -54,6 +88,15 @@ export class FaceTracking_Zappar extends TrackingMode {
54
88
  onFaceLost = new Emitter();
55
89
  init() {
56
90
  this._view = this.component.object.getComponent('view') ?? undefined;
91
+ const provider = this.provider;
92
+ const faceComponent = this.component;
93
+ if (typeof faceComponent.cameraDirection === 'number') {
94
+ // ARFaceTrackingCamera enum is ['front', 'back']
95
+ provider.setPreferredCameraUserFacing(faceComponent.cameraDirection === 0);
96
+ }
97
+ else {
98
+ provider.setPreferredCameraUserFacing(true);
99
+ }
57
100
  const input = this.component.object.getComponent('input');
58
101
  if (input) {
59
102
  input.active = false;
@@ -77,14 +120,17 @@ export class FaceTracking_Zappar extends TrackingMode {
77
120
  return;
78
121
  const provider = this.provider;
79
122
  const pipeline = provider.getPipeline();
80
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height);
123
+ const mirrorPoses = pipeline.cameraFrameUserFacing();
124
+ const viewNear = this._view?.near;
125
+ const viewFar = this._view?.far;
126
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
81
127
  if (this._view) {
82
- this._view.projectionMatrix.set(projectionMatrix);
128
+ this._setProjectionMatrixWithEngineRemap(projectionMatrix);
83
129
  }
84
130
  const cameraPose = pipeline.cameraPoseDefault();
85
131
  this._applyCameraPose(cameraPose);
86
132
  for (const anchor of this._faceTracker.visible) {
87
- const event = this._buildFaceEvent(anchor);
133
+ const event = this._buildFaceEvent(anchor, mirrorPoses);
88
134
  this.onFaceUpdate.notify(event);
89
135
  }
90
136
  }
@@ -133,91 +179,66 @@ export class FaceTracking_Zappar extends TrackingMode {
133
179
  if (!this._faceTracker || !this._faceMesh) {
134
180
  return null;
135
181
  }
136
- const indicesArray = this._faceMesh.indices;
137
- const uvsArray = this._faceMesh.uvs;
138
- const verticesArray = this._faceMesh.vertices;
139
- const indices = [];
140
- for (let i = 0; i < indicesArray.length; i += 3) {
141
- indices.push({
142
- a: indicesArray[i],
143
- b: indicesArray[i + 1],
144
- c: indicesArray[i + 2],
145
- });
146
- }
147
- const uvs = [];
148
- for (let i = 0; i < uvsArray.length; i += 2) {
149
- uvs.push({ u: uvsArray[i], v: uvsArray[i + 1] });
150
- }
151
- return {
152
- maxDetections: this._faceTracker.maxFaces,
153
- pointsPerDetection: verticesArray.length / 3,
154
- indices: indices,
155
- uvs: uvs,
156
- };
182
+ return buildFaceLoadingEventFromMesh(this._faceTracker.maxFaces, this._faceMesh.vertices, this._faceMesh.indices, this._faceMesh.uvs);
157
183
  }
158
184
  _handleAnchorVisible = (anchor) => {
159
185
  if (!this._resourcesReady) {
160
186
  return;
161
187
  }
162
- const event = this._buildFaceEvent(anchor);
188
+ const provider = this.provider;
189
+ const pipeline = provider.getPipeline();
190
+ const mirrorPoses = pipeline.cameraFrameUserFacing();
191
+ const event = this._buildFaceEvent(anchor, mirrorPoses);
163
192
  this.onFaceFound.notify(event);
164
193
  };
165
194
  _handleAnchorNotVisible = (anchor) => {
166
195
  const id = this._anchorNumericId(anchor.id);
167
196
  this.onFaceLost.notify({ id });
168
197
  };
169
- _buildFaceEvent(anchor) {
198
+ _buildFaceEvent(anchor, mirrorPoses) {
170
199
  const provider = this.provider;
171
200
  const pipeline = provider.getPipeline();
172
201
  const cameraPose = pipeline.cameraPoseDefault();
173
- const anchorPose = anchor.pose(cameraPose, false);
202
+ const anchorPose = anchor.pose(cameraPose, mirrorPoses);
174
203
  mat4.copy(this._scratchMatrix, anchorPose);
175
204
  mat4.getTranslation(this._scratchPosition, this._scratchMatrix);
176
205
  mat4.getRotation(this._scratchRotation, this._scratchMatrix);
177
206
  mat4.getScaling(this._scratchScale, this._scratchMatrix);
207
+ quat.copy(this._providerRotation, convertFaceLocalRotationToProviderSpace(this._scratchRotation));
208
+ this._scratchScale[0] = Math.abs(this._scratchScale[0]);
209
+ this._scratchScale[1] = Math.abs(this._scratchScale[1]);
210
+ this._scratchScale[2] = Math.abs(this._scratchScale[2]);
178
211
  const scale = (this._scratchScale[0] + this._scratchScale[1] + this._scratchScale[2]) / 3;
179
212
  const anchorPosition = {
180
213
  x: this._scratchPosition[0],
181
214
  y: this._scratchPosition[1],
182
215
  z: this._scratchPosition[2],
183
216
  };
184
- this._faceMesh.updateFromFaceAnchor(anchor, false);
217
+ this._faceMesh.updateFromFaceAnchor(anchor, mirrorPoses);
185
218
  const verticesArray = this._faceMesh.vertices;
186
219
  const normalsArray = this._faceMesh.normals;
187
220
  const vertices = [];
188
221
  for (let i = 0; i < verticesArray.length; i += 3) {
189
- vertices.push({
190
- x: verticesArray[i],
191
- y: verticesArray[i + 1],
192
- z: verticesArray[i + 2],
193
- });
222
+ vertices.push(convertFaceLocalVectorToProviderSpace(verticesArray[i], verticesArray[i + 1], verticesArray[i + 2]));
194
223
  }
195
224
  const normals = [];
196
225
  for (let i = 0; i < normalsArray.length; i += 3) {
197
- normals.push({
198
- x: normalsArray[i],
199
- y: normalsArray[i + 1],
200
- z: normalsArray[i + 2],
201
- });
226
+ normals.push(convertFaceLocalVectorToProviderSpace(normalsArray[i], normalsArray[i + 1], normalsArray[i + 2]));
202
227
  }
203
228
  const attachmentPoints = {};
204
229
  for (const attachment of AttachmentList) {
205
230
  const landmark = this._landmarks.get(attachment);
206
231
  if (landmark) {
207
- landmark.updateFromFaceAnchor(anchor, false);
232
+ landmark.updateFromFaceAnchor(anchor, mirrorPoses);
208
233
  mat4.copy(this._scratchMatrix, landmark.pose);
209
234
  mat4.getTranslation(this._scratchPosition, this._scratchMatrix);
210
235
  attachmentPoints[attachment] = {
211
- position: {
212
- x: this._scratchPosition[0],
213
- y: this._scratchPosition[1],
214
- z: this._scratchPosition[2],
215
- },
236
+ position: convertFaceLocalVectorToProviderSpace(this._scratchPosition[0], this._scratchPosition[1], this._scratchPosition[2]),
216
237
  };
217
238
  }
218
239
  else {
219
240
  attachmentPoints[attachment] = {
220
- position: { ...anchorPosition },
241
+ position: convertFaceLocalVectorToProviderSpace(0, 0, 0),
221
242
  };
222
243
  }
223
244
  }
@@ -230,10 +251,10 @@ export class FaceTracking_Zappar extends TrackingMode {
230
251
  transform: {
231
252
  position: { ...anchorPosition },
232
253
  rotation: {
233
- x: this._scratchRotation[0],
234
- y: this._scratchRotation[1],
235
- z: this._scratchRotation[2],
236
- w: this._scratchRotation[3],
254
+ x: this._providerRotation[0],
255
+ y: this._providerRotation[1],
256
+ z: this._providerRotation[2],
257
+ w: this._providerRotation[3],
237
258
  },
238
259
  scale,
239
260
  scaledWidth: this._scratchScale[0],
@@ -250,6 +271,64 @@ export class FaceTracking_Zappar extends TrackingMode {
250
271
  this.component.object.setPositionWorld(this._cameraPosition);
251
272
  this.component.object.setRotationWorld(this._cameraRotation);
252
273
  }
274
+ _setProjectionMatrixWithEngineRemap(matrix) {
275
+ const debugWindow = globalThis;
276
+ const view = this._view;
277
+ if (!view) {
278
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
279
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
280
+ mode: 'face',
281
+ viewId: null,
282
+ reverseZ: false,
283
+ status: 'skipped',
284
+ reason: 'no-view',
285
+ };
286
+ }
287
+ return;
288
+ }
289
+ if (typeof view._setProjectionMatrix === 'function') {
290
+ view._setProjectionMatrix(matrix);
291
+ }
292
+ else {
293
+ view.projectionMatrix.set(matrix);
294
+ }
295
+ const engineAny = this.component.engine;
296
+ if (typeof view._id !== 'number') {
297
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
298
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
299
+ mode: 'face',
300
+ viewId: null,
301
+ reverseZ: engineAny.isReverseZEnabled,
302
+ status: 'skipped',
303
+ reason: 'missing-view-id',
304
+ };
305
+ }
306
+ return;
307
+ }
308
+ if (typeof engineAny.wasm?._wl_view_component_remapProjectionMatrix ===
309
+ 'function') {
310
+ const ndcDepthIsZeroToOne = false;
311
+ engineAny.wasm._wl_view_component_remapProjectionMatrix(view._id, engineAny.isReverseZEnabled, ndcDepthIsZeroToOne);
312
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
313
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
314
+ mode: 'face',
315
+ viewId: view._id,
316
+ reverseZ: engineAny.isReverseZEnabled,
317
+ status: 'applied',
318
+ };
319
+ }
320
+ return;
321
+ }
322
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
323
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
324
+ mode: 'face',
325
+ viewId: view._id,
326
+ reverseZ: engineAny.isReverseZEnabled,
327
+ status: 'skipped',
328
+ reason: 'missing-wasm-remap-function',
329
+ };
330
+ }
331
+ }
253
332
  _anchorNumericId(anchorId) {
254
333
  let id = this._anchorNumericIds.get(anchorId);
255
334
  if (id === undefined) {
@@ -32,4 +32,5 @@ export declare class ImageTracking_Zappar extends TrackingMode implements ImageT
32
32
  private _buildImageEvent;
33
33
  private _guessDescriptor;
34
34
  private _applyCameraPose;
35
+ private _setProjectionMatrixWithEngineRemap;
35
36
  }
@@ -23,6 +23,8 @@ export class ImageTracking_Zappar extends TrackingMode {
23
23
  onImageLost = new Emitter();
24
24
  init() {
25
25
  this._view = this.component.object.getComponent('view') ?? undefined;
26
+ const provider = this.provider;
27
+ provider.setPreferredCameraUserFacing(false);
26
28
  const input = this.component.object.getComponent('input');
27
29
  if (input) {
28
30
  input.active = false;
@@ -46,9 +48,11 @@ export class ImageTracking_Zappar extends TrackingMode {
46
48
  return;
47
49
  const provider = this.provider;
48
50
  const pipeline = provider.getPipeline();
49
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height);
51
+ const viewNear = this._view?.near;
52
+ const viewFar = this._view?.far;
53
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
50
54
  if (this._view) {
51
- this._view.projectionMatrix.set(projectionMatrix);
55
+ this._setProjectionMatrixWithEngineRemap(projectionMatrix);
52
56
  }
53
57
  const cameraPose = pipeline.cameraPoseDefault();
54
58
  this._applyCameraPose(cameraPose);
@@ -170,4 +174,61 @@ export class ImageTracking_Zappar extends TrackingMode {
170
174
  this.component.object.setPositionWorld(this._cameraPosition);
171
175
  this.component.object.setRotationWorld(this._cameraRotation);
172
176
  }
177
+ _setProjectionMatrixWithEngineRemap(matrix) {
178
+ const debugWindow = globalThis;
179
+ const view = this._view;
180
+ if (!view) {
181
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
182
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
183
+ mode: 'image',
184
+ viewId: null,
185
+ reverseZ: false,
186
+ status: 'skipped',
187
+ reason: 'no-view',
188
+ };
189
+ }
190
+ return;
191
+ }
192
+ if (typeof view._setProjectionMatrix === 'function') {
193
+ view._setProjectionMatrix(matrix);
194
+ }
195
+ else {
196
+ view.projectionMatrix.set(matrix);
197
+ }
198
+ const engineAny = this.component.engine;
199
+ if (typeof view._id !== 'number') {
200
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
201
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
202
+ mode: 'image',
203
+ viewId: null,
204
+ reverseZ: engineAny.isReverseZEnabled,
205
+ status: 'skipped',
206
+ reason: 'missing-view-id',
207
+ };
208
+ }
209
+ return;
210
+ }
211
+ if (typeof engineAny.wasm?._wl_view_component_remapProjectionMatrix === 'function') {
212
+ const ndcDepthIsZeroToOne = false;
213
+ engineAny.wasm._wl_view_component_remapProjectionMatrix(view._id, engineAny.isReverseZEnabled, ndcDepthIsZeroToOne);
214
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
215
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
216
+ mode: 'image',
217
+ viewId: view._id,
218
+ reverseZ: engineAny.isReverseZEnabled,
219
+ status: 'applied',
220
+ };
221
+ }
222
+ return;
223
+ }
224
+ if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
225
+ debugWindow.__WLE_ZAPPAR_LAST_PROJECTION_REMAP__ = {
226
+ mode: 'image',
227
+ viewId: view._id,
228
+ reverseZ: engineAny.isReverseZEnabled,
229
+ status: 'skipped',
230
+ reason: 'missing-wasm-remap-function',
231
+ };
232
+ }
233
+ }
173
234
  }
@@ -10,6 +10,8 @@ export class WorldTracking_Zappar extends TrackingMode {
10
10
  super(provider, component);
11
11
  }
12
12
  init() {
13
+ const provider = this.provider;
14
+ provider.setPreferredCameraUserFacing(false);
13
15
  const input = this.component.object.getComponent('input');
14
16
  if (input) {
15
17
  // Camera pose will be driven by the AR tracking implementation.
@@ -37,6 +37,8 @@ export declare class ZapparProvider extends ARProvider {
37
37
  private _faceMesh;
38
38
  private _faceResourcesPromise;
39
39
  private _imageTracker;
40
+ private _preferredCameraUserFacing;
41
+ private _cameraSourceUserFacing;
40
42
  private _imageTargetDescriptors;
41
43
  private readonly _imageTargetsChanged;
42
44
  private cameraStarted;
@@ -66,14 +68,23 @@ export declare class ZapparProvider extends ARProvider {
66
68
  private readonly _debugLastSampleAnchorPosition;
67
69
  private _zapparDebugLogIntervalId;
68
70
  private _preRenderErrorLogged;
71
+ private get isVerboseDebugLoggingEnabled();
69
72
  static Name: string;
70
73
  get name(): string;
71
74
  get supportsInstantTracking(): boolean;
72
75
  get onImageTargetsChanged(): Emitter<void[]>;
73
76
  get xrSession(): XRSession | null;
77
+ /**
78
+ * Hint camera facing preference for the next/active session.
79
+ * - `true`: user/front camera
80
+ * - `false`: rear/back camera
81
+ * - `null`: Zappar default
82
+ */
83
+ setPreferredCameraUserFacing(userFacing: boolean | null): void;
74
84
  static registerTrackingProviderWithARSession(arSession: ARSession): ZapparProvider;
75
85
  private constructor();
76
86
  startSession(): Promise<void>;
87
+ private ensurePreRenderRegistered;
77
88
  private startZapparDebugLogging;
78
89
  private stopZapparDebugLogging;
79
90
  private ensureZapparLoaded;
@@ -81,6 +92,7 @@ export declare class ZapparProvider extends ARProvider {
81
92
  ensureZapparNamespace(): Promise<Awaited<ReturnType<typeof loadZappar>>>;
82
93
  private configureCvWorkerIfNeeded;
83
94
  private ensurePipeline;
95
+ private ensureCameraSourcePreference;
84
96
  getPipeline(): Pipeline;
85
97
  ensureFaceResources(): Promise<void>;
86
98
  getFaceTracker(): FaceTracker;
@@ -24,6 +24,8 @@ export class ZapparProvider extends ARProvider {
24
24
  _faceMesh = null;
25
25
  _faceResourcesPromise = null;
26
26
  _imageTracker = null;
27
+ _preferredCameraUserFacing = null;
28
+ _cameraSourceUserFacing = null;
27
29
  _imageTargetDescriptors = [];
28
30
  _imageTargetsChanged = new Emitter();
29
31
  cameraStarted = false;
@@ -53,6 +55,14 @@ export class ZapparProvider extends ARProvider {
53
55
  _debugLastSampleAnchorPosition = vec3.create();
54
56
  _zapparDebugLogIntervalId = null;
55
57
  _preRenderErrorLogged = false;
58
+ get isVerboseDebugLoggingEnabled() {
59
+ if (typeof window === 'undefined') {
60
+ return false;
61
+ }
62
+ const debugWindow = window;
63
+ return (debugWindow.__WLE_ZAPPAR_DEBUG__ === true ||
64
+ debugWindow.__ZAPPAR_WORKER_DEBUG__ === true);
65
+ }
56
66
  static Name = 'Zappar';
57
67
  get name() {
58
68
  return ZapparProvider.Name;
@@ -66,6 +76,15 @@ export class ZapparProvider extends ARProvider {
66
76
  get xrSession() {
67
77
  return this._xrSession;
68
78
  }
79
+ /**
80
+ * Hint camera facing preference for the next/active session.
81
+ * - `true`: user/front camera
82
+ * - `false`: rear/back camera
83
+ * - `null`: Zappar default
84
+ */
85
+ setPreferredCameraUserFacing(userFacing) {
86
+ this._preferredCameraUserFacing = userFacing;
87
+ }
69
88
  static registerTrackingProviderWithARSession(arSession) {
70
89
  const provider = new ZapparProvider(arSession.engine);
71
90
  arSession.registerTrackingProvider(provider);
@@ -78,10 +97,9 @@ export class ZapparProvider extends ARProvider {
78
97
  }
79
98
  engine.onXRSessionStart.add((session) => {
80
99
  this._xrSession = session;
81
- this.onSessionStart.notify(this);
82
100
  });
83
101
  engine.onXRSessionEnd.add(() => {
84
- this.onSessionEnd.notify(this);
102
+ this._xrSession = null;
85
103
  });
86
104
  }
87
105
  async startSession() {
@@ -93,8 +111,15 @@ export class ZapparProvider extends ARProvider {
93
111
  await this.ensureZapparLoaded();
94
112
  await this._zappar.loadedPromise();
95
113
  this.ensurePipeline();
114
+ this.ensureCameraSourcePreference();
96
115
  await this.ensureCameraRunning();
97
116
  this.startZapparDebugLogging();
117
+ if (this._faceTracker) {
118
+ this._faceTracker.enabled = true;
119
+ }
120
+ if (this._imageTracker) {
121
+ this._imageTracker.enabled = true;
122
+ }
98
123
  // For instant tracking providers, we should emit session start here.
99
124
  // (Unlike WebXR, there is no XRSessionStart event.)
100
125
  this.onSessionStart.notify(this);
@@ -102,9 +127,18 @@ export class ZapparProvider extends ARProvider {
102
127
  this.instantTracker.enabled = true;
103
128
  }
104
129
  }
130
+ ensurePreRenderRegistered() {
131
+ if (!this.preRenderRegistered) {
132
+ this.engine.scene.onPreRender.add(this.onPreRender);
133
+ this.preRenderRegistered = true;
134
+ }
135
+ }
105
136
  startZapparDebugLogging() {
106
137
  if (this._zapparDebugLogIntervalId !== null)
107
138
  return;
139
+ if (!this.isVerboseDebugLoggingEnabled) {
140
+ return;
141
+ }
108
142
  const canAccessWindow = typeof window !== 'undefined' &&
109
143
  typeof window.setInterval ===
110
144
  'function';
@@ -157,9 +191,7 @@ export class ZapparProvider extends ARProvider {
157
191
  // Hard-coded paths for local development
158
192
  const workerUrl = './zappar-cv/zappar-cv.worker.js';
159
193
  const wasmUrl = './zappar-cv/zappar-cv.wasm';
160
- const debugWorker = typeof window !== 'undefined' &&
161
- window
162
- .__ZAPPAR_WORKER_DEBUG__;
194
+ const debugWorker = this.isVerboseDebugLoggingEnabled;
163
195
  if (debugWorker) {
164
196
  console.log('[ZapparProvider] configureCvWorkerIfNeeded()', {
165
197
  workerUrl,
@@ -188,21 +220,23 @@ export class ZapparProvider extends ARProvider {
188
220
  const resolvedWasmUrl = typeof window !== 'undefined'
189
221
  ? new URL(wasmUrl, window.location.href).toString()
190
222
  : wasmUrl;
191
- console.log('[ZapparProvider] CV worker created', {
192
- workerUrl,
193
- resolvedWorkerUrl,
194
- wasmUrl,
195
- resolvedWasmUrl,
196
- });
223
+ if (debugWorker) {
224
+ console.log('[ZapparProvider] CV worker created', {
225
+ workerUrl,
226
+ resolvedWorkerUrl,
227
+ wasmUrl,
228
+ resolvedWasmUrl,
229
+ });
230
+ }
197
231
  let workerMessagesSeen = 0;
198
232
  worker.addEventListener('message', (event) => {
199
233
  workerMessagesSeen++;
200
- if (workerMessagesSeen === 1) {
234
+ if (workerMessagesSeen === 1 && debugWorker) {
201
235
  console.log('[ZapparProvider] CV worker first message received', event.data);
202
236
  return;
203
237
  }
204
238
  // Keep a couple more messages for context, but avoid spamming.
205
- if (workerMessagesSeen <= 5) {
239
+ if (debugWorker && workerMessagesSeen <= 5) {
206
240
  console.log('[ZapparProvider] CV worker message', {
207
241
  index: workerMessagesSeen,
208
242
  data: event.data,
@@ -249,8 +283,10 @@ export class ZapparProvider extends ARProvider {
249
283
  }
250
284
  }
251
285
  ensurePipeline() {
252
- if (this.pipeline)
286
+ if (this.pipeline) {
287
+ this.ensurePreRenderRegistered();
253
288
  return;
289
+ }
254
290
  if (!this._zappar) {
255
291
  throw new Error('Zappar is not loaded yet. Call startSession() first.');
256
292
  }
@@ -270,15 +306,27 @@ export class ZapparProvider extends ARProvider {
270
306
  globalThis.ZapparPipeline = pipeline;
271
307
  }
272
308
  if (typeof window !== 'undefined') {
273
- console.log('[ZapparProvider] Using device camera source');
309
+ if (this.isVerboseDebugLoggingEnabled) {
310
+ console.log('[ZapparProvider] Using device camera source');
311
+ }
274
312
  }
275
- const deviceId = Zappar.cameraDefaultDeviceID();
276
- this.cameraSource = new Zappar.CameraSource(pipeline, deviceId);
313
+ this.ensureCameraSourcePreference();
277
314
  this.instantTracker = new Zappar.InstantWorldTracker(pipeline);
278
- if (!this.preRenderRegistered) {
279
- this.engine.scene.onPreRender.add(this.onPreRender);
280
- this.preRenderRegistered = true;
315
+ this.ensurePreRenderRegistered();
316
+ }
317
+ ensureCameraSourcePreference() {
318
+ if (!this.pipeline || !this._zappar) {
319
+ return;
281
320
  }
321
+ const desiredUserFacing = this._preferredCameraUserFacing;
322
+ if (this.cameraSource && this._cameraSourceUserFacing === desiredUserFacing) {
323
+ return;
324
+ }
325
+ const deviceId = desiredUserFacing === null
326
+ ? this._zappar.cameraDefaultDeviceID()
327
+ : this._zappar.cameraDefaultDeviceID(desiredUserFacing);
328
+ this.cameraSource = new this._zappar.CameraSource(this.pipeline, deviceId);
329
+ this._cameraSourceUserFacing = desiredUserFacing;
282
330
  }
283
331
  getPipeline() {
284
332
  if (!this.pipeline) {
@@ -477,15 +525,13 @@ export class ZapparProvider extends ARProvider {
477
525
  if (previousUnpackBuffer)
478
526
  gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null);
479
527
  try {
480
- // Mirror Zappar THREE.js update order:
481
- // - tracking: processGL() -> frameUpdate()
482
- // - texture upload: cameraFrameUploadGL() is performed separately (cameraTexture.ts)
528
+ // Keep GL update order consistent with the non-GL path:
529
+ // process tracking input, upload camera frame texture, then update frame state.
483
530
  this.pipeline.processGL();
531
+ this.pipeline.cameraFrameUploadGL();
484
532
  this.pipeline.frameUpdate();
485
533
  // Update SLAM state every frame (one loop in the provider).
486
534
  this.updateTracking();
487
- // Upload the latest camera frame to a WebGL texture (for background rendering).
488
- this.pipeline.cameraFrameUploadGL();
489
535
  // Bind the Zappar camera texture for Wonderland's sky material.
490
536
  // Wonderland Engine will handle drawing; we only ensure that:
491
537
  // - `videoTexture` sampler uniform points to the last texture unit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderlandengine/ar-provider-zappar",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Wonderland Engine AR tracking provider based on Zappar",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "scripts/**/*.mjs"
33
33
  ],
34
34
  "devDependencies": {
35
- "prettier": "^2.8.4",
35
+ "prettier": "^3.5.3",
36
36
  "typescript": "^4.9.5"
37
37
  },
38
38
  "peerDependencies": {
@@ -1,184 +1,184 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import {createRequire} from 'node:module';
4
- import {fileURLToPath} from 'node:url';
5
-
6
- const require = createRequire(import.meta.url);
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = path.dirname(__filename);
9
-
10
- const logPrefix = '[ar-provider-zappar:postinstall]';
11
-
12
- function log(...args) {
13
- // Keep output minimal but actionable.
14
- console.log(logPrefix, ...args);
15
- }
16
-
17
- function warn(...args) {
18
- console.warn(logPrefix, ...args);
19
- }
20
-
21
- function copyOrThrow(src, dest) {
22
- fs.mkdirSync(path.dirname(dest), {recursive: true});
23
- fs.copyFileSync(src, dest);
24
- }
25
-
26
- function safeLinkOrCopy(src, dest) {
27
- fs.mkdirSync(path.dirname(dest), {recursive: true});
28
- try {
29
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
30
- fs.linkSync(src, dest);
31
- } catch {
32
- try {
33
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
34
- } catch {
35
- // ignore
36
- }
37
- fs.copyFileSync(src, dest);
38
- }
39
- }
40
-
41
- function listFiles(dir) {
42
- try {
43
- return fs.readdirSync(dir, {withFileTypes: true});
44
- } catch {
45
- return [];
46
- }
47
- }
48
-
49
- function findFirstFile(dir, predicate) {
50
- for (const entry of listFiles(dir)) {
51
- if (!entry.isFile()) continue;
52
- if (predicate(entry.name)) return path.join(dir, entry.name);
53
- }
54
- return null;
55
- }
56
-
57
- function findWasmFromWorker(workerPath, umdDir) {
58
- try {
59
- const src = fs.readFileSync(workerPath, 'utf8');
60
- const match = src.match(/["']([^"']+\.wasm)["']/);
61
- if (!match) return null;
62
-
63
- const candidate = path.resolve(umdDir, match[1]);
64
- const relative = path.relative(umdDir, candidate);
65
-
66
- // Ensure we never resolve outside the umd directory.
67
- if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
68
-
69
- return fs.existsSync(candidate) ? candidate : null;
70
- } catch {
71
- return null;
72
- }
73
- }
74
-
75
- function resolveZapparRoot() {
76
- const providerRoot = path.resolve(__dirname, '..');
77
- const zapparPackageJson = require.resolve('@zappar/zappar/package.json', {
78
- paths: [providerRoot],
79
- });
80
- return path.dirname(zapparPackageJson);
81
- }
82
-
83
- function resolveZapparCvRoot() {
84
- const providerRoot = path.resolve(__dirname, '..');
85
- const zapparCvPackageJson = require.resolve('@zappar/zappar-cv/package.json', {
86
- paths: [providerRoot],
87
- });
88
- return path.dirname(zapparCvPackageJson);
89
- }
90
-
91
- function resolveProjectRoot() {
92
- // INIT_CWD is set by npm/yarn/pnpm for lifecycle scripts and points at the
93
- // consumer project that triggered the install.
94
- const initCwd = process.env.INIT_CWD;
95
- if (initCwd && initCwd.trim()) return path.resolve(initCwd);
96
-
97
- // Fallback (best effort): current working directory.
98
- return process.cwd();
99
- }
100
-
101
- function main() {
102
- const zapparRoot = resolveZapparRoot();
103
- const umdDir = path.join(zapparRoot, 'umd');
104
-
105
- const zapparCvRoot = resolveZapparCvRoot();
106
- const zapparCvLibDir = path.join(zapparCvRoot, 'lib');
107
-
108
- const projectRoot = resolveProjectRoot();
109
- const outDir = path.resolve(projectRoot, 'static', 'zappar-cv');
110
-
111
- const workerSource =
112
- findFirstFile(umdDir, (name) => name === 'zappar.worker.js') ??
113
- findFirstFile(umdDir, (name) => name === 'zappar-cv.worker.js') ??
114
- findFirstFile(zapparRoot, (name) => name === 'zappar.worker.js') ??
115
- findFirstFile(zapparRoot, (name) => name === 'zappar-cv.worker.js') ??
116
- null;
117
-
118
- if (!workerSource) {
119
- warn('Could not locate Zappar worker under', umdDir);
120
- warn('Zappar root:', zapparRoot);
121
- return;
122
- }
123
-
124
- const wasmCandidates = listFiles(umdDir)
125
- .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
126
- .map((e) => path.join(umdDir, e.name));
127
-
128
- let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
129
- wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
130
- wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
131
-
132
- if (!wasmSource) {
133
- warn('Could not locate Zappar wasm under', umdDir);
134
- warn('Worker found at:', workerSource);
135
- return;
136
- }
137
-
138
- fs.mkdirSync(outDir, {recursive: true});
139
-
140
- const workerDest = path.join(outDir, 'zappar-cv.worker.js');
141
- copyOrThrow(workerSource, workerDest);
142
-
143
- const wasmBasename = path.basename(wasmSource);
144
- const wasmDest = path.join(outDir, wasmBasename);
145
- copyOrThrow(wasmSource, wasmDest);
146
-
147
- // Create a stable alias that the Wonderland provider code points at.
148
- safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
149
-
150
- // Optional: copy any Zappar binary blobs shipped alongside the wasm.
151
- // (Harmless if unused; avoids runtime 404s if Zappar expects them.)
152
- for (const entry of listFiles(umdDir)) {
153
- if (!entry.isFile()) continue;
154
- if (!entry.name.endsWith('.zbin')) continue;
155
- copyOrThrow(path.join(umdDir, entry.name), path.join(outDir, entry.name));
156
- }
157
-
158
- // Required for face tracking defaults (FaceTracker.loadDefaultModel / FaceMesh.loadDefault*).
159
- // These ship in @zappar/zappar-cv/lib but are fetched at runtime unless staged.
160
- const requiredFaceModelFiles = [
161
- 'face_tracking_model.zbin',
162
- 'face_mesh_face_model.zbin',
163
- ];
164
-
165
- for (const filename of requiredFaceModelFiles) {
166
- const src = path.join(zapparCvLibDir, filename);
167
- const dest = path.join(outDir, filename);
168
- if (!fs.existsSync(src)) {
169
- warn('Could not locate required Zappar face model:', filename);
170
- warn('Looked under:', zapparCvLibDir);
171
- continue;
172
- }
173
- copyOrThrow(src, dest);
174
- }
175
-
176
- log('Copied Zappar CV assets into', outDir);
177
- }
178
-
179
- try {
180
- main();
181
- } catch (e) {
182
- // Postinstall should not hard-fail the whole install; consumers can still override.
183
- warn('Failed to stage Zappar CV assets:', e);
184
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createRequire} from 'node:module';
4
+ import {fileURLToPath} from 'node:url';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const logPrefix = '[ar-provider-zappar:postinstall]';
11
+
12
+ function log(...args) {
13
+ // Keep output minimal but actionable.
14
+ console.log(logPrefix, ...args);
15
+ }
16
+
17
+ function warn(...args) {
18
+ console.warn(logPrefix, ...args);
19
+ }
20
+
21
+ function copyOrThrow(src, dest) {
22
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
23
+ fs.copyFileSync(src, dest);
24
+ }
25
+
26
+ function safeLinkOrCopy(src, dest) {
27
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
28
+ try {
29
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
30
+ fs.linkSync(src, dest);
31
+ } catch {
32
+ try {
33
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
34
+ } catch {
35
+ // ignore
36
+ }
37
+ fs.copyFileSync(src, dest);
38
+ }
39
+ }
40
+
41
+ function listFiles(dir) {
42
+ try {
43
+ return fs.readdirSync(dir, {withFileTypes: true});
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ function findFirstFile(dir, predicate) {
50
+ for (const entry of listFiles(dir)) {
51
+ if (!entry.isFile()) continue;
52
+ if (predicate(entry.name)) return path.join(dir, entry.name);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function findWasmFromWorker(workerPath, umdDir) {
58
+ try {
59
+ const src = fs.readFileSync(workerPath, 'utf8');
60
+ const match = src.match(/["']([^"']+\.wasm)["']/);
61
+ if (!match) return null;
62
+
63
+ const candidate = path.resolve(umdDir, match[1]);
64
+ const relative = path.relative(umdDir, candidate);
65
+
66
+ // Ensure we never resolve outside the umd directory.
67
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
68
+
69
+ return fs.existsSync(candidate) ? candidate : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function resolveZapparRoot() {
76
+ const providerRoot = path.resolve(__dirname, '..');
77
+ const zapparPackageJson = require.resolve('@zappar/zappar/package.json', {
78
+ paths: [providerRoot],
79
+ });
80
+ return path.dirname(zapparPackageJson);
81
+ }
82
+
83
+ function resolveZapparCvRoot() {
84
+ const providerRoot = path.resolve(__dirname, '..');
85
+ const zapparCvPackageJson = require.resolve('@zappar/zappar-cv/package.json', {
86
+ paths: [providerRoot],
87
+ });
88
+ return path.dirname(zapparCvPackageJson);
89
+ }
90
+
91
+ function resolveProjectRoot() {
92
+ // INIT_CWD is set by npm/yarn/pnpm for lifecycle scripts and points at the
93
+ // consumer project that triggered the install.
94
+ const initCwd = process.env.INIT_CWD;
95
+ if (initCwd && initCwd.trim()) return path.resolve(initCwd);
96
+
97
+ // Fallback (best effort): current working directory.
98
+ return process.cwd();
99
+ }
100
+
101
+ function main() {
102
+ const zapparRoot = resolveZapparRoot();
103
+ const umdDir = path.join(zapparRoot, 'umd');
104
+
105
+ const zapparCvRoot = resolveZapparCvRoot();
106
+ const zapparCvLibDir = path.join(zapparCvRoot, 'lib');
107
+
108
+ const projectRoot = resolveProjectRoot();
109
+ const outDir = path.resolve(projectRoot, 'static', 'zappar-cv');
110
+
111
+ const workerSource =
112
+ findFirstFile(umdDir, (name) => name === 'zappar.worker.js') ??
113
+ findFirstFile(umdDir, (name) => name === 'zappar-cv.worker.js') ??
114
+ findFirstFile(zapparRoot, (name) => name === 'zappar.worker.js') ??
115
+ findFirstFile(zapparRoot, (name) => name === 'zappar-cv.worker.js') ??
116
+ null;
117
+
118
+ if (!workerSource) {
119
+ warn('Could not locate Zappar worker under', umdDir);
120
+ warn('Zappar root:', zapparRoot);
121
+ return;
122
+ }
123
+
124
+ const wasmCandidates = listFiles(umdDir)
125
+ .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
126
+ .map((e) => path.join(umdDir, e.name));
127
+
128
+ let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
129
+ wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
130
+ wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
131
+
132
+ if (!wasmSource) {
133
+ warn('Could not locate Zappar wasm under', umdDir);
134
+ warn('Worker found at:', workerSource);
135
+ return;
136
+ }
137
+
138
+ fs.mkdirSync(outDir, {recursive: true});
139
+
140
+ const workerDest = path.join(outDir, 'zappar-cv.worker.js');
141
+ copyOrThrow(workerSource, workerDest);
142
+
143
+ const wasmBasename = path.basename(wasmSource);
144
+ const wasmDest = path.join(outDir, wasmBasename);
145
+ copyOrThrow(wasmSource, wasmDest);
146
+
147
+ // Create a stable alias that the Wonderland provider code points at.
148
+ safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
149
+
150
+ // Optional: copy any Zappar binary blobs shipped alongside the wasm.
151
+ // (Harmless if unused; avoids runtime 404s if Zappar expects them.)
152
+ for (const entry of listFiles(umdDir)) {
153
+ if (!entry.isFile()) continue;
154
+ if (!entry.name.endsWith('.zbin')) continue;
155
+ copyOrThrow(path.join(umdDir, entry.name), path.join(outDir, entry.name));
156
+ }
157
+
158
+ // Required for face tracking defaults (FaceTracker.loadDefaultModel / FaceMesh.loadDefault*).
159
+ // These ship in @zappar/zappar-cv/lib but are fetched at runtime unless staged.
160
+ const requiredFaceModelFiles = [
161
+ 'face_tracking_model.zbin',
162
+ 'face_mesh_face_model.zbin',
163
+ ];
164
+
165
+ for (const filename of requiredFaceModelFiles) {
166
+ const src = path.join(zapparCvLibDir, filename);
167
+ const dest = path.join(outDir, filename);
168
+ if (!fs.existsSync(src)) {
169
+ warn('Could not locate required Zappar face model:', filename);
170
+ warn('Looked under:', zapparCvLibDir);
171
+ continue;
172
+ }
173
+ copyOrThrow(src, dest);
174
+ }
175
+
176
+ log('Copied Zappar CV assets into', outDir);
177
+ }
178
+
179
+ try {
180
+ main();
181
+ } catch (e) {
182
+ // Postinstall should not hard-fail the whole install; consumers can still override.
183
+ warn('Failed to stage Zappar CV assets:', e);
184
+ }