@needle-tools/engine 5.1.0-alpha.7 → 5.1.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/components.needle.json +1 -1
- package/dist/{materialx-CE2sUv2B.umd.cjs → materialx-B47Bz-xs.umd.cjs} +1 -1
- package/dist/{materialx-u1EqYrhu.min.js → materialx-B85WjP7E.min.js} +1 -1
- package/dist/{materialx-vyB2Zbt4.js → materialx-jkHmVPez.js} +1 -1
- package/dist/{needle-engine.bundle-BNj2FJS7.min.js → needle-engine.bundle-BotdnwVF.min.js} +173 -173
- package/dist/{needle-engine.bundle-tHGdwr9a.umd.cjs → needle-engine.bundle-DagaNrZK.umd.cjs} +147 -147
- package/dist/{needle-engine.bundle-BwS5IsLn.js → needle-engine.bundle-mtqHZ45d.js} +7832 -7624
- package/dist/needle-engine.d.ts +301 -23
- package/dist/needle-engine.js +575 -565
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/dist/{vendor-ButPLzor.umd.cjs → vendor-1UvpPPSB.umd.cjs} +2 -2
- package/dist/{vendor-DqZC4Is7.min.js → vendor-BQ2Vuntm.min.js} +4 -4
- package/dist/{vendor-DkWSNjMV.js → vendor-BslSKZPo.js} +113 -106
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/engine_audio.d.ts +30 -1
- package/lib/engine/engine_audio.js +57 -13
- package/lib/engine/engine_audio.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_instantiate_resolve.js +40 -10
- package/lib/engine/engine_instantiate_resolve.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +72 -72
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_math.d.ts +1 -1
- package/lib/engine/engine_math.js +10 -4
- package/lib/engine/engine_math.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_networking_blob.js.map +1 -1
- package/lib/engine/engine_scenedata.js +7 -0
- package/lib/engine/engine_scenedata.js.map +1 -1
- package/lib/engine/engine_serialization_core.js +7 -2
- package/lib/engine/engine_serialization_core.js.map +1 -1
- package/lib/engine/engine_time_utils.d.ts +10 -6
- package/lib/engine/engine_time_utils.js +10 -6
- package/lib/engine/engine_time_utils.js.map +1 -1
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_qrcode.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine/xr/TempXRContext.js.map +1 -1
- package/lib/engine-components/AudioSource.d.ts +46 -2
- package/lib/engine-components/AudioSource.js +268 -60
- package/lib/engine-components/AudioSource.js.map +1 -1
- package/lib/engine-components/DragControls.js +2 -2
- package/lib/engine-components/DragControls.js.map +1 -1
- package/lib/engine-components/DragControlsConstraints.d.ts +0 -15
- package/lib/engine-components/DragControlsConstraints.js +14 -8
- package/lib/engine-components/DragControlsConstraints.js.map +1 -1
- package/lib/engine-components/EventList.js +1 -1
- package/lib/engine-components/EventList.js.map +1 -1
- package/lib/engine-components/SceneSwitcher.js +17 -2
- package/lib/engine-components/SceneSwitcher.js.map +1 -1
- package/lib/engine-components/codegen/components.d.ts +10 -0
- package/lib/engine-components/codegen/components.js +10 -0
- package/lib/engine-components/codegen/components.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
- package/lib/engine-components/timeline/PlayableDirector.d.ts +6 -0
- package/lib/engine-components/timeline/PlayableDirector.js +25 -12
- package/lib/engine-components/timeline/PlayableDirector.js.map +1 -1
- package/lib/engine-components/web/ScrollFollow.d.ts +33 -0
- package/lib/engine-components/web/ScrollFollow.js +164 -22
- package/lib/engine-components/web/ScrollFollow.js.map +1 -1
- package/lib/needle-app.d.ts +18 -0
- package/lib/needle-app.js +19 -0
- package/lib/needle-app.js.map +1 -0
- package/package.json +3 -2
- package/plugins/common/license.js +25 -4
- package/plugins/common/needle-engine.js +5 -2
- package/plugins/types/userconfig.d.ts +22 -0
- package/plugins/vite/dts-generator.d.ts +24 -0
- package/plugins/vite/dts-generator.js +40 -7
- package/plugins/vite/index.d.ts +2 -0
- package/plugins/vite/index.js +17 -0
- package/plugins/vite/license.js +4 -4
- package/plugins/vite/needle-app.js +78 -5
- package/plugins/vite/remote.d.ts +28 -0
- package/plugins/vite/remote.js +124 -0
- package/src/engine/api.ts +1 -1
- package/src/engine/engine_audio.ts +68 -15
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_instantiate_resolve.ts +37 -10
- package/src/engine/engine_license.ts +69 -69
- package/src/engine/engine_math.ts +11 -4
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_scenedata.ts +7 -2
- package/src/engine/engine_serialization_core.ts +7 -2
- package/src/engine/engine_time_utils.ts +10 -6
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/AudioSource.ts +256 -55
- package/src/engine-components/DragControls.ts +3 -2
- package/src/engine-components/DragControlsConstraints.ts +16 -8
- package/src/engine-components/EventList.ts +1 -1
- package/src/engine-components/SceneSwitcher.ts +14 -2
- package/src/engine-components/codegen/components.ts +10 -0
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
- package/src/engine-components/timeline/PlayableDirector.ts +23 -12
- package/src/engine-components/web/ScrollFollow.ts +168 -22
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PositionalAudio } from "three";
|
|
2
2
|
import { PositionalAudioHelper } from 'three/examples/jsm/helpers/PositionalAudioHelper.js';
|
|
3
3
|
|
|
4
4
|
import { isDevEnvironment } from "../engine/debug/index.js";
|
|
@@ -136,7 +136,13 @@ export class AudioSource extends Behaviour {
|
|
|
136
136
|
*
|
|
137
137
|
* @returns True if the audio is playing, false otherwise
|
|
138
138
|
*/
|
|
139
|
-
get isPlaying(): boolean {
|
|
139
|
+
get isPlaying(): boolean {
|
|
140
|
+
// For the media-element path (string clips and MediaStreams) three.js does NOT maintain
|
|
141
|
+
// sound.isPlaying (hasPlaybackControl is false once a media/stream source is set), so the
|
|
142
|
+
// <audio> element is the source of truth.
|
|
143
|
+
if (this._audioElement) return !this._audioElement.paused && !this._audioElement.ended;
|
|
144
|
+
return this.sound?.isPlaying ?? false;
|
|
145
|
+
}
|
|
140
146
|
|
|
141
147
|
/**
|
|
142
148
|
* The total duration of the currently loaded audio clip in seconds.
|
|
@@ -145,11 +151,14 @@ export class AudioSource extends Behaviour {
|
|
|
145
151
|
* @remarks For MediaStream clips, duration is not directly available and will return undefined. If the audio clip has not started loading or is still loading, duration may also be undefined until the audio buffer is ready.
|
|
146
152
|
*/
|
|
147
153
|
get duration() {
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
// Media-element path is the primary source of truth for string clips.
|
|
155
|
+
// (NaN/Infinity for MediaStream or before metadata is loaded → treated as "unknown".)
|
|
156
|
+
const elDuration = this._usesMediaElementSource ? this._audioElement?.duration : undefined;
|
|
157
|
+
if (elDuration && isFinite(elDuration)) {
|
|
158
|
+
return elDuration;
|
|
150
159
|
}
|
|
151
|
-
else if (this.
|
|
152
|
-
return this.
|
|
160
|
+
else if (this.sound?.buffer?.duration) {
|
|
161
|
+
return this.sound.buffer.duration;
|
|
153
162
|
}
|
|
154
163
|
else if (this._loadedClip instanceof MediaStream) {
|
|
155
164
|
// MediaStream duration is not directly available; return undefined or estimate if possible
|
|
@@ -164,14 +173,14 @@ export class AudioSource extends Behaviour {
|
|
|
164
173
|
*/
|
|
165
174
|
get time01() {
|
|
166
175
|
const duration = this.duration;
|
|
167
|
-
if (duration
|
|
168
|
-
return this.
|
|
176
|
+
if (duration) {
|
|
177
|
+
return this.time / duration;
|
|
169
178
|
}
|
|
170
179
|
return 0;
|
|
171
180
|
}
|
|
172
181
|
set time01(val: number) {
|
|
173
182
|
const duration = this.duration;
|
|
174
|
-
if (duration
|
|
183
|
+
if (duration) {
|
|
175
184
|
this.time = val * duration;
|
|
176
185
|
}
|
|
177
186
|
}
|
|
@@ -180,8 +189,17 @@ export class AudioSource extends Behaviour {
|
|
|
180
189
|
* The current playback position in seconds.
|
|
181
190
|
* Can be set to seek to a specific time in the audio.
|
|
182
191
|
*/
|
|
183
|
-
get time(): number {
|
|
192
|
+
get time(): number {
|
|
193
|
+
// Media-element path: the element's currentTime is authoritative.
|
|
194
|
+
if (this._usesMediaElementSource && this._audioElement) return this._audioElement.currentTime;
|
|
195
|
+
return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0;
|
|
196
|
+
}
|
|
184
197
|
set time(val: number) {
|
|
198
|
+
// Media-element path: seek directly on the element (sound.offset is not used for media nodes).
|
|
199
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
200
|
+
this._audioElement.currentTime = val;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
185
203
|
if (this.sound) {
|
|
186
204
|
if (val === this.sound.offset) return;
|
|
187
205
|
const wasPlaying = this.isPlaying;
|
|
@@ -199,12 +217,19 @@ export class AudioSource extends Behaviour {
|
|
|
199
217
|
*/
|
|
200
218
|
@serializable()
|
|
201
219
|
get loop(): boolean {
|
|
220
|
+
// Media-element path: the element holds the authoritative loop flag (sound.getLoop()
|
|
221
|
+
// warns and returns false once hasPlaybackControl is false).
|
|
222
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
223
|
+
this._loop = this._audioElement.loop;
|
|
224
|
+
return this._loop;
|
|
225
|
+
}
|
|
202
226
|
if (this.sound) this._loop = this.sound.getLoop();
|
|
203
227
|
return this._loop;
|
|
204
228
|
}
|
|
205
229
|
set loop(val: boolean) {
|
|
206
230
|
this._loop = val;
|
|
207
|
-
if (this.
|
|
231
|
+
if (this._usesMediaElementSource && this._audioElement) this._audioElement.loop = val;
|
|
232
|
+
else if (this.sound) this.sound.setLoop(val);
|
|
208
233
|
}
|
|
209
234
|
|
|
210
235
|
/**
|
|
@@ -279,9 +304,13 @@ export class AudioSource extends Behaviour {
|
|
|
279
304
|
*/
|
|
280
305
|
@serializable()
|
|
281
306
|
set pitch(val: number) {
|
|
282
|
-
|
|
307
|
+
// Media-element path: playbackRate on the element (sound.setPlaybackRate warns and is a
|
|
308
|
+
// no-op once hasPlaybackControl is false).
|
|
309
|
+
if (this._usesMediaElementSource && this._audioElement) this._audioElement.playbackRate = val;
|
|
310
|
+
else if (this.sound) this.sound.setPlaybackRate(val);
|
|
283
311
|
}
|
|
284
312
|
get pitch(): number {
|
|
313
|
+
if (this._usesMediaElementSource && this._audioElement) return this._audioElement.playbackRate;
|
|
285
314
|
return this.sound ? this.sound.getPlaybackRate() : 1;
|
|
286
315
|
}
|
|
287
316
|
|
|
@@ -298,13 +327,20 @@ export class AudioSource extends Behaviour {
|
|
|
298
327
|
private sound: PositionalAudio | null = null;
|
|
299
328
|
private helper: PositionalAudioHelper | null = null;
|
|
300
329
|
private wasPlaying = false;
|
|
301
|
-
private audioLoader: AudioLoader | null = null;
|
|
302
330
|
private shouldPlay: boolean = false;
|
|
303
331
|
// set this from audio context time, used to set clip offset when setting "time" property
|
|
304
332
|
// there is maybe a better way to set a audio clip current time?!
|
|
305
|
-
private _lastClipStartedLoading: string | MediaStream | null = null;
|
|
306
333
|
private _loadedClip: string | MediaStream | null = null;
|
|
307
334
|
private _audioElement: HTMLAudioElement | null = null;
|
|
335
|
+
/**
|
|
336
|
+
* True when {@link _audioElement} is a file-URL element routed through
|
|
337
|
+
* `sound.setMediaElementSource()` (the iOS-survivable string-clip path). For this path ALL
|
|
338
|
+
* playback control (play/pause/stop/loop/pitch/time/isPlaying) goes through the element,
|
|
339
|
+
* because three.js sets `hasPlaybackControl = false` once a media source is attached.
|
|
340
|
+
* It is `false` for the distinct MediaStream path (which uses an element with `srcObject`
|
|
341
|
+
* plus `setMediaStreamSource()` and is controlled differently).
|
|
342
|
+
*/
|
|
343
|
+
private _usesMediaElementSource: boolean = false;
|
|
308
344
|
|
|
309
345
|
// Spatial blend dual-path audio nodes
|
|
310
346
|
private _entryNode: GainNode | null = null;
|
|
@@ -352,18 +388,49 @@ export class AudioSource extends Behaviour {
|
|
|
352
388
|
return this.sound?.context;
|
|
353
389
|
}
|
|
354
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Resumes the shared AudioContext if it is currently suspended/interrupted and the
|
|
393
|
+
* page is visible. Uses the same policy as the global resume handler so this never
|
|
394
|
+
* triggers the iOS `InvalidStateError: Failed to start the audio device` (which
|
|
395
|
+
* happens when resuming while the page is hidden). No-op when the context is already
|
|
396
|
+
* running or when resuming is not currently allowed.
|
|
397
|
+
*/
|
|
398
|
+
private ensureContextResumed() {
|
|
399
|
+
const ctx = this.audioContext;
|
|
400
|
+
if (!ctx) return;
|
|
401
|
+
const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
|
|
402
|
+
// Only skip while hidden — resuming a hidden context throws iOS InvalidStateError.
|
|
403
|
+
if (visibility !== "visible") return;
|
|
404
|
+
// Resume even when ctx.state already reports "running". On iOS an auto-resume that
|
|
405
|
+
// happened OUTSIDE a user gesture (e.g. our visibilitychange retry) can leave the
|
|
406
|
+
// context reporting "running" while the audio HARDWARE stays off ("zombie" context).
|
|
407
|
+
// play() is user-initiated, so this resume() runs inside the gesture and re-enables
|
|
408
|
+
// actual output. It is a harmless no-op on an already-healthy context.
|
|
409
|
+
// (The global handler in engine_audio.ts still uses shouldResumeAudioContext, because
|
|
410
|
+
// it fires on timers/events with no gesture and must not spam resume on a running ctx.)
|
|
411
|
+
ctx.resume().catch(e => { if (debug) console.warn("[AudioSource] Failed to resume AudioContext:", e); });
|
|
412
|
+
}
|
|
413
|
+
|
|
355
414
|
/** @internal */
|
|
356
415
|
awake() {
|
|
357
416
|
if (debug) console.log("[AudioSource]", this);
|
|
358
417
|
|
|
359
|
-
this.audioLoader = new AudioLoader();
|
|
360
418
|
if (this.playOnAwake) this.shouldPlay = true;
|
|
361
419
|
|
|
362
420
|
if (this.preload) {
|
|
363
421
|
if (typeof this.clip === "string" && this.clip.length > 0) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
422
|
+
// Preload = create the <audio> element and wire it into the Web Audio graph, but do
|
|
423
|
+
// NOT play. Wiring needs the PositionalAudio (Sound), which in turn needs an
|
|
424
|
+
// AudioListener that only exists after a user gesture — so defer when not ready.
|
|
425
|
+
if (this.Sound) {
|
|
426
|
+
this.createAudioFromElement();
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
AudioSource.registerWaitForAllowAudio(() => {
|
|
430
|
+
if (this.destroyed || this.enabled === false) return;
|
|
431
|
+
if (typeof this.clip === "string" && this.clip.length > 0) this.createAudioFromElement();
|
|
432
|
+
});
|
|
433
|
+
}
|
|
367
434
|
}
|
|
368
435
|
}
|
|
369
436
|
}
|
|
@@ -397,15 +464,23 @@ export class AudioSource extends Behaviour {
|
|
|
397
464
|
switch (document.visibilityState) {
|
|
398
465
|
case "hidden":
|
|
399
466
|
if (this.playInBackground === false || DeviceUtilities.isMobileDevice()) {
|
|
400
|
-
|
|
401
|
-
if (
|
|
467
|
+
const wasPlaying = this.isPlaying;
|
|
468
|
+
if (wasPlaying) {
|
|
402
469
|
this.pause();
|
|
403
470
|
}
|
|
471
|
+
// pause() clears wasPlaying so a *user-initiated* pause is not auto-resumed on
|
|
472
|
+
// the next foreground. This pause is automatic (page hidden / device locked), so
|
|
473
|
+
// re-assert the resume intent AFTER pause() — otherwise we never resume on unlock.
|
|
474
|
+
this.wasPlaying = wasPlaying;
|
|
404
475
|
}
|
|
405
476
|
break;
|
|
406
477
|
case "visible":
|
|
407
478
|
if (debug) console.log("visible", this.enabled, this.playOnAwake, !this.isPlaying, AudioSource.userInteractionRegistered, this.wasPlaying);
|
|
408
|
-
|
|
479
|
+
// Resume anything that was actively playing when the page was hidden — not
|
|
480
|
+
// just playOnAwake sources. `wasPlaying` is the correct signal: it is only
|
|
481
|
+
// set when this source was playing at hide time, so script-started clips
|
|
482
|
+
// resume too (previously they stayed silent until a manual play()).
|
|
483
|
+
if (this.enabled && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
|
|
409
484
|
this.play();
|
|
410
485
|
}
|
|
411
486
|
break;
|
|
@@ -419,13 +494,89 @@ export class AudioSource extends Behaviour {
|
|
|
419
494
|
this.sound?.setVolume(this.volume);
|
|
420
495
|
}
|
|
421
496
|
|
|
422
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Sets up playback of a string (file URL) clip through an `HTMLAudioElement` routed into the
|
|
499
|
+
* Web Audio graph via `sound.setMediaElementSource()`.
|
|
500
|
+
*
|
|
501
|
+
* Why a media element instead of a decoded `AudioBuffer`: on iOS Safari an
|
|
502
|
+
* `AudioBufferSourceNode` has its output torn down after a device lock / audio-session
|
|
503
|
+
* interruption and cannot be reliably revived. A `MediaElementAudioSourceNode` survives the
|
|
504
|
+
* lock because the playing `<audio>` element keeps iOS's media session alive, so the shared
|
|
505
|
+
* AudioContext is never torn down and resumes cleanly. This is the same approach three.js's own
|
|
506
|
+
* `webaudio_orientation` example uses.
|
|
507
|
+
*
|
|
508
|
+
* This does NOT start playback — it only creates/wires the element so a later `.play()` (driven
|
|
509
|
+
* by a user gesture) is cheap.
|
|
510
|
+
*/
|
|
511
|
+
private createAudioFromElement() {
|
|
423
512
|
if (this.destroyed) {
|
|
424
513
|
if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
425
514
|
return;
|
|
426
515
|
}
|
|
516
|
+
const sound = this.Sound;
|
|
517
|
+
if (!sound) {
|
|
518
|
+
if (debug) console.warn("Failed getting sound?", this.name);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// If the element is already wired for the CURRENT clip, there is nothing to (re)create —
|
|
523
|
+
// re-running setMediaElementSource on the same element throws InvalidStateError
|
|
524
|
+
// ("already connected to a MediaElementSourceNode"), so bail out early.
|
|
525
|
+
if (this._audioElement && this._usesMediaElementSource && this._loadedClip === this.clip) {
|
|
526
|
+
// keep volume/mute in sync, then we're done
|
|
527
|
+
if (this.context.application.muted) sound.setVolume(0);
|
|
528
|
+
else sound.setVolume(this.volume);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// (re)create the element when the clip changed since the element was wired.
|
|
533
|
+
if (this._audioElement && this._loadedClip !== this.clip) {
|
|
534
|
+
this._audioElement.pause();
|
|
535
|
+
this._audioElement = null;
|
|
536
|
+
this._usesMediaElementSource = false;
|
|
537
|
+
}
|
|
538
|
+
const created = !this._audioElement;
|
|
539
|
+
if (created) {
|
|
540
|
+
const el = new Audio();
|
|
541
|
+
el.crossOrigin = "anonymous";
|
|
542
|
+
el.preload = "auto";
|
|
543
|
+
el.src = this.clip as string;
|
|
544
|
+
this._audioElement = el;
|
|
545
|
+
}
|
|
546
|
+
this._audioElement!.loop = this._loop;
|
|
547
|
+
|
|
548
|
+
// IMPORTANT: build the spatial-blend graph FIRST so getOutput() returns the entry node,
|
|
549
|
+
// THEN route the media element source into it. setMediaElementSource() immediately calls
|
|
550
|
+
// sound.connect() (source → getOutput()), so the entry node and the getOutput override
|
|
551
|
+
// must already exist. Order matters.
|
|
552
|
+
this.setupSpatialBlendNodes();
|
|
553
|
+
// Only connect once per element — createMediaElementSource() throws if the same
|
|
554
|
+
// HTMLMediaElement is passed to it twice.
|
|
555
|
+
if (!this._usesMediaElementSource) {
|
|
556
|
+
sound.setMediaElementSource(this._audioElement!);
|
|
557
|
+
this._usesMediaElementSource = true;
|
|
558
|
+
}
|
|
559
|
+
this._loadedClip = this.clip;
|
|
427
560
|
|
|
428
|
-
if (
|
|
561
|
+
if (this.context.application.muted) sound.setVolume(0);
|
|
562
|
+
else sound.setVolume(this.volume);
|
|
563
|
+
this.applySpatialDistanceSettings();
|
|
564
|
+
|
|
565
|
+
// Playback is started explicitly by onNewClip()/play()/onEnable — do NOT auto-play here.
|
|
566
|
+
// Registering __onAllowAudioCallback would (once the user has interacted) fire immediately
|
|
567
|
+
// and call play() a SECOND time, restarting the element → audible "clipping repeat".
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Sets up the {@link PositionalAudio} graph for a MediaStream clip (used for the MediaStream
|
|
572
|
+
* path only — string clips go through {@link createAudioFromElement}). The actual stream node
|
|
573
|
+
* is attached in {@link play} via `setMediaStreamSource`.
|
|
574
|
+
*/
|
|
575
|
+
private createAudio = () => {
|
|
576
|
+
if (this.destroyed) {
|
|
577
|
+
if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
429
580
|
|
|
430
581
|
const sound = this.Sound;
|
|
431
582
|
if (!sound) {
|
|
@@ -433,21 +584,12 @@ export class AudioSource extends Behaviour {
|
|
|
433
584
|
return;
|
|
434
585
|
}
|
|
435
586
|
|
|
436
|
-
if (sound.isPlaying)
|
|
437
|
-
sound.stop();
|
|
438
|
-
|
|
439
|
-
if (buffer) sound.setBuffer(buffer);
|
|
440
587
|
this._loadedClip = this.clip;
|
|
441
|
-
sound.loop = this._loop;
|
|
442
588
|
if (this.context.application.muted) sound.setVolume(0);
|
|
443
589
|
else sound.setVolume(this.volume);
|
|
444
|
-
sound.autoplay = this.shouldPlay && AudioSource.userInteractionRegistered;
|
|
445
590
|
this.setupSpatialBlendNodes();
|
|
446
591
|
this.applySpatialDistanceSettings();
|
|
447
592
|
|
|
448
|
-
if (sound.isPlaying)
|
|
449
|
-
sound.stop();
|
|
450
|
-
|
|
451
593
|
// make sure we only play the sound if the user has interacted with the page
|
|
452
594
|
AudioSource.registerWaitForAllowAudio(this.__onAllowAudioCallback);
|
|
453
595
|
}
|
|
@@ -564,23 +706,21 @@ export class AudioSource extends Behaviour {
|
|
|
564
706
|
if (debug)
|
|
565
707
|
console.log(clip);
|
|
566
708
|
if (clip.endsWith(".mp3") || clip.endsWith(".wav") || clip.endsWith(".ogg") || clip.endsWith(".opus") || clip.endsWith(".flac") || clip.endsWith(".aac") || clip.endsWith(".webm")) {
|
|
567
|
-
if (!this.audioLoader)
|
|
568
|
-
this.audioLoader = new AudioLoader();
|
|
569
709
|
this.shouldPlay = true;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
710
|
+
// Route the file URL through an <audio> element (survives iOS lock — see
|
|
711
|
+
// createAudioFromElement). This creates/wires the element without playing.
|
|
712
|
+
this.createAudioFromElement();
|
|
713
|
+
// Start playback through the element (NOT this.sound — hasPlaybackControl is false
|
|
714
|
+
// for a media-element source). __onAllowAudioCallback would also play once
|
|
715
|
+
// interaction is registered; this covers the already-interacted case directly.
|
|
716
|
+
if (this.shouldPlay && AudioSource.userInteractionRegistered) {
|
|
717
|
+
this.ensureContextResumed();
|
|
718
|
+
this._hasEnded = false;
|
|
719
|
+
this._audioElement?.play().catch(e => {
|
|
720
|
+
if (debug) console.warn(`[AudioSource] Failed to play audio clip "${clip}":`, e);
|
|
721
|
+
});
|
|
722
|
+
this.fadeEntryGain(1); // fade in (no-op on first play; undoes a prior pause() fade)
|
|
573
723
|
}
|
|
574
|
-
this._lastClipStartedLoading = clip;
|
|
575
|
-
if (debug)
|
|
576
|
-
console.log("load audio", clip);
|
|
577
|
-
const buffer = await this.audioLoader.loadAsync(clip).catch(e => {
|
|
578
|
-
console.error(`[AudioSource] Error loading audio clip "${clip}":`, e);
|
|
579
|
-
return null;
|
|
580
|
-
});
|
|
581
|
-
if (this.destroyed) return;
|
|
582
|
-
if (this._lastClipStartedLoading === clip) this._lastClipStartedLoading = null;
|
|
583
|
-
if (buffer) this.createAudio(buffer);
|
|
584
724
|
}
|
|
585
725
|
else console.warn("Unsupported audio clip type", clip)
|
|
586
726
|
}
|
|
@@ -612,9 +752,12 @@ export class AudioSource extends Behaviour {
|
|
|
612
752
|
|
|
613
753
|
// Load if the sound hasn't been created yet or if the clip changed since last load
|
|
614
754
|
let needsLoading = !this.sound || (clip && clip !== this._loadedClip);
|
|
615
|
-
|
|
755
|
+
// For string clips the <audio> element must additionally be wired (createAudioFromElement);
|
|
756
|
+
// if it isn't yet (e.g. preload was off, or the element was torn down), force a (re)load.
|
|
757
|
+
if (typeof clip === "string" && (!this._audioElement || !this._usesMediaElementSource)) needsLoading = true;
|
|
616
758
|
if (clip instanceof MediaStream || typeof clip === "string")
|
|
617
759
|
this.clip = clip;
|
|
760
|
+
|
|
618
761
|
if (needsLoading) {
|
|
619
762
|
this.shouldPlay = true;
|
|
620
763
|
return this.onNewClip(clip).then(() => true).catch(() => false);
|
|
@@ -625,16 +768,23 @@ export class AudioSource extends Behaviour {
|
|
|
625
768
|
|
|
626
769
|
if (debug) console.log("[AudioSource] play", this.sound?.getVolume(), this.sound);
|
|
627
770
|
// If a different clip was passed in, needsLoading above would be true and onNewClip handles it.
|
|
628
|
-
// This guard prevents double-playing the same already-playing sound
|
|
629
|
-
if (this.sound && !this.
|
|
771
|
+
// This guard prevents double-playing the same already-playing sound.
|
|
772
|
+
if (this.sound && !this.isPlaying) {
|
|
773
|
+
// On iOS the shared context can be suspended/interrupted (device locked, app
|
|
774
|
+
// backgrounded, audio session interrupted by a call) — starting playback on it produces
|
|
775
|
+
// no sound until it is resumed. Resume it here (we are inside a user gesture) so playback
|
|
776
|
+
// does not silently depend on the global resume handler running first. See engine_audio.ts.
|
|
777
|
+
this.ensureContextResumed();
|
|
630
778
|
const muted = this.context.application.muted;
|
|
631
779
|
if (muted) this.sound.setVolume(0);
|
|
632
780
|
this.gameObject?.add(this.sound);
|
|
633
781
|
|
|
634
782
|
if (this.clip instanceof MediaStream) {
|
|
635
783
|
|
|
636
|
-
//
|
|
637
|
-
//
|
|
784
|
+
// Distinct from the file-URL media-element path: a MediaStream uses
|
|
785
|
+
// setMediaStreamSource + an element holding srcObject (the element is only needed
|
|
786
|
+
// so the stream actually plays — playback is not controlled through it here).
|
|
787
|
+
this._usesMediaElementSource = false;
|
|
638
788
|
this.sound.setMediaStreamSource(this.clip);
|
|
639
789
|
|
|
640
790
|
if (!this._audioElement) {
|
|
@@ -649,8 +799,15 @@ export class AudioSource extends Behaviour {
|
|
|
649
799
|
|
|
650
800
|
}
|
|
651
801
|
else {
|
|
652
|
-
|
|
653
|
-
|
|
802
|
+
// File-URL media-element path: playback control is on the element, not this.sound
|
|
803
|
+
// (hasPlaybackControl is false once setMediaElementSource was called). Volume/mute
|
|
804
|
+
// is still applied via sound.setVolume() above (the gain node still works).
|
|
805
|
+
// Resume the element from its (frozen) position, then fade the entry gain back in so
|
|
806
|
+
// the resume is click-free. No seek, no graph reconnect — both caused the stutter.
|
|
807
|
+
this._audioElement?.play().catch(e => {
|
|
808
|
+
if (debug) console.warn("[AudioSource] Failed to play audio element:", e);
|
|
809
|
+
});
|
|
810
|
+
this.fadeEntryGain(1);
|
|
654
811
|
return true;
|
|
655
812
|
}
|
|
656
813
|
}
|
|
@@ -658,6 +815,23 @@ export class AudioSource extends Behaviour {
|
|
|
658
815
|
return false;
|
|
659
816
|
}
|
|
660
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Smoothly ramp the spatial-graph entry-gain node to `target` (1 = audible, 0 = silent) over a
|
|
820
|
+
* few ms. A short *fade* — not a hard 0/1 gain change and not a connect/disconnect — is what
|
|
821
|
+
* avoids the click on pause/resume (an instant change or a graph (dis)connect steps the waveform
|
|
822
|
+
* → audible click). On iOS the MediaElementAudioSourceNode keeps feeding the graph after
|
|
823
|
+
* element.pause() following an interruption, so fading this gain to 0 is what actually silences a
|
|
824
|
+
* "paused" media clip; element.pause() only freezes its playback position.
|
|
825
|
+
*/
|
|
826
|
+
private fadeEntryGain(target: number, seconds = 0.02) {
|
|
827
|
+
if (!this._entryNode || !this.sound) return;
|
|
828
|
+
const ctx = this.sound.context;
|
|
829
|
+
const g = this._entryNode.gain;
|
|
830
|
+
g.cancelScheduledValues(ctx.currentTime);
|
|
831
|
+
g.setValueAtTime(g.value, ctx.currentTime);
|
|
832
|
+
g.linearRampToValueAtTime(target, ctx.currentTime + seconds);
|
|
833
|
+
}
|
|
834
|
+
|
|
661
835
|
/**
|
|
662
836
|
* Pauses audio playback while maintaining the current position.
|
|
663
837
|
* Use play() to resume from the paused position.
|
|
@@ -666,10 +840,24 @@ export class AudioSource extends Behaviour {
|
|
|
666
840
|
if (debug) console.log("[AudioSource] pause", this);
|
|
667
841
|
this._hasEnded = true;
|
|
668
842
|
this.shouldPlay = false;
|
|
843
|
+
// A user-initiated pause must not be revived by the visibility handler on the next
|
|
844
|
+
// foreground (it replays sources whose `wasPlaying` is set).
|
|
845
|
+
this.wasPlaying = false;
|
|
846
|
+
if (this._usesMediaElementSource) {
|
|
847
|
+
// File-URL media-element path. On iOS the MediaElementAudioSourceNode keeps emitting into
|
|
848
|
+
// the Web Audio graph after element.pause() once the audio session has been interrupted
|
|
849
|
+
// (lock/unlock) — pausing the element does NOT silence it (confirmed on-device). So we
|
|
850
|
+
// fade the entry gain to 0 (click-free silence — the fade is what actually silences it),
|
|
851
|
+
// then freeze the element's position. play() fades it back in.
|
|
852
|
+
this.fadeEntryGain(0);
|
|
853
|
+
this._audioElement?.pause();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
669
856
|
if (this.sound && this.sound.isPlaying && this.sound.source) {
|
|
670
857
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
671
858
|
this.sound.pause();
|
|
672
859
|
}
|
|
860
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
673
861
|
this._audioElement?.remove();
|
|
674
862
|
}
|
|
675
863
|
|
|
@@ -681,12 +869,22 @@ export class AudioSource extends Behaviour {
|
|
|
681
869
|
if (debug) console.log("[AudioSource] stop", this);
|
|
682
870
|
this._hasEnded = true;
|
|
683
871
|
this.shouldPlay = false;
|
|
872
|
+
this.wasPlaying = false;
|
|
873
|
+
if (this._usesMediaElementSource) {
|
|
874
|
+
// True stop: detach the source from the graph (see pause() — element.pause() alone does
|
|
875
|
+
// not silence it on iOS after an interruption), pause, and rewind to the start.
|
|
876
|
+
this.fadeEntryGain(0);
|
|
877
|
+
this._audioElement?.pause();
|
|
878
|
+
if (this._audioElement) this._audioElement.currentTime = 0;
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
684
881
|
if (this.sound && this.sound.source) {
|
|
685
882
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
686
883
|
if (debug)
|
|
687
884
|
console.log("[AudioSource] lastContextTime", this._lastContextTime);
|
|
688
885
|
this.sound.stop();
|
|
689
886
|
}
|
|
887
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
690
888
|
this._audioElement?.remove();
|
|
691
889
|
}
|
|
692
890
|
|
|
@@ -707,7 +905,10 @@ export class AudioSource extends Behaviour {
|
|
|
707
905
|
this.applySpatialDistanceSettings();
|
|
708
906
|
}
|
|
709
907
|
|
|
710
|
-
|
|
908
|
+
// Use the element-aware `isPlaying` getter, NOT `sound.isPlaying`: for a media-element
|
|
909
|
+
// source three.js leaves `sound.isPlaying` permanently false (hasPlaybackControl=false),
|
|
910
|
+
// which would otherwise fire "ended" every frame during playback.
|
|
911
|
+
if (this.sound && !this.isPlaying && this.shouldPlay && !this._hasEnded) {
|
|
711
912
|
this._hasEnded = true;
|
|
712
913
|
if (debug) console.log("[AudioSource] Audio clip ended", this.clip);
|
|
713
914
|
this.dispatchEvent(new CustomEvent("ended", { detail: this }));
|
|
@@ -113,6 +113,7 @@ export class DragProfile {
|
|
|
113
113
|
* @see {@link SyncedTransform} for networked dragging
|
|
114
114
|
* @see {@link ObjectRaycaster} for pointer detection
|
|
115
115
|
*/
|
|
116
|
+
|
|
116
117
|
export class DragControls extends Behaviour implements IPointerEventHandler {
|
|
117
118
|
|
|
118
119
|
/**
|
|
@@ -797,8 +798,8 @@ class MultiTouchDragHandler implements IDragHandler {
|
|
|
797
798
|
private _currentScaleRatio: number = 1;
|
|
798
799
|
/** World-scale of the dragged object captured at drag start. Used to apply scale directly. */
|
|
799
800
|
private readonly _initialWorldScale: Vector3 = new Vector3(1, 1, 1);
|
|
800
|
-
/** Clamps the dragged object's world scale during two-pointer scaling.
|
|
801
|
-
private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.
|
|
801
|
+
/** Clamps the dragged object's world scale during two-pointer scaling. */
|
|
802
|
+
private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.0001, 1_000_000, true);
|
|
802
803
|
|
|
803
804
|
private _tempVec1: Vector3 = new Vector3();
|
|
804
805
|
private _tempVec2: Vector3 = new Vector3();
|
|
@@ -163,6 +163,8 @@ export class KeepScaleConstraint implements IDragConstraint {
|
|
|
163
163
|
* new ScaleLimitConstraint(0.01, 10000, true, object.worldScale.clone());
|
|
164
164
|
* ```
|
|
165
165
|
*/
|
|
166
|
+
const _scaleLimitWs = new Vector3();
|
|
167
|
+
|
|
166
168
|
export class ScaleLimitConstraint implements IDragConstraint {
|
|
167
169
|
/**
|
|
168
170
|
* @param min Lower bound. Relative mode: fraction of initial scale. Raw mode: absolute world-scale per axis.
|
|
@@ -184,27 +186,33 @@ export class ScaleLimitConstraint implements IDragConstraint {
|
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
apply(followObject: GameObject): void {
|
|
187
|
-
|
|
189
|
+
// Read world scale directly from the matrix columns (col 0/1/2 lengths) rather than
|
|
190
|
+
// going through the worldScale getter, which internally calls updateWorldMatrix() again.
|
|
191
|
+
// We call it once here so the matrix is fresh, then read the elements directly.
|
|
192
|
+
const obj = followObject as unknown as Object3D;
|
|
193
|
+
obj.updateWorldMatrix(true, false);
|
|
194
|
+
const e = obj.matrixWorld.elements;
|
|
195
|
+
const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
|
|
196
|
+
const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
|
|
197
|
+
const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
|
|
198
|
+
_scaleLimitWs.set(sx, sy, sz);
|
|
199
|
+
const ws = _scaleLimitWs;
|
|
200
|
+
|
|
188
201
|
if (this.relativeToInitialScale) {
|
|
189
|
-
// Derive the uniform scale ratio from the magnitude of the current world scale
|
|
190
|
-
// relative to the initial world scale magnitude.
|
|
191
202
|
const initLen = this._initialWorldScale.length();
|
|
192
203
|
if (initLen < 1e-10) return;
|
|
193
204
|
const ratio = ws.length() / initLen;
|
|
194
205
|
const clamped = Math.max(this.min, Math.min(this.max, ratio));
|
|
195
206
|
if (Math.abs(clamped - ratio) > 1e-9) {
|
|
196
|
-
ws.copy(this._initialWorldScale).multiplyScalar(clamped);
|
|
197
|
-
followObject.worldScale = ws;
|
|
207
|
+
followObject.worldScale = ws.copy(this._initialWorldScale).multiplyScalar(clamped);
|
|
198
208
|
followObject.updateMatrix();
|
|
199
209
|
}
|
|
200
210
|
} else {
|
|
201
|
-
// Absolute per-axis clamp.
|
|
202
211
|
const cx = Math.max(this.min, Math.min(this.max, ws.x));
|
|
203
212
|
const cy = Math.max(this.min, Math.min(this.max, ws.y));
|
|
204
213
|
const cz = Math.max(this.min, Math.min(this.max, ws.z));
|
|
205
214
|
if (cx !== ws.x || cy !== ws.y || cz !== ws.z) {
|
|
206
|
-
ws.set(cx, cy, cz);
|
|
207
|
-
followObject.worldScale = ws;
|
|
215
|
+
followObject.worldScale = ws.set(cx, cy, cz);
|
|
208
216
|
followObject.updateMatrix();
|
|
209
217
|
}
|
|
210
218
|
}
|
|
@@ -56,7 +56,7 @@ export class CallInfo {
|
|
|
56
56
|
if (args !== undefined && args.length > 0)
|
|
57
57
|
argumentsBuffer.push(...args);
|
|
58
58
|
argumentsBuffer.push(...this.arguments);
|
|
59
|
-
this.target(...
|
|
59
|
+
this.target(...argumentsBuffer);
|
|
60
60
|
argumentsBuffer.length = 0;
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
@@ -791,8 +791,20 @@ export class SceneSwitcher extends Behaviour {
|
|
|
791
791
|
for (let i = 0; i < this.scenes.length; i++) {
|
|
792
792
|
const scene = this.scenes[i];
|
|
793
793
|
if (!scene) continue;
|
|
794
|
-
|
|
795
|
-
|
|
794
|
+
// Match against the url-derived name AND, for in-scene object
|
|
795
|
+
// references (which have an empty url), the referenced object's
|
|
796
|
+
// name. Without the latter, `?scene=<name>` never resolves for
|
|
797
|
+
// non-prefab/object scenes and the switcher falls back to the
|
|
798
|
+
// default scene (scenes is AssetReference[], so the
|
|
799
|
+
// `scene instanceof Object3D` branch alone never matched them).
|
|
800
|
+
const candidateNames: string[] = [];
|
|
801
|
+
if (scene instanceof Object3D) candidateNames.push(scene.name);
|
|
802
|
+
else {
|
|
803
|
+
if (scene.url) candidateNames.push(sceneUriToName(scene.url));
|
|
804
|
+
const asset = scene.asset;
|
|
805
|
+
if (asset instanceof Object3D && asset.name) candidateNames.push(asset.name);
|
|
806
|
+
}
|
|
807
|
+
if (candidateNames.some(n => n?.toLowerCase().includes(lowerCaseValue))) {
|
|
796
808
|
return this.select(i);
|
|
797
809
|
}
|
|
798
810
|
}
|
|
@@ -25,7 +25,17 @@ export { LogStats } from "../debug/LogStats.js";
|
|
|
25
25
|
export { DeleteBox } from "../DeleteBox.js";
|
|
26
26
|
export { Deletable } from "../DeleteBox.js";
|
|
27
27
|
export { DeviceFlag } from "../DeviceFlag.js";
|
|
28
|
+
export { DragProfile } from "../DragControls.js";
|
|
28
29
|
export { DragControls } from "../DragControls.js";
|
|
30
|
+
export { GridSnapConstraint } from "../DragControlsConstraints.js";
|
|
31
|
+
export { GrabPointPlaneConstraint } from "../DragControlsConstraints.js";
|
|
32
|
+
export { KeepRotationConstraint } from "../DragControlsConstraints.js";
|
|
33
|
+
export { KeepScaleConstraint } from "../DragControlsConstraints.js";
|
|
34
|
+
export { ScaleLimitConstraint } from "../DragControlsConstraints.js";
|
|
35
|
+
export { FixedRotationAxesConstraint } from "../DragControlsConstraints.js";
|
|
36
|
+
export { AxisRotationConstraint } from "../DragControlsConstraints.js";
|
|
37
|
+
export { PlaneHeightLockConstraint } from "../DragControlsConstraints.js";
|
|
38
|
+
export { SnapToSurfaceConstraint } from "../DragControlsConstraints.js";
|
|
29
39
|
export { DropListener } from "../DropListener.js";
|
|
30
40
|
export { Duplicatable } from "../Duplicatable.js";
|
|
31
41
|
export { CallInfo } from "../EventList.js";
|