@displayxr/inline3d 1.20.0 → 1.21.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/CHANGELOG.md CHANGED
@@ -5,7 +5,33 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
- ## Unreleased
8
+ ## 1.21.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Minor: a new `setSource`
11
+ option. No pixel change for any existing transition, and the default (`cut`) is unchanged.
12
+
13
+ ### Added
14
+
15
+ - **Sequence transitions: one splat at a time** (#36). `setSource(next, { transition: 'reassemble' })`
16
+ disperses the current photo (`assemble` played backwards), releases it, loads the next one and
17
+ assembles it: 3000 ms, 45 % out, a 10 % empty beat, 45 % in. The general form
18
+ `transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }` runs any two of
19
+ `assemble`, `dissolve-in`, `converge`, `shimmer`, `sweep`, `fade` (each draws nothing at its start,
20
+ which is what makes the swap invisible; `inflate` and `dissolve` are refused).
21
+ - No second camera, live-outgoing layer, overlay target or frozen capture: the eye camera renders
22
+ every frame with one asset in the scene, so the transition machinery cannot hold an image still
23
+ under a moving head. The fallback if a panel still pauses on the other transitions.
24
+ - One splat resident: the next file is only FETCHED during the out phase, and reaches the engine
25
+ after the old asset is destroyed and unloaded. `prepareSource(src, { transition: 'reassemble' })`
26
+ therefore only fetches and compiles (`numSplats` is `null`); `resident: true` opts back into the
27
+ full prepare. Measured peak: 1 asset, 1,179,648 gaussians, 58.8 MB of GPU textures, the still
28
+ photo's (a live `crossfade`: 2 assets, 131.6 MB).
29
+ - Latest wins: a newer sequence takes over from the amount on screen; any other call ends it as it
30
+ ends any transition. An HTTP error on the next file brings the current photo back and rejects.
31
+ - Both bodies are pre-warmed (by `prepareSource`, or before the out clock starts); `?dxrdiag`
32
+ records the sequence with its steps. See `docs/splat-effects.md` § Sequence transitions.
33
+
34
+ ## 1.20.0 — 2026-09-24
9
35
 
10
36
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change at rest; the
11
37
  first frames of a live `crossfade` / `wavefront` / particle transition change (see Fixed).
@@ -1164,6 +1164,58 @@ export function particleSpan(t, overlap, side) {
1164
1164
  /** Options a page may pass through setSource to both sides of a particle transition. */
1165
1165
  export const PARTICLE_TRANSITION_OPTIONS = Object.freeze(['order', 'stagger', 'jitter', 'maxDisparity', 'dotSize', 'noiseScale', 'layerSize', 'origin']);
1166
1166
 
1167
+ // ── setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }) ────────────
1168
+ //
1169
+ // One photo at a time: the current photo plays a reveal BACKWARDS until nothing of it is drawn,
1170
+ // it is released, the next one is loaded and placed hidden, and it plays a reveal FORWARDS. No
1171
+ // second camera, no live-outgoing layer, no overlay target, no frozen capture: the eye camera
1172
+ // renders every frame, with one asset in the scene.
1173
+ //
1174
+ // A reveal can take part only if it draws NOTHING at amount 0 — otherwise the swap would pop from
1175
+ // a visible end state to an empty start (inflate's end state is a flat photo: that swap is
1176
+ // 'flip'). `dissolve` is left out for the reason it is not a setSource transition: its sway has a
1177
+ // depth component (docs/splat-effects.md §Comfort).
1178
+
1179
+ /**
1180
+ * The reveals a sequence can run, with the option overrides each side gets by default. The
1181
+ * particle ones reuse the tuned sides of the particle transitions (a sparse `density`, and a
1182
+ * `vanish` so the swarm thins out to nothing and gathers out of nothing).
1183
+ */
1184
+ export const SEQUENCE_REVEALS = Object.freeze({
1185
+ assemble: { out: PARTICLE_TRANSITIONS.swarm.out.opts, in: PARTICLE_TRANSITIONS.swarm.in.opts },
1186
+ 'dissolve-in': { out: PARTICLE_TRANSITIONS.dust.out.opts, in: PARTICLE_TRANSITIONS.dust.in.opts },
1187
+ converge: { out: PARTICLE_TRANSITIONS.burst.out.opts, in: PARTICLE_TRANSITIONS.burst.in.opts },
1188
+ shimmer: { out: PARTICLE_TRANSITIONS['shimmer-cross'].out.opts, in: PARTICLE_TRANSITIONS['shimmer-cross'].in.opts },
1189
+ sweep: { out: {}, in: {} },
1190
+ fade: { out: {}, in: {} },
1191
+ });
1192
+
1193
+ /** Named sequences: `transition: 'reassemble'` = assemble backwards, then forwards. */
1194
+ export const SEQUENCE_TRANSITIONS = Object.freeze({
1195
+ reassemble: { out: 'assemble', in: 'assemble', durationMs: 3000, easing: 'linear', beat: 0.1 },
1196
+ });
1197
+
1198
+ /**
1199
+ * Does `effect` with resolved options `o` draw nothing at amount 0? sweep's front starts one band
1200
+ * before its origin and fade's coverage is 0; a particle reveal needs `hiddenAtZero` (converge,
1201
+ * shimmer: not yet launched; assemble, dissolve-in: `vanish` > 0).
1202
+ */
1203
+ export function emptyAtZero(effect, o) {
1204
+ if (effect === 'sweep' || effect === 'fade') return true;
1205
+ const def = EFFECTS[effect];
1206
+ return !!(def?.particle && def.hiddenAtZero?.(o));
1207
+ }
1208
+
1209
+ /**
1210
+ * The three spans of a sequence over its clock, as fractions: out over [0, a], the empty beat
1211
+ * over [a, 1 − a], in over [1 − a, 1], with a = (1 − beat) / 2.
1212
+ */
1213
+ export function sequenceSpans(beat) {
1214
+ const b = Math.min(0.9, Math.max(0, beat));
1215
+ const a = (1 - b) / 2;
1216
+ return { out: a, beat: b, in: a };
1217
+ }
1218
+
1167
1219
 
1168
1220
  /**
1169
1221
  * The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
@@ -1522,6 +1574,55 @@ export class SplatEffects {
1522
1574
  };
1523
1575
  }
1524
1576
 
1577
+ /**
1578
+ * A TILE-scope effect driven by the adapter's clock (setSource's sequence transitions: one photo
1579
+ * in the scene, so the tile material carries its values and every manager of the tile reads
1580
+ * them — a fresh one included, so a just-placed photo draws its hidden start state on its very
1581
+ * first frame). Render time: no work-buffer rewrite, no re-sort. Named `name` (the chunk's
1582
+ * prefix), so a sequence installs the same program as a particle transition of the same effect
1583
+ * and order. Hidden from effects() and from a page's stopEffect(). Returns
1584
+ * { set(amount, timeS), restart(), remove(), amount }.
1585
+ */
1586
+ driveTile(name, effect, opts) {
1587
+ const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 0 }, 'set', { internal: true });
1588
+ const inst = this._makeInstance(name, o, 'set');
1589
+ inst.def = EFFECTS[effect];
1590
+ inst.hidden = true;
1591
+ inst.timeS = 0;
1592
+ this._replace('tile', name, inst);
1593
+ this._setupInstance(inst);
1594
+ this._apply('tile', inst, this.ctx.now());
1595
+ this._install('tile');
1596
+ const alive = () => !this._disposed && this.scopes.get('tile')?.get(name) === inst;
1597
+ return {
1598
+ set: (amount, timeS = inst.timeS) => {
1599
+ if (!alive()) return false;
1600
+ inst.opts.progress = Math.min(1, Math.max(0, amount));
1601
+ inst.timeS = timeS;
1602
+ this._apply('tile', inst, this.ctx.now());
1603
+ return true;
1604
+ },
1605
+ restart: () => {
1606
+ if (!alive()) return;
1607
+ inst.state = {};
1608
+ this._setupInstance(inst);
1609
+ this._apply('tile', inst, this.ctx.now());
1610
+ },
1611
+ remove: () => {
1612
+ if (!alive()) return;
1613
+ this.scopes.get('tile').delete(name);
1614
+ inst.resolve({ finished: true });
1615
+ this._install('tile');
1616
+ },
1617
+ get amount() {
1618
+ return inst.opts.progress;
1619
+ },
1620
+ get alive() {
1621
+ return alive();
1622
+ },
1623
+ };
1624
+ }
1625
+
1525
1626
  /**
1526
1627
  * ONE tile-scope body for the two photos of a setSource particle transition, with each photo's
1527
1628
  * values on its OWN mesh instance (the engine draws each photo through its own gsplat manager and
@@ -61,6 +61,10 @@ import {
61
61
  PARTICLE_TRANSITIONS,
62
62
  PARTICLE_TRANSITION_OPTIONS,
63
63
  particleSpan,
64
+ SEQUENCE_REVEALS,
65
+ SEQUENCE_TRANSITIONS,
66
+ emptyAtZero,
67
+ sequenceSpans,
64
68
  } from './inline3d-splat-effects.js';
65
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
66
70
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
@@ -2486,8 +2490,56 @@ export const SOURCE_TRANSITIONS = Object.freeze({
2486
2490
  wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
2487
2491
  // the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
2488
2492
  ...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
2493
+ // the named sequences (./inline3d-splat-effects.js SEQUENCE_TRANSITIONS); the general form is
2494
+ // `transition: { type: 'sequence', out, in }`
2495
+ ...Object.fromEntries(Object.entries(SEQUENCE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, sequence: true }])),
2489
2496
  });
2490
2497
 
2498
+ /** A sequence's own defaults (the general form, `{ type: 'sequence', out, in }`). */
2499
+ const SEQUENCE_DEFAULTS = Object.freeze({ durationMs: 3000, easing: 'linear', beat: 0.1 });
2500
+
2501
+ /**
2502
+ * A sequence transition's plan: { name, out: { effect, opts }, in: { effect, opts }, beat } —
2503
+ * each side's defaults (SEQUENCE_REVEALS), then the page's shared particle options (particle
2504
+ * sides only), then `outgoingFx` / `incomingFx`. Every side is validated as its effect, and must
2505
+ * draw nothing at amount 0 (the swap happens there). `spec` = the named sequence or the object.
2506
+ */
2507
+ export function resolveSequence(name, spec, o = {}) {
2508
+ const want = (k) => {
2509
+ const e = spec[k];
2510
+ if (typeof e !== 'string' || !(e in SEQUENCE_REVEALS)) {
2511
+ throw new Error(
2512
+ `@displayxr/inline3d/splat: setSource sequence ${k} '${e}' — expected one of ${Object.keys(SEQUENCE_REVEALS).join(', ')} ` +
2513
+ "(a reveal that draws nothing at its start; inflate's start is a flat photo — use transition 'flip').",
2514
+ );
2515
+ }
2516
+ return e;
2517
+ };
2518
+ const outEffect = want('out');
2519
+ const inEffect = want('in');
2520
+ for (const k of ['outgoingFx', 'incomingFx']) {
2521
+ if (o[k] !== undefined && (o[k] === null || typeof o[k] !== 'object')) throw new TypeError(`@displayxr/inline3d/splat: setSource ${k} must be an object of effect options.`);
2522
+ }
2523
+ const beat = o.beat ?? spec.beat ?? SEQUENCE_DEFAULTS.beat;
2524
+ if (!(typeof beat === 'number' && beat >= 0 && beat <= 0.9)) throw new RangeError(`@displayxr/inline3d/splat: setSource beat must be a number in [0, 0.9], got ${o.beat ?? spec.beat}.`);
2525
+ const side = (which, effect, extra) => {
2526
+ const shared = {};
2527
+ // sweep / fade take only the origin of the shared options
2528
+ const keys = EFFECTS[effect].particle ? PARTICLE_TRANSITION_OPTIONS : ['origin'];
2529
+ for (const k of keys) if (o[k] !== undefined) shared[k] = o[k];
2530
+ const opts = { ...SEQUENCE_REVEALS[effect][which], ...shared, ...(extra || {}) };
2531
+ if (opts.order === 'layers') {
2532
+ throw new Error(`@displayxr/inline3d/splat: setSource sequence: order 'layers' is a reveal-only order (it needs the file index, which a render-time body does not have).`);
2533
+ }
2534
+ const r = resolveEffectOptions(effect, { ...opts, scope: 'tile' }, 'set', { internal: true }); // throws on a bad one
2535
+ if (!emptyAtZero(effect, r)) {
2536
+ throw new Error(`@displayxr/inline3d/splat: setSource sequence ${which}: '${effect}' with these options still draws at its start (vanish 0?) — the swap would pop. Give it vanish > 0.`);
2537
+ }
2538
+ return { effect, opts };
2539
+ };
2540
+ return { name, out: side('out', outEffect, o.outgoingFx), in: side('in', inEffect, o.incomingFx), beat };
2541
+ }
2542
+
2491
2543
  /**
2492
2544
  * A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
2493
2545
  * overlap } — the table's defaults, then the page's shared particle options, then its
@@ -2518,11 +2570,34 @@ export function resolveParticleTransition(name, o = {}) {
2518
2570
  return { out, in: inc, overlap };
2519
2571
  }
2520
2572
 
2573
+ /** resolveSwap for a sequence: the plan carries `sequence` (resolveSequence) and no outgoing image. */
2574
+ function resolveSequenceSwap(name, spec, o) {
2575
+ if (resolveRevealOption(o.reveal)) throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${name}' is its own reveal.`);
2576
+ if (o.outgoing !== undefined && o.outgoing !== null) {
2577
+ resolveOutgoingOption(o.outgoing); // a bad value is still a bad value
2578
+ throw new Error(`@displayxr/inline3d/splat: setSource outgoing does not apply to '${name}' — one photo at a time, no outgoing image.`);
2579
+ }
2580
+ const durationMs = o.durationMs ?? spec.durationMs ?? SEQUENCE_DEFAULTS.durationMs;
2581
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setSource durationMs must be ≥ 0, got ${o.durationMs}.`);
2582
+ const easing = o.easing ?? spec.easing ?? SEQUENCE_DEFAULTS.easing;
2583
+ if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
2584
+ const sequence = resolveSequence(name, spec, o);
2585
+ return { transition: 'sequence', durationMs, easing, reveal: null, outgoing: null, particles: null, sequence };
2586
+ }
2587
+
2521
2588
  /** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
2522
2589
  export function resolveSwap(o = {}) {
2523
2590
  if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
2591
+ // The general sequence form: transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }
2592
+ // — its own keys read like setSource's (setSource's win when both are given).
2593
+ if (o.transition !== null && typeof o.transition === 'object') {
2594
+ const { type, out: outName, in: inName, ...rest } = o.transition;
2595
+ if (type !== 'sequence') throw new Error(`@displayxr/inline3d/splat: setSource transition object — expected { type: 'sequence', out, in }, got type '${type}'.`);
2596
+ return resolveSequenceSwap('sequence', { out: outName, in: inName, ...SEQUENCE_DEFAULTS }, { ...rest, ...o, transition: 'sequence' });
2597
+ }
2524
2598
  const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
2525
2599
  const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
2600
+ if (SOURCE_TRANSITIONS[transition]?.sequence) return resolveSequenceSwap(transition, SEQUENCE_TRANSITIONS[transition], o);
2526
2601
  if (!(transition in SOURCE_TRANSITIONS)) {
2527
2602
  throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
2528
2603
  }
@@ -3807,21 +3882,34 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3807
3882
  if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
3808
3883
  const t0 = performance.now();
3809
3884
  if (diag && diag.phase === 'idle') diag.setPhase('prepare');
3810
- let loaded;
3885
+ // A SEQUENCE keeps one splat resident: its prepare only FETCHES the bytes (nothing goes to the
3886
+ // engine — no decode, no GPU upload) and compiles its shaders; the engine load runs at the swap,
3887
+ // once the current asset is gone, in the empty beat. `resident: true` opts back into the full
3888
+ // prepare (decoded + uploaded now, not in the scene): no load at the swap, two assets resident
3889
+ // during the dwell.
3890
+ if (po.resident !== undefined && typeof po.resident !== 'boolean') throw new TypeError('@displayxr/inline3d/splat: prepareSource resident must be a boolean.');
3891
+ const fetchOnly = !!warm?.sequence && po.resident !== true;
3892
+ let loaded = null;
3893
+ let source = null;
3811
3894
  try {
3812
- loaded = await loadOne(pcModule, app, src, { background: true });
3895
+ if (fetchOnly) {
3896
+ const got = await fetchSourceBytes(src);
3897
+ if (!got.ok) throw new Error(`@displayxr/inline3d/splat: prepareSource could not fetch ${src} (HTTP ${got.status}).`);
3898
+ source = got.source;
3899
+ }
3900
+ else loaded = await loadOne(pcModule, app, src, { background: true });
3813
3901
  } finally {
3814
3902
  if (diag?.phase === 'prepare') diag.setPhase('idle');
3815
3903
  }
3816
3904
  perfSpan('prepareSource', t0);
3817
- diag?.mark('prepared', { ms: Math.round(performance.now() - t0) });
3905
+ diag?.mark('prepared', { ms: Math.round(performance.now() - t0), fetchOnly });
3818
3906
  // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3819
3907
  await prewarmTransition(warm); // null (no transition declared): the overlay only
3820
- const entry = { loaded, state: 'ready', dispose: null };
3908
+ const entry = { loaded, source, state: 'ready', dispose: null };
3821
3909
  const prepared = {
3822
3910
  [PREPARED_TAG]: true,
3823
- /** The asset's own count (every splat of a flat source). */
3824
- numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
3911
+ /** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
3912
+ numSplats: loaded ? loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0 : null,
3825
3913
  /** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
3826
3914
  get state() {
3827
3915
  return entry.state;
@@ -3830,8 +3918,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3830
3918
  if (entry.state !== 'ready') return;
3831
3919
  entry.state = 'disposed';
3832
3920
  livePrepared.delete(entry);
3833
- app.assets.remove(loaded.asset);
3834
- loaded.asset.unload?.();
3921
+ entry.source = null;
3922
+ if (loaded) {
3923
+ app.assets.remove(loaded.asset);
3924
+ loaded.asset.unload?.();
3925
+ }
3835
3926
  },
3836
3927
  };
3837
3928
  entry.dispose = prepared.dispose;
@@ -3861,27 +3952,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3861
3952
  // The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
3862
3953
  // (crossfade, wavefront and the particles all composite through them; created on the first
3863
3954
  // capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
3864
- const chunkKey = plan?.particles
3865
- ? `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`
3955
+ // [key, code()] per variant: a particle transition's one body, the wavefront's ridge + cull, a
3956
+ // sequence's two (its out body, then its in body — one program each, or one when they match).
3957
+ const orderOf = (side) => (EFFECTS[side.effect].particle ? { order: side.opts.order } : {});
3958
+ const variants = plan?.particles
3959
+ ? [[`${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })]]
3866
3960
  : plan?.transition === 'wavefront'
