@glissade/core 0.12.1 → 0.13.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clips.d.ts +104 -2
- package/dist/clips.js +262 -10
- package/dist/targetRef.js +40 -10
- package/package.json +1 -1
package/dist/clips.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ interface DurationOpts {
|
|
|
88
88
|
/** Arriving ease of the motion. */
|
|
89
89
|
ease?: EaseSpec;
|
|
90
90
|
}
|
|
91
|
-
/** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
|
|
91
|
+
/** Entrance: opacity 0→1 and scale [0.8,0.8]→[1,1] (a "pop" in). */
|
|
92
92
|
declare function popIn(opts?: DurationOpts): Clip;
|
|
93
93
|
type SlideEdge = 'left' | 'right' | 'top' | 'bottom';
|
|
94
94
|
/**
|
|
@@ -111,4 +111,106 @@ declare function driftLoop(opts?: (DurationOpts & {
|
|
|
111
111
|
amplitude?: number;
|
|
112
112
|
})): Clip;
|
|
113
113
|
//#endregion
|
|
114
|
-
|
|
114
|
+
//#region src/morph.d.ts
|
|
115
|
+
/** An axis-aligned box. `x`,`y` are the box CENTER (the Rect `position` convention). */
|
|
116
|
+
interface Box {
|
|
117
|
+
x: number;
|
|
118
|
+
y: number;
|
|
119
|
+
w: number;
|
|
120
|
+
h: number;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The shared-element contract: `morphNode` is the ONE element that travels (its
|
|
124
|
+
* `position`+`scale` are FLIPped); `fromNode`/`toNode` (both optional) cross-fade
|
|
125
|
+
* their `opacity`. The explicit map IS the "shared morphId" — there is no scene
|
|
126
|
+
* query, the caller names the three roles.
|
|
127
|
+
*/
|
|
128
|
+
interface MorphTargets {
|
|
129
|
+
morphNode: TweenTarget;
|
|
130
|
+
fromNode?: TweenTarget;
|
|
131
|
+
toNode?: TweenTarget;
|
|
132
|
+
}
|
|
133
|
+
interface MorphOpts {
|
|
134
|
+
/** Wall-clock start second of the morph. */
|
|
135
|
+
at: number;
|
|
136
|
+
/** Total morph length in seconds (> 0). */
|
|
137
|
+
duration: number;
|
|
138
|
+
/** Arriving ease of position/scale and the cross-fade (default linear). */
|
|
139
|
+
ease?: EaseSpec;
|
|
140
|
+
/**
|
|
141
|
+
* The scale BASIS: `[base.w, base.h]` is the size at which `morphNode` renders
|
|
142
|
+
* at scale [1,1]. Defaults to `to` (the TO box), so the end scale is [1,1] when
|
|
143
|
+
* `morphNode` is authored at the document's size. `fromScale = from/base`,
|
|
144
|
+
* `toScale = to/base`.
|
|
145
|
+
*/
|
|
146
|
+
base?: {
|
|
147
|
+
w: number;
|
|
148
|
+
h: number;
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Cross-fade split in [0,1] (default 0.5): `fromNode` fades out over
|
|
152
|
+
* `duration*crossfade`; `toNode` fades in starting at `duration*(1-crossfade)`.
|
|
153
|
+
*/
|
|
154
|
+
crossfade?: number;
|
|
155
|
+
/** v1 default `'transform'` (position+scale FLIP). `'size'` reserved/deferred. */
|
|
156
|
+
metric?: 'transform';
|
|
157
|
+
}
|
|
158
|
+
interface MorphResult {
|
|
159
|
+
tracks: ClipResult['tracks'];
|
|
160
|
+
/** Wall-clock end second (`at + duration`). */
|
|
161
|
+
end: number;
|
|
162
|
+
}
|
|
163
|
+
declare class MorphError extends Error {
|
|
164
|
+
constructor(message: string);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
|
|
168
|
+
*
|
|
169
|
+
* morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
|
|
170
|
+
* { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
|
|
171
|
+
* { at: 0.5, duration: 1.2 })
|
|
172
|
+
*
|
|
173
|
+
* Emits (via the clip path):
|
|
174
|
+
* morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
|
|
175
|
+
* morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
|
|
176
|
+
* fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
|
|
177
|
+
* toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
|
|
178
|
+
*/
|
|
179
|
+
declare function morph(from: Box, to: Box, targets: MorphTargets, opts: MorphOpts): MorphResult;
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/presence.d.ts
|
|
182
|
+
declare class PresenceError extends Error {
|
|
183
|
+
constructor(message: string);
|
|
184
|
+
}
|
|
185
|
+
interface PresenceOpts {
|
|
186
|
+
/** Wall-clock second the node ENTERS (becomes visible). */
|
|
187
|
+
show: number;
|
|
188
|
+
/** Wall-clock second the node has fully EXITED (the exit LANDS here). */
|
|
189
|
+
hide: number;
|
|
190
|
+
/** Entrance clip (default = a plain opacity fade-in 0→1, ~0.3s). */
|
|
191
|
+
enter?: Clip;
|
|
192
|
+
/** Exit clip (default = a plain opacity fade-out 1→0, ~0.3s). Back-timed to land on `hide`. */
|
|
193
|
+
exit?: Clip;
|
|
194
|
+
/** Forwarded to `enter.apply` (speed / per-channel overrides). */
|
|
195
|
+
enterOpts?: ApplyOpts;
|
|
196
|
+
/** Forwarded to `exit.apply` (speed / per-channel overrides). */
|
|
197
|
+
exitOpts?: ApplyOpts;
|
|
198
|
+
}
|
|
199
|
+
interface PresenceResult {
|
|
200
|
+
/** Reconciled tracks: the opacity guard (fused with enter/exit opacity keys) + any non-opacity channels. */
|
|
201
|
+
tracks: Track[];
|
|
202
|
+
/** The real exit second — siblings anchor here. Equals `hide`. */
|
|
203
|
+
end: number;
|
|
204
|
+
/** When the node became visible (= `show`). */
|
|
205
|
+
shownAt: number;
|
|
206
|
+
/** When the node finished exiting (= `hide`). */
|
|
207
|
+
hiddenAt: number;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
|
|
211
|
+
*
|
|
212
|
+
* presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
|
|
213
|
+
*/
|
|
214
|
+
declare function presence(nodeId: TweenTarget, opts: PresenceOpts): PresenceResult;
|
|
215
|
+
//#endregion
|
|
216
|
+
export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type Box, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, MorphError, type MorphOpts, type MorphResult, type MorphTargets, PresenceError, type PresenceOpts, type PresenceResult, type SlideEdge, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
|
package/dist/clips.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as resolveTweenTarget, p as track, s as key, x as inferValueType } from "./targetRef.js";
|
|
1
|
+
import { i as resolveTweenTarget, p as track, s as key, t as TARGET_PATH, x as inferValueType } from "./targetRef.js";
|
|
2
2
|
//#region src/clip.ts
|
|
3
3
|
/**
|
|
4
4
|
* Motion clips (DESIGN.md §2 build-time sugar): `clip()` captures a relative-time
|
|
@@ -66,11 +66,19 @@ function compileChannel(target, ch, startSec, speed, override) {
|
|
|
66
66
|
if (ch.keys.length === 0) throw new ClipError(`clip channel '${target}' has no keys`);
|
|
67
67
|
const type = ch.type ?? inferValueType(ch.keys[0].value);
|
|
68
68
|
const lastIdx = ch.keys.length - 1;
|
|
69
|
+
if (override && lastIdx === 0 && override.from !== void 0) throw new ClipError(override.to !== void 0 ? `clip override for '${target}' supplies both 'from' and 'to' on a single-key channel — ambiguous (both target the only key)` : `clip override for '${target}' supplies 'from' on a single-key channel — ambiguous (the only key already is the 'to' value)`);
|
|
69
70
|
return track(target, type, ch.keys.map((k, i) => {
|
|
70
71
|
let value = k.value;
|
|
72
|
+
let overridden = false;
|
|
71
73
|
if (override) {
|
|
72
|
-
if (i === 0 && override.from !== void 0)
|
|
73
|
-
|
|
74
|
+
if (i === 0 && override.from !== void 0) {
|
|
75
|
+
value = assertOverrideType(target, type, override.from);
|
|
76
|
+
overridden = true;
|
|
77
|
+
}
|
|
78
|
+
if (i === lastIdx && override.to !== void 0) {
|
|
79
|
+
value = assertOverrideType(target, type, override.to);
|
|
80
|
+
overridden = true;
|
|
81
|
+
}
|
|
74
82
|
}
|
|
75
83
|
const out = {
|
|
76
84
|
t: startSec + k.t / speed,
|
|
@@ -80,7 +88,8 @@ function compileChannel(target, ch, startSec, speed, override) {
|
|
|
80
88
|
if (ease !== void 0) out.ease = ease;
|
|
81
89
|
if (k.interp !== void 0) out.interp = k.interp;
|
|
82
90
|
if (k.id !== void 0) out.id = k.id;
|
|
83
|
-
if (k.derived !== void 0) out.derived = k.derived;
|
|
91
|
+
if (k.derived !== void 0 && !overridden) out.derived = k.derived;
|
|
92
|
+
if (k.from !== void 0) out.from = k.from;
|
|
84
93
|
return out;
|
|
85
94
|
}));
|
|
86
95
|
}
|
|
@@ -159,7 +168,7 @@ function clipList(c, targets, startSec, opts) {
|
|
|
159
168
|
* Loop clips (`pulse`, `driftLoop`) author first-key-value == last-key-value, so
|
|
160
169
|
* tiling them under `clipList` (or repeating the clip) reads seamlessly.
|
|
161
170
|
*/
|
|
162
|
-
/** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
|
|
171
|
+
/** Entrance: opacity 0→1 and scale [0.8,0.8]→[1,1] (a "pop" in). */
|
|
163
172
|
function popIn(opts) {
|
|
164
173
|
const d = opts?.duration ?? .3;
|
|
165
174
|
const ease = opts?.ease ?? "easeOutCubic";
|
|
@@ -170,7 +179,7 @@ function popIn(opts) {
|
|
|
170
179
|
},
|
|
171
180
|
scale: {
|
|
172
181
|
path: "scale",
|
|
173
|
-
keys: [key(0, .8), key(d, 1, ease)]
|
|
182
|
+
keys: [key(0, [.8, .8]), key(d, [1, 1], ease)]
|
|
174
183
|
}
|
|
175
184
|
} });
|
|
176
185
|
}
|
|
@@ -203,9 +212,9 @@ function pulse(opts) {
|
|
|
203
212
|
return clip({ channels: { scale: {
|
|
204
213
|
path: "scale",
|
|
205
214
|
keys: [
|
|
206
|
-
key(0, 1),
|
|
207
|
-
key(d / 2, peak, ease),
|
|
208
|
-
key(d, 1, ease)
|
|
215
|
+
key(0, [1, 1]),
|
|
216
|
+
key(d / 2, [peak, peak], ease),
|
|
217
|
+
key(d, [1, 1], ease)
|
|
209
218
|
]
|
|
210
219
|
} } });
|
|
211
220
|
}
|
|
@@ -227,4 +236,247 @@ function driftLoop(opts) {
|
|
|
227
236
|
} } });
|
|
228
237
|
}
|
|
229
238
|
//#endregion
|
|
230
|
-
|
|
239
|
+
//#region src/morph.ts
|
|
240
|
+
/**
|
|
241
|
+
* Shared-element box-FLIP morph (0.13 build-time sugar). `morph()` takes two
|
|
242
|
+
* caller-supplied `Box` literals (a FROM rect and a TO rect, both with the Rect
|
|
243
|
+
* CENTER convention for x,y) and a map of target nodes, and compiles a FLIP
|
|
244
|
+
* (First-Last-Invert-Play) position+scale tween on ONE shared element plus an
|
|
245
|
+
* optional opacity cross-fade between the from/to nodes.
|
|
246
|
+
*
|
|
247
|
+
* It is PURE CORE: the FLIP delta is plain arithmetic over the two boxes — no
|
|
248
|
+
* Yoga, no worldMatrix, no signal evaluation, no scene query (a scene-side
|
|
249
|
+
* `worldBoxOf(node)` convenience is a deferred 0.14 fast-follow). The emission
|
|
250
|
+
* delegates to the validated `clip()` path (one track-emission codepath), so the
|
|
251
|
+
* output is BYTE-INDISTINGUISHABLE from hand-authored `track(...)` — same
|
|
252
|
+
* determinism contract as `clip`/`stagger`/`springTo`.
|
|
253
|
+
*/
|
|
254
|
+
var MorphError = class extends Error {
|
|
255
|
+
constructor(message) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = "MorphError";
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
function assertFiniteBox(label, b) {
|
|
261
|
+
for (const [k, v] of [
|
|
262
|
+
["x", b.x],
|
|
263
|
+
["y", b.y],
|
|
264
|
+
["w", b.w],
|
|
265
|
+
["h", b.h]
|
|
266
|
+
]) if (!Number.isFinite(v)) throw new MorphError(`morph ${label}.${k} must be finite (got ${v})`);
|
|
267
|
+
if (!(b.w > 0) || !(b.h > 0)) throw new MorphError(`morph ${label} must have w>0 and h>0 (got w=${b.w}, h=${b.h}); a zero/negative box yields a NaN/Infinity scale`);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Resolve a node-level `TweenTarget` and append a prop suffix, producing the
|
|
271
|
+
* `'<nodeId>/<prop>'` string the clip map form resolves (which re-runs the
|
|
272
|
+
* structural/anonymous-id rejection via resolveTweenTarget). A string target is
|
|
273
|
+
* a bare node id; a property-signal carrier exposes its node via TARGET_PATH.
|
|
274
|
+
*
|
|
275
|
+
* The node id may itself carry slashes (an each() clone like 'card/3'); DO NOT
|
|
276
|
+
* re-split it on the FIRST slash — APPEND the prop and trust the caller. The
|
|
277
|
+
* scene's longest-registered-prefix resolver disambiguates node id vs prop path
|
|
278
|
+
* at bind time, so 'card/3' targets the clone, not the wrapping 'card' Group.
|
|
279
|
+
*/
|
|
280
|
+
function nodeTarget(target, prop) {
|
|
281
|
+
const id = typeof target === "string" ? target : target[TARGET_PATH];
|
|
282
|
+
if (typeof id !== "string" || id.length === 0) return `${String(id)}/${prop}`;
|
|
283
|
+
return `${typeof target === "string" ? id : id.slice(0, Math.max(0, id.lastIndexOf("/")))}/${prop}`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
|
|
287
|
+
*
|
|
288
|
+
* morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
|
|
289
|
+
* { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
|
|
290
|
+
* { at: 0.5, duration: 1.2 })
|
|
291
|
+
*
|
|
292
|
+
* Emits (via the clip path):
|
|
293
|
+
* morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
|
|
294
|
+
* morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
|
|
295
|
+
* fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
|
|
296
|
+
* toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
|
|
297
|
+
*/
|
|
298
|
+
function morph(from, to, targets, opts) {
|
|
299
|
+
const { at, duration } = opts;
|
|
300
|
+
const ease = opts.ease;
|
|
301
|
+
const crossfade = opts.crossfade ?? .5;
|
|
302
|
+
const metric = opts.metric ?? "transform";
|
|
303
|
+
if (metric !== "transform") throw new MorphError(`morph metric '${metric}' is not supported in v1 (only 'transform')`);
|
|
304
|
+
assertFiniteBox("from", from);
|
|
305
|
+
assertFiniteBox("to", to);
|
|
306
|
+
if (!(duration > 0)) throw new MorphError(`morph duration must be > 0 (got ${duration})`);
|
|
307
|
+
if (!(crossfade >= 0 && crossfade <= 1)) throw new MorphError(`morph crossfade must be in [0,1] (got ${crossfade})`);
|
|
308
|
+
const base = opts.base ?? {
|
|
309
|
+
w: to.w,
|
|
310
|
+
h: to.h
|
|
311
|
+
};
|
|
312
|
+
assertFiniteBox("to", {
|
|
313
|
+
x: 0,
|
|
314
|
+
y: 0,
|
|
315
|
+
w: base.w,
|
|
316
|
+
h: base.h
|
|
317
|
+
});
|
|
318
|
+
const fromScale = [from.w / base.w, from.h / base.h];
|
|
319
|
+
const toScale = [to.w / base.w, to.h / base.h];
|
|
320
|
+
const fromCenter = [from.x, from.y];
|
|
321
|
+
const toCenter = [to.x, to.y];
|
|
322
|
+
const channels = {
|
|
323
|
+
position: {
|
|
324
|
+
path: "position",
|
|
325
|
+
type: "vec2",
|
|
326
|
+
keys: [key(0, fromCenter), key(duration, toCenter, ease)]
|
|
327
|
+
},
|
|
328
|
+
scale: {
|
|
329
|
+
path: "scale",
|
|
330
|
+
type: "vec2",
|
|
331
|
+
keys: [key(0, fromScale), key(duration, toScale, ease)]
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const targetMap = {
|
|
335
|
+
position: nodeTarget(targets.morphNode, "position"),
|
|
336
|
+
scale: nodeTarget(targets.morphNode, "scale")
|
|
337
|
+
};
|
|
338
|
+
if (targets.fromNode !== void 0 && duration * crossfade > 0) {
|
|
339
|
+
channels["fromOpacity"] = {
|
|
340
|
+
path: "opacity",
|
|
341
|
+
type: "number",
|
|
342
|
+
keys: [key(0, 1), key(duration * crossfade, 0, ease)]
|
|
343
|
+
};
|
|
344
|
+
targetMap["fromOpacity"] = nodeTarget(targets.fromNode, "opacity");
|
|
345
|
+
}
|
|
346
|
+
if (targets.toNode !== void 0 && duration * (1 - crossfade) < duration) {
|
|
347
|
+
channels["toOpacity"] = {
|
|
348
|
+
path: "opacity",
|
|
349
|
+
type: "number",
|
|
350
|
+
keys: [key(duration * (1 - crossfade), 0), key(duration, 1, ease)]
|
|
351
|
+
};
|
|
352
|
+
targetMap["toOpacity"] = nodeTarget(targets.toNode, "opacity");
|
|
353
|
+
}
|
|
354
|
+
const { tracks } = clip({ channels }).apply(targetMap, at);
|
|
355
|
+
return {
|
|
356
|
+
tracks,
|
|
357
|
+
end: at + duration
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/presence.ts
|
|
362
|
+
/**
|
|
363
|
+
* Presence scheduling (the 0.13 enter/exit sugar): build-time sugar over `clip`
|
|
364
|
+
* that schedules a node's ENTER on `show`, its EXIT to land exactly on `hide`,
|
|
365
|
+
* and authors a real `<nodeId>/opacity` WINDOW GUARD so the node is culled
|
|
366
|
+
* (opacity<=0, see scene/node.ts §3) outside the live window.
|
|
367
|
+
*
|
|
368
|
+
* Like `clip`/`springTo`/`stagger`, this is authoring SUGAR — it compiles to
|
|
369
|
+
* ordinary keyed `Track[]` via `track()`, byte-INDISTINGUISHABLE from the
|
|
370
|
+
* hand-authored form. There is NO runtime visibility flag and no non-track
|
|
371
|
+
* document state: a presence is a pure function of its arguments. Back-timing
|
|
372
|
+
* the exit is pure arithmetic; reconciling the enter/exit opacity keys with the
|
|
373
|
+
* guard uses the builder's deterministic later-wins coincident-`t` dedup with a
|
|
374
|
+
* fixed merge order, so goldens stay byte-identical.
|
|
375
|
+
*
|
|
376
|
+
* The canonical case is a "send-line agency moment": a node enters, lives, and
|
|
377
|
+
* exits on a beat. Siblings anchor to the real exit via the returned `end`.
|
|
378
|
+
*/
|
|
379
|
+
var PresenceError = class extends Error {
|
|
380
|
+
constructor(message) {
|
|
381
|
+
super(message);
|
|
382
|
+
this.name = "PresenceError";
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
/** The default enter/exit: a plain opacity fade over `DEFAULT_FADE` seconds. */
|
|
386
|
+
const DEFAULT_FADE = .3;
|
|
387
|
+
/** Plain opacity fade-in 0→1 (the default enter). */
|
|
388
|
+
function defaultEnter() {
|
|
389
|
+
return clip({ channels: { opacity: {
|
|
390
|
+
path: "opacity",
|
|
391
|
+
keys: [key(0, 0), key(DEFAULT_FADE, 1)]
|
|
392
|
+
} } });
|
|
393
|
+
}
|
|
394
|
+
/** Plain opacity fade-out 1→0 (the default exit). */
|
|
395
|
+
function defaultExit() {
|
|
396
|
+
return clip({ channels: { opacity: {
|
|
397
|
+
path: "opacity",
|
|
398
|
+
keys: [key(0, 1), key(DEFAULT_FADE, 0)]
|
|
399
|
+
} } });
|
|
400
|
+
}
|
|
401
|
+
/** Effective duration of a clip after a speed multiplier (`apply` divides every t by speed). */
|
|
402
|
+
function effectiveDuration(c, opts) {
|
|
403
|
+
return c.duration / (opts?.speed ?? 1);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Sort keys by `t`, breaking ties by original index (a STABLE sort, so that at a
|
|
407
|
+
* coincident t the LATER-emitted key survives the later-wins dedup regardless of
|
|
408
|
+
* the engine's `Array.sort` stability). Pure: returns a new array.
|
|
409
|
+
*/
|
|
410
|
+
function stableSortByT(keys) {
|
|
411
|
+
return keys.map((k, i) => [k, i]).sort((a, b) => a[0].t - b[0].t || a[1] - b[1]).map(([k]) => k);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Pull the keys of the clip's OWN `<nodeId>/opacity` track out of an applied
|
|
415
|
+
* result (if it authored one). Non-opacity tracks pass straight through.
|
|
416
|
+
*/
|
|
417
|
+
function partitionOpacity(tracks, opacityTarget) {
|
|
418
|
+
const rest = [];
|
|
419
|
+
let opacityKeys = [];
|
|
420
|
+
for (const tr of tracks) if (tr.target === opacityTarget) opacityKeys = tr.keys;
|
|
421
|
+
else rest.push(tr);
|
|
422
|
+
return {
|
|
423
|
+
opacityKeys,
|
|
424
|
+
rest
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
|
|
429
|
+
*
|
|
430
|
+
* presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
|
|
431
|
+
*/
|
|
432
|
+
function presence(nodeId, opts) {
|
|
433
|
+
const { show, hide } = opts;
|
|
434
|
+
const opacityTarget = resolveTweenTarget(typeof nodeId === "string" ? `${nodeId}/opacity` : nodeId);
|
|
435
|
+
const lastSlash = opacityTarget.lastIndexOf("/");
|
|
436
|
+
const nodeIdStr = lastSlash < 0 ? opacityTarget : opacityTarget.slice(0, lastSlash);
|
|
437
|
+
const enter = opts.enter ?? defaultEnter();
|
|
438
|
+
const exit = opts.exit ?? defaultExit();
|
|
439
|
+
const enterRes = enter.apply(nodeIdStr, show, opts.enterOpts);
|
|
440
|
+
const enterEnd = show + effectiveDuration(enter, opts.enterOpts);
|
|
441
|
+
const exitDur = effectiveDuration(exit, opts.exitOpts);
|
|
442
|
+
const exitStart = hide - exitDur;
|
|
443
|
+
if (exitStart <= show) throw new PresenceError(`presence('${nodeIdStr}') exit needs ${exitDur.toFixed(3)}s but only ${(hide - show).toFixed(3)}s between show (${show}) and hide (${hide}) — widen the window or shorten/speed up the exit`);
|
|
444
|
+
const exitRes = exit.apply(nodeIdStr, exitStart, opts.exitOpts);
|
|
445
|
+
const { opacityKeys: enterOpacity, rest: enterRest } = partitionOpacity(enterRes.tracks, opacityTarget);
|
|
446
|
+
const { opacityKeys: exitOpacity, rest: exitRest } = partitionOpacity(exitRes.tracks, opacityTarget);
|
|
447
|
+
const bareGuard = [
|
|
448
|
+
key(0, 0, { interp: "hold" }),
|
|
449
|
+
key(enterEnd, 1, { interp: "hold" }),
|
|
450
|
+
key(hide, 0, { interp: "hold" })
|
|
451
|
+
];
|
|
452
|
+
const overlay = [];
|
|
453
|
+
if (enterOpacity.length > 0) enterOpacity.forEach((k, i) => {
|
|
454
|
+
if (i === 0 && k.value !== 0 && k.interp !== "hold") overlay.push({
|
|
455
|
+
...k,
|
|
456
|
+
interp: "hold"
|
|
457
|
+
});
|
|
458
|
+
else overlay.push(k);
|
|
459
|
+
});
|
|
460
|
+
else overlay.push(key(show, 0), key(enterEnd, 1));
|
|
461
|
+
if (exitOpacity.length > 0) for (const k of exitOpacity) overlay.push(k);
|
|
462
|
+
else overlay.push(key(exitStart, 1), key(hide, 0));
|
|
463
|
+
const sorted = stableSortByT([...bareGuard, ...overlay]);
|
|
464
|
+
const deduped = [];
|
|
465
|
+
for (const k of sorted) {
|
|
466
|
+
const last = deduped[deduped.length - 1];
|
|
467
|
+
if (last && last.t === k.t) deduped[deduped.length - 1] = k;
|
|
468
|
+
else deduped.push(k);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
tracks: [
|
|
472
|
+
track(opacityTarget, "number", deduped),
|
|
473
|
+
...enterRest,
|
|
474
|
+
...exitRest
|
|
475
|
+
],
|
|
476
|
+
end: hide,
|
|
477
|
+
shownAt: show,
|
|
478
|
+
hiddenAt: hide
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
export { ClipError, MorphError, PresenceError, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
|
package/dist/targetRef.js
CHANGED
|
@@ -602,6 +602,33 @@ const pathType = {
|
|
|
602
602
|
};
|
|
603
603
|
const lerpN = (a, b, t) => a + (b - a) * t;
|
|
604
604
|
const lerpPt = (a, b, t) => [lerpN(a[0], b[0], t), lerpN(a[1], b[1], t)];
|
|
605
|
+
/** Same hue at alpha 0 — a transparent stand-in so an appearing/disappearing
|
|
606
|
+
* `bg` (mesh baseline) fades through alpha instead of popping at the boundary.
|
|
607
|
+
* `parseColor` THROWS on a non-hex/non-canonical CSS color (hsl/named/oklch);
|
|
608
|
+
* since this runs inside `lerp` during evaluate() it must NEVER throw — fall
|
|
609
|
+
* back to holding the PRESENT color so the bg snaps in/out instead of crashing
|
|
610
|
+
* the whole frame. */
|
|
611
|
+
function transparentOf(color) {
|
|
612
|
+
try {
|
|
613
|
+
return formatColor({
|
|
614
|
+
...parseColor(color),
|
|
615
|
+
a: 0
|
|
616
|
+
});
|
|
617
|
+
} catch {
|
|
618
|
+
return color;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/** `lerpColor` that NEVER throws inside `lerp` (evaluate()): on an unparseable
|
|
622
|
+
* color (hsl/named/oklch — `parseColor` only knows hex/rgb) it SNAPS (holds `a`,
|
|
623
|
+
* then `b` at t≥1) instead of crashing the frame. Byte-identical to `lerpColor`
|
|
624
|
+
* for parseable colors, so it does not move any golden. */
|
|
625
|
+
function safeLerpColor(a, b, t) {
|
|
626
|
+
try {
|
|
627
|
+
return lerpColor(a, b, t);
|
|
628
|
+
} catch {
|
|
629
|
+
return t >= 1 ? b : a;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
605
632
|
/** Lift a solid color to a uniform gradient matching `shape` (every stop = color),
|
|
606
633
|
* so a color↔gradient tween fades smoothly instead of snapping. */
|
|
607
634
|
function liftColor(color, shape) {
|
|
@@ -625,10 +652,10 @@ function liftColor(color, shape) {
|
|
|
625
652
|
};
|
|
626
653
|
}
|
|
627
654
|
let warnedPaintShape = false;
|
|
628
|
-
function paintSnap(t, a, b) {
|
|
655
|
+
function paintSnap(t, a, b, message = "paint lerp across mismatched gradient shapes (different kind or stop count): snapping instead of interpolating — match the kind + stop count on both keyframes (§2.2)") {
|
|
629
656
|
if (!warnedPaintShape) {
|
|
630
657
|
warnedPaintShape = true;
|
|
631
|
-
emitDevWarning(
|
|
658
|
+
emitDevWarning(message);
|
|
632
659
|
}
|
|
633
660
|
return t >= 1 ? b : a;
|
|
634
661
|
}
|
|
@@ -649,17 +676,20 @@ const paintType = {
|
|
|
649
676
|
};
|
|
650
677
|
if (a.kind === "mesh" && b.kind === "mesh") {
|
|
651
678
|
if (a.points.length !== b.points.length) return paintSnap(t, a, b);
|
|
679
|
+
if (a.interpolation !== b.interpolation) return paintSnap(t, a, b, `paint lerp across mismatched mesh interpolation modes ('${a.interpolation ?? "smooth"}' → '${b.interpolation ?? "smooth"}'): snapping instead of interpolating — the blend kernel is discrete, so it switches once at the boundary; match \`interpolation\` on both keyframes to morph the points (§3 Paint)`);
|
|
680
|
+
const points = a.points.map((pa, i) => {
|
|
681
|
+
const pb = b.points[i];
|
|
682
|
+
return {
|
|
683
|
+
pos: lerpPt(pa.pos, pb.pos, t),
|
|
684
|
+
color: lerpColor(pa.color, pb.color, t)
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
const bg = a.bg !== void 0 || b.bg !== void 0 ? { bg: safeLerpColor(a.bg ?? transparentOf(b.bg), b.bg ?? transparentOf(a.bg), t) } : {};
|
|
652
688
|
return {
|
|
653
689
|
kind: "mesh",
|
|
654
|
-
points
|
|
655
|
-
const pb = b.points[i];
|
|
656
|
-
return {
|
|
657
|
-
pos: lerpPt(pa.pos, pb.pos, t),
|
|
658
|
-
color: lerpColor(pa.color, pb.color, t)
|
|
659
|
-
};
|
|
660
|
-
}),
|
|
690
|
+
points,
|
|
661
691
|
...a.interpolation ? { interpolation: a.interpolation } : {},
|
|
662
|
-
...
|
|
692
|
+
...bg
|
|
663
693
|
};
|
|
664
694
|
}
|
|
665
695
|
if (a.kind === "mesh" || b.kind === "mesh") return paintSnap(t, a, b);
|
package/package.json
CHANGED