3867
- ? 'wavefront'
3868
- : null;
3869
- const wantChunk = !!(fx && chunkKey && !prewarmed.has(chunkKey));
3870
- const wantOverlay = !prewarmed.has('overlay');
3871
- if (!wantChunk && !wantOverlay) return;
3961
+ ? [['wavefront', () => fx.sharedChunkCodeFor([['transition', 'wavefront', {}], ['transition-cull', 'wipecull', {}]])]]
3962
+ : plan?.sequence
3963
+ ? [plan.sequence.out, plan.sequence.in].map((side) => [`${side.effect}|${side.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', side.effect, orderOf(side))])
3964
+ : [];
3965
+ const wantChunks = fx ? variants.filter(([k], i) => !prewarmed.has(k) && variants.findIndex((v) => v[0] === k) === i) : [];
3966
+ // A sequence draws through no overlay: nothing to warm there.
3967
+ const wantOverlay = !prewarmed.has('overlay') && !plan?.sequence;
3968
+ if (!wantChunks.length && !wantOverlay) return;
3872
3969
  await yieldIdle();
3873
3970
  if (removed) return;
3874
3971
  try {
3875
3972
  const pc = pcModule;
3876
3973
  const made = [];
3877
- if (wantChunk && fx) {
3974
+ for (const [chunkKey, codeOf] of fx ? wantChunks : []) {
3878
3975
  const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3879
- const code = plan.particles
3880
- ? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
3881
- : fx.sharedChunkCodeFor([
3882
- ['transition', 'wavefront', {}],
3883
- ['transition-cull', 'wipecull', {}],
3884
- ]);
3976
+ const code = codeOf();
3885
3977
  let issued = 0;
3886
3978
  for (const cam of cams) {
3887
3979
  const mi = managerMi(cam, null);
@@ -3987,7 +4079,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3987
4079
  const gen = ++sourceGen;
3988
4080
  if (diag) {
3989
4081
  rigLockArmed = true;
3990
- diag.setPhase('swap', { transition: plan.transition, outgoing: plan.outgoing || 'default', prepared: preparedAssets.has(next) });
4082
+ diag.setPhase('swap', {
4083
+ transition: plan.sequence ? `${plan.sequence.name}(${plan.sequence.out.effect}>${plan.sequence.in.effect})` : plan.transition,
4084
+ outgoing: plan.sequence ? 'none (one splat)' : plan.outgoing || 'default',
4085
+ prepared: preparedAssets.has(next),
4086
+ });
3991
4087
  }
3992
4088
  // A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
3993
4089
  const prep = preparedAssets.get(next) || null;
@@ -4004,10 +4100,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4004
4100
  await first.catch(() => null); // a failed first asset may be replaced
4005
4101
  if (!app || removed) return out;
4006
4102
  const pc = pcModule;
4103
+ // A sequence: one splat at a time (playSequence). Without the effects runner (no asset ever
4104
+ // loaded) there is nothing to play out or in: a cut.
4105
+ if (plan.sequence && fx) return playSequence({ next, plan, gen, prep, resetPose, app, pc });
4007
4106
  // Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
4008
4107
  // loads, rather than on the window's first frames. Best effort, not awaited.
4009
- if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
4010
- const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
4108
+ if (!prep && current && plan.transition !== 'cut' && !plan.sequence) prewarmTransition(plan);
4109
+ // (a fetch-only prepare — prepareSource for a sequence — loads here, from its bytes)
4110
+ const loaded = prep ? prep.loaded || (await loadOne(pc, app, prep.source)) : await loadOne(pc, app, next);
4011
4111
  if (removed || gen !== sourceGen) {
4012
4112
  app.assets.remove(loaded.asset);
4013
4113
  loaded.asset.unload?.();
@@ -4015,7 +4115,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4015
4115
  }
4016
4116
  const prev = current;
4017
4117
  pendingSwap?.finish();
4018
- const transition = prev ? plan.transition : 'cut';
4118
+ const transition = prev && !plan.sequence ? plan.transition : 'cut';
4019
4119
  const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
4020
4120
  // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
4021
4121
  // drawn. The overlay goes up in the same task, so no frame shows neither photo.
@@ -4258,6 +4358,240 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4258
4358
  return out;
4259
4359
  }
4260
4360
 
4361
+ /**
4362
+ * The BYTES of a source, fetched without touching the engine (a sequence's preload: CPU memory
4363
+ * only — no decode, no GPU upload). Bytes / a Blob are returned as they are. A URL the engine
4364
+ * must resolve itself (a Streamed SOG, an unbundled SOG's meta.json) is returned as the URL, and
4365
+ * so is any URL whose fetch THROWS here (CORS, a scheme fetch does not take…): the engine's own
4366
+ * load then runs at the swap. Resolves { source, ok }: ok = false only when the server answered
4367
+ * and it was not the file (an HTTP error) — the one case known before the current photo goes.
4368
+ */
4369
+ async function fetchSourceBytes(src, signal) {
4370
+ if (typeof src !== 'string') return { source: src, ok: true };
4371
+ const fmt = engineFormatFor(src, null, opts.fileName, opts.fileType);
4372
+ if (!fmt || fmt.streamed || fmt.ext === 'json' || typeof fetch !== 'function') return { source: src, ok: true };
4373
+ try {
4374
+ const r = await fetch(src, signal ? { signal } : undefined);
4375
+ if (!r.ok) return { source: src, ok: false, status: r.status };
4376
+ return { source: new Uint8Array(await r.arrayBuffer()), ok: true };
4377
+ } catch (err) {
4378
+ if (signal?.aborted) return { source: null, ok: false, aborted: true };
4379
+ return { source: src, ok: true, fetchError: err };
4380
+ }
4381
+ }
4382
+
4383
+ /** The sequence on screen: the asset its effect is on and how much of it shows (a newer sequence starts from there). */
4384
+ let seqShown = null;
4385
+
4386
+ /**
4387
+ * setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }): ONE splat at
4388
+ * a time.
4389
+ * out — the current photo plays `out` backwards on the eye camera (a tile-scope body at
4390
+ * render time) until nothing of it is drawn. Meanwhile the next file's BYTES are
4391
+ * fetched (a URL; a prepareSource() result is already in hand).
4392
+ * swap — the current asset is released and DESTROYED (entity + GPU resource) before the next
4393
+ * one is given to the engine; then it is decoded, uploaded, placed and adopted (rig
4394
+ * waterfall, rig declared) in one task with its effect at amount 0, so its first frame
4395
+ * draws nothing.
4396
+ * in — after the empty beat (and at least 3 frames, for its work buffer and sort and the
4397
+ * rig to land), its effect's frame is re-taken under the new rig and it plays `in`
4398
+ * forwards. The end removes the body: the exact baseline.
4399
+ * No second camera, layer, target, overlay or capture. Latest wins: a newer sequence takes over
4400
+ * from where this one stands (its out phase starts at the amount shown); a newer other call
4401
+ * ends this one when its asset has loaded (or at once, once this one's photo is gone).
4402
+ */
4403
+ async function playSequence({ next, plan, gen, prep, resetPose, app, pc }) {
4404
+ const seq = plan.sequence;
4405
+ const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
4406
+ const spans = sequenceSpans(seq.beat);
4407
+ const outMs = plan.durationMs * spans.out;
4408
+ const beatMs = plan.durationMs * spans.beat;
4409
+ const inMs = plan.durationMs * spans.in;
4410
+ let finished = false;
4411
+ const finishers = [];
4412
+ /** Resolvers of the waits in flight: ending this call resolves them at once, not on a later frame. */
4413
+ const wakers = new Set();
4414
+ const finish = () => {
4415
+ if (finished) return;
4416
+ finished = true;
4417
+ for (const f of finishers) f();
4418
+ for (const w of [...wakers]) w();
4419
+ wakers.clear();
4420
+ viewer._transitionState = null;
4421
+ if (gen === sourceGen) diag?.setPhase('settle');
4422
+ if (pendingSwap?.finish === finish) pendingSwap = null;
4423
+ };
4424
+ const ac = typeof AbortController === 'function' ? new AbortController() : null;
4425
+ finishers.push(() => ac?.abort());
4426
+ let shown = null; // this call's seqShown
4427
+ finishers.push(() => {
4428
+ if (seqShown && seqShown === shown) seqShown = null;
4429
+ });
4430
+ const dropLoaded = (l) => {
4431
+ if (!l) return;
4432
+ app.assets.remove(l.asset);
4433
+ l.asset.unload?.();
4434
+ };
4435
+ /** Superseded (a newer setSource) or removed: this call stops at its next step. */
4436
+ const stale = () => finished || removed || gen !== sourceGen;
4437
+ /** Run `step(k, s)` over `ms` of viewer ticks (k 0 → 1, s = seconds); false if ended early. */
4438
+ const span = (ms, step) =>
4439
+ new Promise((resolve) => {
4440
+ let t0 = null;
4441
+ const wake = () => resolve(false);
4442
+ wakers.add(wake);
4443
+ viewer._hooks.push((t) => {
4444
+ if (finished || removed) return (resolve(false), false);
4445
+ if (!wakers.has(wake)) return false;
4446
+ if (t0 === null) t0 = t;
4447
+ const k = ms > 0 ? Math.min(1, Math.max(0, (t - t0) / ms)) : 1;
4448
+ step(k, (t - t0) / 1000);
4449
+ if (k >= 1) return (wakers.delete(wake), resolve(true), false);
4450
+ return true;
4451
+ });
4452
+ });
4453
+ // The next asset's bytes, fetched during the out phase. Nothing of it goes to the engine yet.
4454
+ const sourceP = prep ? Promise.resolve({ source: prep.loaded ? null : prep.source, ok: true }) : fetchSourceBytes(next, ac?.signal);
4455
+ // Both bodies compiled before the clock starts. The photo stays on screen meanwhile, live — and
4456
+ // a sequence already playing on it keeps playing: it is taken over only once this one can run.
4457
+ await prewarmTransition(plan);
4458
+ if (stale()) {
4459
+ if (prep) dropLoaded(prep.loaded);
4460
+ finish();
4461
+ return out;
4462
+ }
4463
+ // a sequence already on this photo: take over from how much of it shows, in this same task
4464
+ const startAmount = seqShown && current && seqShown.entity === current.entity ? seqShown.amount : 1;
4465
+ pendingSwap?.finish(); // latest wins: whatever was in flight ends now (at its end state)
4466
+ pendingSwap = { finish };
4467
+ viewer._transitionPath = 'sequence'; // diagnostics
4468
+ diag?.mark('sequence', { name: seq.name, out: seq.out.effect, in: seq.in.effect, beat: seq.beat, from: +startAmount.toFixed(3), prepared: prep ? (prep.loaded ? 'resident' : 'fetched') : 'no' });
4469
+
4470
+ // ── out ──
4471
+ const prev = current;
4472
+ diag?.setPhase('window');
4473
+ if (prev) {
4474
+ const fxOut = fx.driveTile('transition', seq.out.effect, seq.out.opts); // its frame: the current rig's
4475
+ finishers.push(() => fxOut.remove());
4476
+ fxOut.set(startAmount, 0);
4477
+ shown = seqShown = { entity: prev.entity, amount: startAmount };
4478
+ const full = await span(outMs * startAmount, (k, s) => {
4479
+ const a = startAmount * (1 - ease(k));
4480
+ fxOut.set(a, s);
4481
+ shown.amount = a;
4482
+ viewer._transitionState = { phase: 'out', raw: k }; // diagnostics
4483
+ });
4484
+ if (!full) {
4485
+ // taken over (a newer call finished this one) or removed
4486
+ if (prep) dropLoaded(prep.loaded);
4487
+ return out;
4488
+ }
4489
+ diag?.mark('out-done');
4490
+ // Nothing of the current photo is drawn now. Its file must have arrived before it is let go:
4491
+ // if the next one cannot be fetched, the current one comes back instead.
4492
+ const got = await sourceP;
4493
+ if (!got.ok && !got.aborted && !stale()) {
4494
+ diag?.mark('fetch-failed', { status: got.status ?? null });
4495
+ viewer._transitionState = { phase: 'back', raw: 0 };
4496
+ await span(inMs, (k, s) => {
4497
+ const a = ease(k);
4498
+ fxOut.set(a, s);
4499
+ shown.amount = a;
4500
+ });
4501
+ finish();
4502
+ throw new Error(`@displayxr/inline3d/splat: setSource could not fetch ${typeof next === 'string' ? next : 'the source'} (HTTP ${got.status}); the current photo stays.`);
4503
+ }
4504
+ // COMMITTED: release it, and wait until it is gone (entity destroyed, resource unloaded)
4505
+ // before the next one is made resident. A newer call from here on finds nothing on screen.
4506
+ current = null;
4507
+ out.mesh = null;
4508
+ shown = null;
4509
+ seqShown = null;
4510
+ await new Promise((resolve) => {
4511
+ prev.entity.enabled = false;
4512
+ fx?.dropEntity(prev.entity);
4513
+ let frames = 0;
4514
+ // the engine's gsplat world keeps the placement until its next rebuild: destroy a few frames later
4515
+ viewer._hooks.push(() => {
4516
+ if (++frames < 4 && !removed) return true;
4517
+ prev.entity.destroy?.();
4518
+ app.assets.remove(prev.asset);
4519
+ prev.asset.unload?.();
4520
+ resolve();
4521
+ return false;
4522
+ });
4523
+ });
4524
+ diag?.mark('released');
4525
+ }
4526
+ const outEndAt = now();
4527
+ if (stale()) {
4528
+ if (prep) dropLoaded(prep.loaded);
4529
+ finish();
4530
+ return out;
4531
+ }
4532
+
4533
+ // ── swap ──
4534
+ let loaded;
4535
+ try {
4536
+ const got = await sourceP;
4537
+ viewer._transitionState = { phase: 'load', raw: 0 };
4538
+ const loadP = prep?.loaded ? Promise.resolve(prep.loaded) : loadOne(pc, app, got.source ?? next);
4539
+ // a newer call ends this one at once; the asset, when it lands, is dropped unseen
4540
+ const r = await Promise.race([loadP.then((l) => ({ l })), new Promise((resolve) => wakers.add(() => resolve(null)))]);
4541
+ if (!r) {
4542
+ loadP.then(dropLoaded, () => {});
4543
+ return out;
4544
+ }
4545
+ loaded = r.l;
4546
+ } catch (err) {
4547
+ finish();
4548
+ if (stale()) return out;
4549
+ throw err; // the tile is empty: the current photo was already gone
4550
+ }
4551
+ if (stale()) {
4552
+ dropLoaded(loaded);
4553
+ finish();
4554
+ return out;
4555
+ }
4556
+ diag?.mark('loaded');
4557
+ const entity = viewer.addSplatAsset(loaded.asset);
4558
+ out.mesh = { numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0, entity, asset: loaded.asset, resource: loaded.res };
4559
+ const kept = applyLoaded(loaded); // the rig waterfall; the rig is declared here, before any frame of it
4560
+ current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
4561
+ if (resetPose && !pageMode) viewer.resetPose();
4562
+ // same task: the in body at amount 0 (tile material values), so its first frame draws nothing
4563
+ const fxIn = fx.driveTile('transition', seq.in.effect, seq.in.opts);
4564
+ finishers.push(() => fxIn.remove());
4565
+ fxIn.set(0, 0);
4566
+ shown = seqShown = { entity, amount: 0 };
4567
+ diag?.mark('adopted');
4568
+
4569
+ // ── the empty beat: at least `beat` since the old photo went, and 3 frames of the new one ──
4570
+ await new Promise((resolve) => {
4571
+ let ticks = 0;
4572
+ wakers.add(resolve);
4573
+ viewer._hooks.push((t) => {
4574
+ if (finished || removed) return (resolve(), false);
4575
+ viewer._transitionState = { phase: 'beat', raw: 0 };
4576
+ if (++ticks >= 3 && t - outEndAt >= beatMs) return (resolve(), false);
4577
+ return true;
4578
+ });
4579
+ });
4580
+ if (finished || removed) return out;
4581
+
4582
+ // ── in ──
4583
+ fxIn.restart(); // its frame: the new photo under its own rig, now declared and drawn
4584
+ diag?.mark('in-start');
4585
+ const full = await span(inMs, (k, s) => {
4586
+ const a = ease(k);
4587
+ fxIn.set(a, s);
4588
+ shown.amount = a;
4589
+ viewer._transitionState = { phase: 'in', raw: k }; // diagnostics
4590
+ });
4591
+ if (full) finish(); // removes the body: the plain render, exactly
4592
+ return out;
4593
+ }
4594
+
4261
4595
  /** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
4262
4596
  const managerMi = (cam, layer) => {
4263
4597
  const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
package/splat.d.ts CHANGED
@@ -623,8 +623,17 @@ export interface SplatSourceOptions {
623
623
  * (nothing moves: twinkling points out, twinkling points in) and `'dust'` (drifting dust out,
624
624
  * gathering dust in). 2.6–2.8 s, linear shared clock (each particle eases itself). The old
625
625
  * photo is live, in 2D too; a hidden tab gets the crossfade.
626
- */
627
- transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront' | SplatParticleTransitionName;
626
+ *
627
+ * The SEQUENCE transitions (docs/splat-effects.md §Sequence transitions): ONE photo at a time.
628
+ * `'reassemble'` disperses the current photo (`assemble` backwards), releases it, loads the next
629
+ * one and assembles it (3000 ms: 45 % out, a 10 % empty beat, 45 % in). The general form
630
+ * `{ type: 'sequence', out, in }` runs any two of `assemble`, `dissolve-in`, `converge`,
631
+ * `shimmer`, `sweep`, `fade`. No second camera, overlay or capture: the eye camera renders every
632
+ * frame.
633
+ */
634
+ transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront' | SplatParticleTransitionName | 'reassemble' | SplatSequenceTransition;
635
+ /** Sequence transitions: the empty beat between out and in, a fraction of `durationMs`, 0..0.9 (default 0.1). */
636
+ beat?: number;
628
637
  durationMs?: number;
629
638
  easing?: SplatEasing;
630
639
  /** `cut`/`crossfade` only: reveal the INCOMING asset (entity scope) while the old one fades. */
@@ -656,9 +665,9 @@ export interface SplatSourceOptions {
656
665
  origin?: SplatEffectOrigin;
657
666
  /** Particle transitions: how much of the clock the two photos' spans share, 0..1 (0 = one after the other). */
658
667
  overlap?: number;
659
- /** Particle transitions: option overrides for the OUTGOING photo's effect. */
668
+ /** Particle and sequence transitions: option overrides for the OUTGOING photo's effect. */
660
669
  outgoingFx?: SplatParticleOptions & Record<string, unknown>;
661
- /** Particle transitions: option overrides for the INCOMING photo's effect. */
670
+ /** Particle and sequence transitions: option overrides for the INCOMING photo's effect. */
662
671
  incomingFx?: SplatParticleOptions & Record<string, unknown>;
663
672
  }
664
673
 
@@ -668,18 +677,45 @@ export interface SplatSourceOptions {
668
677
  * first transition of each kind in a page blocks its first frame on the link (tens of ms, hundreds
669
678
  * the first time a machine sees the variant). Validated like setSource's.
670
679
  */
671
- export type SplatPrepareOptions = SplatSourceOptions;
680
+ export type SplatPrepareOptions = SplatSourceOptions & {
681
+ /**
682
+ * Sequence transitions only. Default false: the prepare only FETCHES the file's bytes, so one
683
+ * splat stays resident and the decode + upload run at the swap, in the empty beat. `true`: the
684
+ * full prepare (decoded and uploaded now, not in the scene) — no load at the swap, two assets
685
+ * resident during the dwell.
686
+ */
687
+ resident?: boolean;
688
+ };
672
689
 
673
690
  /** setSource's particle transitions (docs/splat-effects.md §Particle transitions). */
674
691
  export type SplatParticleTransitionName = 'swarm' | 'burst' | 'shimmer-cross' | 'dust';
675
692
 
693
+ /** The reveals a sequence transition can run (each draws nothing at its start). */
694
+ export type SplatSequenceRevealName = 'assemble' | 'dissolve-in' | 'converge' | 'shimmer' | 'sweep' | 'fade';
695
+
696
+ /**
697
+ * setSource's general SEQUENCE transition: `out` backwards on the current photo, the swap, `in`
698
+ * forwards on the next. `durationMs` / `beat` / `easing` here or at the top level of the options
699
+ * (the top level wins).
700
+ */
701
+ export interface SplatSequenceTransition {
702
+ type: 'sequence';
703
+ out: SplatSequenceRevealName;
704
+ in: SplatSequenceRevealName;
705
+ durationMs?: number;
706
+ beat?: number;
707
+ easing?: SplatEasing;
708
+ }
709
+
676
710
  /**
677
711
  * What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
678
- * fully resident (GPU textures + the engine's centre array) until used or disposed.
712
+ * fully resident (GPU textures + the engine's centre array) until used or disposed — except for a
713
+ * sequence transition (`prepareSource(src, { transition: 'reassemble' })`), whose prepare only
714
+ * fetches the file's bytes: nothing reaches the engine until the swap.
679
715
  */
680
716
  export interface SplatPreparedSource {
681
- /** The asset's own count (every splat of a flat source). */
682
- readonly numSplats: number;
717
+ /** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
718
+ readonly numSplats: number | null;
683
719
  /** `'ready'` until `setSource` uses it (`'used'`) or `dispose()` drops it (`'disposed'`). */
684
720
  readonly state: 'ready' | 'used' | 'disposed';
685
721
  /** Release the prepared asset (no-op once used or disposed). */