@msb235/blits 0.12.1 → 0.14.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 +55 -0
- package/README.md +17 -0
- package/dist/channels.d.ts +5 -0
- package/dist/channels.js +2 -0
- package/dist/closed.d.ts +1 -0
- package/dist/closed.js +45 -1
- package/dist/crowd.d.ts +2 -0
- package/dist/crowd.js +9 -1
- package/dist/cue.d.ts +1 -1
- package/dist/cue.js +26 -2
- package/dist/fling.d.ts +66 -0
- package/dist/fling.js +162 -0
- package/dist/fold.js +14 -0
- package/dist/handle.d.ts +1 -0
- package/dist/handle.js +6 -1
- package/dist/held.d.ts +2 -0
- package/dist/held.js +21 -13
- package/dist/history.js +2 -0
- package/dist/mixer.d.ts +8 -0
- package/dist/mixer.js +15 -2
- package/dist/motion.d.ts +18 -1
- package/dist/motion.js +44 -5
- package/dist/motions.d.ts +8 -3
- package/dist/motions.js +7 -5
- package/dist/move.js +40 -10
- package/dist/origin.d.ts +3 -1
- package/dist/origin.js +16 -3
- package/dist/pack.d.ts +1 -0
- package/dist/pack.js +2 -0
- package/dist/project.js +1 -0
- package/dist/record.js +1 -0
- package/dist/rows.js +3 -1
- package/dist/seek.js +19 -3
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/stack.d.ts +2 -2
- package/dist/stack.js +4 -5
- package/dist/strays.js +1 -1
- package/dist/transport.d.ts +5 -0
- package/dist/transport.js +2 -0
- package/dist/types/mix.d.ts +11 -4
- package/dist/types/patch.d.ts +2 -0
- package/dist/types/voice.d.ts +12 -4
- package/dist/voice.d.ts +4 -0
- package/dist/voice.js +2 -0
- package/docs/schema.html +15 -8
- package/package.json +5 -1
package/dist/types/mix.d.ts
CHANGED
|
@@ -17,13 +17,18 @@ export interface MixOptions<H = unknown> {
|
|
|
17
17
|
/** Merged into every `setting.host`. Its type is the mix's `H`, which every patch and signal it cues reads. */
|
|
18
18
|
host?: H;
|
|
19
19
|
/**
|
|
20
|
-
* The band a rest-less channel's contribution switches on and off across.
|
|
21
|
-
*
|
|
20
|
+
* The band a rest-less channel's contribution switches on and off across. Default: on at any weight
|
|
21
|
+
* above 0, off at 0, so the contribution passes whole while its voice has any weight.
|
|
22
22
|
*/
|
|
23
23
|
band?: {
|
|
24
24
|
on: number;
|
|
25
25
|
off: number;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Tags that act as loci: a voice carrying one folds with the others carrying it, as if its `locus`
|
|
29
|
+
* named that tag.
|
|
30
|
+
*/
|
|
31
|
+
loci?: readonly string[];
|
|
27
32
|
/**
|
|
28
33
|
* The most `dt` a `step` or signal is handed, however long the gap. Off by default, since a
|
|
29
34
|
* patch with a closed form wants the whole gap; an integrator wants this set.
|
|
@@ -129,7 +134,7 @@ export interface Mix<I, O, H = unknown> {
|
|
|
129
134
|
* state keeps it once, not per member. One `spec` cues them all, and a member given as a
|
|
130
135
|
* `BlendMember` carries its own `name`, `tags` and `hits` in place of the spec's.
|
|
131
136
|
*/
|
|
132
|
-
blend(members: readonly (Patch<I, O,
|
|
137
|
+
blend(members: readonly (Patch<I, O, any, H> | BlendMember<I, O, H>)[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight' | 'locus'>): Handle<I>[];
|
|
133
138
|
/**
|
|
134
139
|
* Cues an owner: a voice with no patch that holds the voices cued with `owner` set to its handle,
|
|
135
140
|
* and plays them on its own clock, so they can be placed, timed and faded as one. Its handle acts
|
|
@@ -303,7 +308,9 @@ export interface Mix<I, O, H = unknown> {
|
|
|
303
308
|
* Another frame would change no pose, so a host's frame loop may sleep: every voice is done, frozen
|
|
304
309
|
* at a plain weight, or a motion whose every subject has landed on its target. At rate 0 it is
|
|
305
310
|
* enough that every voice not done has started, at a plain weight, with no anchor. A change made
|
|
306
|
-
* since the last sync, a retarget or a rate included, makes it false until the next.
|
|
311
|
+
* since the last sync, a retarget or a rate included, makes it false until the next. Read as true,
|
|
312
|
+
* it tells the mix the host may sleep: a voice cued with no `start` before the next sync starts
|
|
313
|
+
* at that sync.
|
|
307
314
|
*/
|
|
308
315
|
readonly inert: boolean;
|
|
309
316
|
/**
|
package/dist/types/patch.d.ts
CHANGED
|
@@ -110,6 +110,8 @@ export type MotionSpec = {
|
|
|
110
110
|
/** The friction's time constant, ms. */
|
|
111
111
|
readonly ms: number;
|
|
112
112
|
readonly settle: number;
|
|
113
|
+
/** The spring back's, per second squared; only on a glide with bounds. */
|
|
114
|
+
readonly stiffness?: number;
|
|
113
115
|
} | {
|
|
114
116
|
readonly kind: 'tween';
|
|
115
117
|
/** How long a stretch takes; undefined where it varies by subject, kept on the patch then. */
|
package/dist/types/voice.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface SpanHints {
|
|
|
47
47
|
* @category voice
|
|
48
48
|
*/
|
|
49
49
|
export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
|
|
50
|
-
patch: Patch<I, O,
|
|
50
|
+
patch: Patch<I, O, any, H>;
|
|
51
51
|
/**
|
|
52
52
|
* What `history.revive` rebuilds this spec from, so a history `store` can page the voice out once
|
|
53
53
|
* it has left. A voice cued without it stays in memory for as long as history reaches it.
|
|
@@ -72,7 +72,9 @@ export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
|
|
|
72
72
|
* When the voice starts, in ms on the host's clock, the one it passes `sync`: a rAF timestamp and
|
|
73
73
|
* `performance.now()` share an origin, `Date.now()` does not. Default: the time the mix last
|
|
74
74
|
* synced to, and 0 for a voice cued before the first sync, which that sync finds already as far
|
|
75
|
-
* in as its timestamp.
|
|
75
|
+
* in as its timestamp. Where the host read `inert` as true since the last sync, its frame loop
|
|
76
|
+
* may be asleep, and the default is the next sync instead, so a voice cued after a long pause
|
|
77
|
+
* starts there rather than that far in.
|
|
76
78
|
*/
|
|
77
79
|
start?: number;
|
|
78
80
|
/**
|
|
@@ -175,7 +177,7 @@ export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
|
|
|
175
177
|
* @category blending
|
|
176
178
|
*/
|
|
177
179
|
export interface BlendMember<I, O, H = unknown> {
|
|
178
|
-
patch: Patch<I, O,
|
|
180
|
+
patch: Patch<I, O, any, H>;
|
|
179
181
|
/** What other voices call this member by. Default: the spec's `name`. */
|
|
180
182
|
name?: string;
|
|
181
183
|
/** Default: the spec's `tags`. */
|
|
@@ -384,6 +386,11 @@ export interface Handle<I = unknown> {
|
|
|
384
386
|
* `glide`: the patch's own `push`. Throws for any other voice.
|
|
385
387
|
*/
|
|
386
388
|
push(subject: I, velocity: Value, at?: number): void;
|
|
389
|
+
/**
|
|
390
|
+
* Puts `subject` at `value`, keeping its velocity, for a voice playing any motion patch: the
|
|
391
|
+
* patch's own `set`. Throws for any other voice.
|
|
392
|
+
*/
|
|
393
|
+
set(subject: I, value: Value, at?: number): void;
|
|
387
394
|
/**
|
|
388
395
|
* Where `subject` is and how fast it moves, for a voice playing a motion patch: the patch's own
|
|
389
396
|
* `read`. Undefined for any other voice, and wherever the patch's `read` is.
|
|
@@ -399,7 +406,8 @@ export interface Handle<I = unknown> {
|
|
|
399
406
|
}
|
|
400
407
|
/**
|
|
401
408
|
* What `Handle.seek` does with each subject's state: `rebuild` (the default) makes it fresh and
|
|
402
|
-
* steps it to the new position; `keep` leaves it as it was
|
|
409
|
+
* steps it to the new position; `keep` leaves it as it was, except for a subject the seek sends back
|
|
410
|
+
* before its start, which starts afresh there.
|
|
403
411
|
*
|
|
404
412
|
* @category voice
|
|
405
413
|
*/
|
package/dist/voice.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface Subject<S> {
|
|
|
86
86
|
weight: number;
|
|
87
87
|
/** Left at rest during a handover, so it stops contributing. */
|
|
88
88
|
rested: boolean;
|
|
89
|
+
/** The mix time it rested at, which a seek reads to know whether it had by then; NaN until it does. */
|
|
90
|
+
restedAt: number;
|
|
89
91
|
/**
|
|
90
92
|
* Per written channel, whether a rest-less contribution is on: 0 unknown, 1 on, 2 off; null until
|
|
91
93
|
* a rest-less channel asks.
|
|
@@ -333,6 +335,8 @@ export declare class Voice<I, O> {
|
|
|
333
335
|
outSet: boolean;
|
|
334
336
|
/** The host time a pinned start was given at, NaN for none: a seek back pins it again. */
|
|
335
337
|
pinned: number;
|
|
338
|
+
/** Cued with no start while its mix slept, so the next sync sets its start. */
|
|
339
|
+
waking: boolean;
|
|
336
340
|
/** Whether its start is still to be fixed by an anchor, so it waits pending. */
|
|
337
341
|
placing: boolean;
|
|
338
342
|
/** What each member of a join last answered, kept for once its target has left. */
|
package/dist/voice.js
CHANGED
|
@@ -226,6 +226,8 @@ export class Voice {
|
|
|
226
226
|
outSet = false;
|
|
227
227
|
/** The host time a pinned start was given at, NaN for none: a seek back pins it again. */
|
|
228
228
|
pinned = Number.NaN;
|
|
229
|
+
/** Cued with no start while its mix slept, so the next sync sets its start. */
|
|
230
|
+
waking = false;
|
|
229
231
|
/** Whether its start is still to be fixed by an anchor, so it waits pending. */
|
|
230
232
|
placing = false;
|
|
231
233
|
/** What each member of a join last answered, kept for once its target has left. */
|
package/docs/schema.html
CHANGED
|
@@ -295,7 +295,7 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
295
295
|
|
|
296
296
|
<p><b>Color is a value; how it merges is a parameter.</b> A <code>color</code> value is OKLab <code>[L, a, b, coverage]</code>, and <code>oklab(0xrrggbb)</code> makes one at full coverage. <code>color()</code> averages: voices stack as a premultiplied sum, so the pose is <code>[Σw·L, Σw·a, Σw·b, Σw]</code>, which lanes and <code>pull</code> fold as any <code>vec(4, sum())</code>. blits knows no subject's own color, so the host lays the result over its base at write: <code>toHex(pose, under)</code> divides the coverage out, caps it at 1, mixes over <code>under</code> in OKLab and clips to sRGB per channel, and <code>css(pose)</code> writes an <code>oklab()</code> with the coverage as alpha. <code>color(last())</code> replaces, the last voice to pass winning behind the band. <code>mul</code> and <code>max</code> per OKLab axis mean nothing anyone asks for, so <code>color</code> refuses them; a tint, a hue shift, chroma and lightness are channels of their own. OKLab is the store because voices average per axis there, and OKLCH's hue wraps. How two colors interpolate is the channel's choice, <code>{ lerp: 'oklab' | 'oklch' }</code>: OKLab, the default, crosses opposite hues through a pale gray, and OKLCH goes round the hue as <code>mixHex</code> does, at the cost of leaving the lanes.</p>
|
|
297
297
|
|
|
298
|
-
<p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the contribution is dropped, above it the value passes through whole. That is the rule hinge's blend mode already applies to color, and it is stated here once so no consumer rediscovers it. <b>The threshold is a band, not an edge</b> — it drops at <code>off</code> and passes at <code>on</code>, and between the two it holds whatever it last did. One edge chatters: a weight resting on it is carried back and forth by frame noise, and every crossing drops or restores the whole contribution. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity.</p>
|
|
298
|
+
<p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the contribution is dropped, above it the value passes through whole. That is the rule hinge's blend mode already applies to color, and it is stated here once so no consumer rediscovers it. <b>The threshold is a band, not an edge</b> — it drops at <code>off</code> and passes at <code>on</code>, and between the two it holds whatever it last did. One edge chatters: a weight resting on it is carried back and forth by frame noise, and every crossing drops or restores the whole contribution. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity. <b>The stock band is no band</b>: on at any weight above 0 and off at 0, so a fade in shows the value at once and a fade out holds it to its end. The band was meant to be sized from frame noise in klieg's <code>near</code> and <code>dwell</code>, but klieg, its one measured user, set its mix to exactly this to match <code>hinge</code>, so it is the default (2026-10-23); a host whose weight rests near a threshold passes a band of its own.</p>
|
|
299
299
|
|
|
300
300
|
<p>The stock set is not the limit: a consumer that needs arithmetic it lacks writes its own <code>Channel</code>, and nothing in the mix knows the difference. sherpa's <code>transform</code> is one, composed by the host from whatever it puts in a matrix. A channel whose values are objects can add <code>fold</code>, which writes each contribution into the pose's own value where <code>merge</code> and <code>scale</code> would each make a new one. The mix starts that value as a copy of the channel's <code>rest</code>, and copies an array, a typed array, or a plain object itself; where <code>rest</code> is anything else, such as a class instance, the channel gives <code>copy</code>, or <code>cue</code> refuses a patch that writes it.</p>
|
|
301
301
|
|
|
@@ -512,12 +512,13 @@ interface Handle<I = unknown> {
|
|
|
512
512
|
<span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
|
|
513
513
|
locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
|
|
514
514
|
weightOf(subject: I): number;
|
|
515
|
-
<span class="cm">/** A motion patch's own `to`, `push`, and `read`, reached without holding the patch; `at` is a voice time. `to`
|
|
516
|
-
throws for a voice not playing a `spring` or a `tween`,
|
|
517
|
-
`read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
|
|
515
|
+
<span class="cm">/** A motion patch's own `to`, `push`, `set`, and `read`, reached without holding the patch; `at` is a voice time. `to`
|
|
516
|
+
throws for a voice not playing a `spring` or a `tween`, `push` for one not playing a `spring` or a `glide`, and
|
|
517
|
+
`set` for one playing no motion patch; `read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
|
|
518
518
|
`Motion` is `{ value, velocity }`. See Springs, glides and tweens. */</span>
|
|
519
519
|
to(subject: I, target: Value, at?: number): void;
|
|
520
520
|
push(subject: I, velocity: Value, at?: number): void;
|
|
521
|
+
set(subject: I, value: Value, at?: number): void;
|
|
521
522
|
read(subject: I, at?: number): Motion<Value> | undefined;
|
|
522
523
|
<span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
|
|
523
524
|
readonly done: Promise<void>;
|
|
@@ -554,7 +555,7 @@ interface MixOptions<H = unknown> {
|
|
|
554
555
|
reduce?: boolean | (() => boolean);
|
|
555
556
|
<span class="cm">/** Merged into every `setting.host`. `H` types it for every patch and signal the mix cues, so none needs a cast. */</span>
|
|
556
557
|
host?: H;
|
|
557
|
-
<span class="cm">/** The band a rest-less channel's contribution switches across. Default on
|
|
558
|
+
<span class="cm">/** The band a rest-less channel's contribution switches across. Default on above 0 and off at 0: no band. */</span>
|
|
558
559
|
band?: { on: number; off: number };
|
|
559
560
|
<span class="cm">/** The most `dt` a step or signal is handed, however long the gap. Off by default. */</span>
|
|
560
561
|
maxDt?: number;
|
|
@@ -741,6 +742,7 @@ mix.book({
|
|
|
741
742
|
<tr><td>Retarget on interruption</td><td><code>from: 'current'</code> on a <code>keys</code> voice takes stop 0 from the subject's pose in the frame before it first contributes — every other voice folded in, itself excluded — and lerps from there through the channel's <code>lerp</code>. It also leaves at the velocity the subject had, taken from its last two probed poses: the first segment gains <code>c·u(1−u)²</code>, which is zero at both ends and flat at the end, so the segment's own curve still lands on its stop while the start matches the subject's speed. A subject that was still leaves at rest. With fewer than two poses on record, as on a mix whose host probes into its own object before any retarget is cued, it starts on the segment's own curve.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
|
|
742
743
|
</table></div>
|
|
743
744
|
|
|
745
|
+
<p><b>A tag can be a locus.</b> A mix made with <code>loci: ['phase']</code> folds every voice carrying the tag <code>phase</code> as one locus, as if each had <code>locus: 'phase'</code>, so a source that already tags its voices need not name a locus too. A voice whose tags and <code>locus</code> name two loci is refused. Decided 2026-09-29 that tags absorb loci; the declaration was put on the mix (2026-10-23), and <code>locus</code> stays, since klieg, wod, magicsmoke and astv cue with it.</p>
|
|
744
746
|
<p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. Each channel of that contribution then contributes at <code>min(1, Σw)</code> over the members that wrote it, and is scaled toward rest like any other: a channel only one member writes fades with that member, rather than holding at the whole locus’s weight while it leaves. On a <code>sum</code> channel this is exactly <code>w₁a + w₂b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 — which is what one alternative means — where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. A stock <code>last()</code> channel has nothing to interpolate, so the locus takes its heaviest member’s value, and the later cued of two as heavy. Cue order is otherwise visible only to a <code>lerp</code> that is not linear: a <code>color({ lerp: 'oklch' })</code> or a <code>hex</code> takes the hue the short way round at each step, so three or more of them in a locus can read differently by the order they were cued in.</p>
|
|
745
747
|
|
|
746
748
|
<div class="callout">
|
|
@@ -754,9 +756,14 @@ mix.book({
|
|
|
754
756
|
mix.cue({ patch: s });
|
|
755
757
|
s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
|
|
756
758
|
s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
|
|
759
|
+
s.set(node, 20); <span class="cm">// moved there: same target, same velocity</span>
|
|
757
760
|
s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
|
|
758
761
|
|
|
759
762
|
const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span>
|
|
763
|
+
g.set(node, 80); <span class="cm">// under a dragging pointer</span>
|
|
764
|
+
g.push(node, -900); <span class="cm">// let go: coasts on from there</span>
|
|
765
|
+
|
|
766
|
+
const list = glide<Node, Pose>('y', { from: 0, bounds: [-2400, 0] }); <span class="cm">// springs back from an edge it coasts past</span>
|
|
760
767
|
|
|
761
768
|
const t = tween<Node, Pose>('x', { from: (n) => n.x, to: (n) => n.goal, ms: 400, ease: 'ease-out' });
|
|
762
769
|
t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
|
|
@@ -764,6 +771,8 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
|
|
|
764
771
|
<ul>
|
|
765
772
|
<li><b>Time is the voice's</b>, so <code>rate</code> and <code>seek</code> apply. A change takes effect at the voice time it names, or, named none, at the subject's own voice time at the mix's latest frame, whether or not the host probed it then; naming one is what makes a retarget land the same at any frame rate. A subject no frame of its voice has met yet, or still inside its stagger, takes an unnamed change at its first read, after any named change due by then. <code>read</code> with no time answers at that same latest frame, counting every change due by then, and answers nothing once the voice is gone.</li>
|
|
766
773
|
<li><b>History is kept to the mix's horizon.</b> Each subject keeps every stretch a read may still reach, so a read earlier than a change finds the stretch that was playing then. A mix without <code>history</code> keeps only the stretch playing now.</li>
|
|
774
|
+
<li id="fling"><b>Dragging and flinging.</b> <code>@msb235/blits/fling</code> wires pointer events to a motion patch: <code>fling(element, glide, subject, { axis, scale })</code> stops the subject where it is on a press, <code>set</code>s it under the pointer on each move, and <code>push</code>es it on release at the pointer’s velocity, which <code>track</code> reads as the least-squares slope of the last 100 ms of samples, or zero if the pointer sat still for 40 ms before letting go. <code>track</code> has no listeners, so anything with a time and a position can feed it. It is a subpath because it is the package’s only code that knows a page exists, and it asks only for <code>addEventListener</code>, <code>removeEventListener</code> and <code>setPointerCapture</code>, so the package builds without the DOM’s types. Since the coast is a voice like any other, other voices read it: a signal over <code>glide.read(subject)</code> drives parallax off its value or a blur off its velocity. Past an edge of a glide's <code>bounds</code>, a drag carries the subject less and less of the way, never more than <code>give</code> (default 120 units) past: at the edge it follows 0.55 of the drag, as iOS's scroll views do, and <code>give: 0</code> holds it at the edge. A press on a subject springing back past an edge holds it where it is. Added 2026-10-24.</li>
|
|
775
|
+
<li id="bounds"><b>Bounds.</b> <code>glide(…, { bounds: [min, max], stiffness })</code> gives each axis the least and greatest value a coast comes to rest at, the same for every subject or one pair per subject; <code>g.bounds(subject)</code> reads them back. A coast that would stop inside them is the glide without bounds, to the bit. One that would stop past an edge coasts until it crosses it, then springs back on a critically damped spring of <code>stiffness</code> (default 170, a spring’s), starting at the speed it crossed at, so neither where it is nor how fast it moves jumps. One let go already past an edge springs back from there, unless its coast carries it back inside, when it coasts with no spring at all. A <code>set</code> past an edge leaves the subject there, coasting on, until the next <code>push</code>: that is how a drag holds it out, and why a <code>push(subject, 0)</code> lets it go. It needed no new state: a stretch still holds where it starts, how fast, and where it heads, which is now the edge when the coast stops past one, and the closed form tells the three cases apart from those numbers alone, so lanes, seeks and history carry it as they carry any glide. Added 2026-10-24.</li>
|
|
767
776
|
<li><b>A spring or a glide settles.</b> Within <code>settle</code> (1e-4) of where it heads, and that slow, it lands there exactly, so <code>atRest</code> sees it. <code>settle: 0</code> keeps the curve exact. A tween needs none: it lands on its target exactly at <code>ms</code>.</li>
|
|
768
777
|
<li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch, or through <code>handle.to</code>, <code>push</code>, and <code>read</code>, which reach it. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was. Its values are the kind its channel holds, a number for a number and an array for an array: one every subject shares is refused at <code>cue</code>, one given per subject on that subject's first sample.</li>
|
|
769
778
|
<li><b>The constants are data.</b> <code>patch.motion</code>, a <code>MotionSpec</code>, is <code>{ kind: 'spring', stiffness, damping, mass, settle }</code> or <code>{ kind: 'glide', ms, settle }</code> or <code>{ kind: 'tween', ms, ease }</code>, with <code>ms</code> undefined where it varies by subject, so an engine can run one without calling <code>at</code>. Where each subject starts and heads stays on the patch, since either may be a function of the subject.</li>
|
|
@@ -901,7 +910,7 @@ for (const step of steps) mix.arc({ owner: program, active: stage(step), hold: 4
|
|
|
901
910
|
</ul>
|
|
902
911
|
Decided 2026-10-06 and 2026-10-07, reversing the 2026-10-01 decision that mix time is only ever a reading. The mix goes back by itself rather than through snapshots the host saves and loads, since a host-kept log of its own calls would be a second record of what the mix did. What happened after <code>t</code> plays again rather than vanishing, because scrubbing must never cut off a recorded future. A call made while rewound branches rather than dropping the old future, and the mix drops its own copies of state after the fork rather than keeping them per branch, so memory stays inside the horizon. History lives only for the session. The mix's own logs hold what happened up to the playhead and the tape holds the calls after it, so nothing is recorded twice and the logs never branch. A recorded call's <code>invert</code> does nothing, since the mix restores rather than undoing; so the mix moves its tape back only with <code>goto</code>, forward one <code>redo</code> at a time, and opens no journals.</li>
|
|
903
912
|
<li><b>How sure.</b> <code>assess</code> says per channel <code>held</code> where a voice feeding it reads input from outside the clock with no recording of it (a signal marked <code>input</code>, a patch that <code>reads</code> host fields, or a <code>from: 'current'</code> start read back with nothing kept), <code>stepped</code> where state crossed a gap in one go without <code>stepMs</code>, or a read back, or a seek back the record goes on from, stepped a stateful signal on from a copy, and <code>exact</code> otherwise; a read ahead reads a stateful signal at every known event, which makes it <code>exact</code>. The least sure voice decides. Under <code>stepMs</code> a read back lands on the pose the mix showed, so a scrub back and forth gives the same picture. A control change the host makes between a sync and the frame's first probe or pull showed in that frame, so a read back shows it there; one made after shows from the next frame.</li>
|
|
904
|
-
<li><b>A voice's own seek.</b> <code>handle.seek(elapsed)</code> moves one voice's clock, which leaves the state its patch steps and its signals keep belonging to the old position. By default each subject's state is rebuilt: made fresh and stepped from the voice's start to the new position at its next probe, on the <code>stepMs</code> grid where there is one, so under <code>stepMs</code> it lands where a voice cued fresh and played there would. <code>state: 'keep'</code> leaves it as it was. The seek answers how sure the state now is: <code>exact</code> for a voice with no state, or rebuilt under <code>stepMs</code> with no <code>maxDt</code>; <code>stepped</code> where it was caught up in one step, or a signal's kept state was started over; <code>held</code> where it was kept, or depends on host fields or an input weight a rebuild cannot know. A rebuild does not read <code>history</code> yet: starting from a copy kept near the new position would save the stepping, and could make state fed by recorded input exact. Decided and built 2026-10-08.</li>
|
|
913
|
+
<li><b>A voice's own seek.</b> <code>handle.seek(elapsed)</code> moves one voice's clock, which leaves the state its patch steps and its signals keep belonging to the old position. By default each subject's state is rebuilt: made fresh and stepped from the voice's start to the new position at its next probe, on the <code>stepMs</code> grid where there is one, so under <code>stepMs</code> it lands where a voice cued fresh and played there would. <code>state: 'keep'</code> leaves it as it was, except for a subject sent back before its start, which starts afresh there, since it has run no steps yet. The seek answers how sure the state now is: <code>exact</code> for a voice with no state, or rebuilt under <code>stepMs</code> with no <code>maxDt</code>; <code>stepped</code> where it was caught up in one step, or a signal's kept state was started over; <code>held</code> where it was kept, or depends on host fields or an input weight a rebuild cannot know. A rebuild does not read <code>history</code> yet: starting from a copy kept near the new position would save the stepping, and could make state fed by recorded input exact. Decided and built 2026-10-08.</li>
|
|
905
914
|
<li><b>Cost.</b> A projection copies each subject's record on first probe: about 3 ms for 1000 subjects under three voices, three times a live frame (<code>npm run bench</code>, rows <code>ahead</code> and <code>back</code>). That is cheap enough to make one per frame for a continuous scrub. History costs one control entry per handle change and one copy per stateful subject per <code>every</code>, all dropped past the horizon. A seek back puts each stateful record back from the last copy at or before the moment sought, at the subject's next probe, and steps it on from there. A seek lets go of the copies made after the moment it sought, so a mix sought back before every frame would step each subject from the same copy, further each time. The copy a record was put back from therefore keeps its state stepped ahead to near the moment sought, a state per copy a seek has used, moved on once that moment is <code>every</code> past it, and the next record put back from the copy steps on from that state where the steps due run past it. It does so only where the same steps give the same state: under <code>stepMs</code>, with no <code>maxDt</code>, for a patch that <code>reads</code> no host field.</li>
|
|
906
915
|
</ul>
|
|
907
916
|
|
|
@@ -1059,13 +1068,11 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
1059
1068
|
<h2 id="open">Open</h2>
|
|
1060
1069
|
<ul>
|
|
1061
1070
|
<li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line — an orientation that wraps, where 350° and 10° must meet at 0°, is <code>angle</code>, whose <code>lerp</code> goes the short way round; <code>color</code> as <code>color()</code>, OKLab with coverage. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
|
|
1062
|
-
<li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>tags absorb loci</b>: a tag declared to fold its voices as alternatives does what <code>locus</code> does now; where the declaration lives is not decided.</li>
|
|
1063
1071
|
<li><b>What spans do not do yet.</b> Hold back part of a budget for children the host knows are coming, short of a strategy of its own; skip a span held by a span; send the events a skipped child would have sent on the way. Found while building them, 2026-10-07.</li>
|
|
1064
1072
|
<li><b>The package name.</b> Published as <code>@msb235/blits</code> for now, decided 2026-09-30, while npm answers a request for the bare <code>blits</code>, which the registry 404s. Moving to <code>blits</code> later changes every import. <code>@lightningjs/blits</code> is a live TV app framework with its own animation plugin, so searches for the name land there first.</li>
|
|
1065
1073
|
<li><b>Whether <code>at</code> writes into a buffer: no, it returns.</b> A patch allocates one partial delta per subject per frame, and <code>writes</code> plus the fold order would let the mix hand it one scratch object instead. The port measured what that would buy: a frame of klieg’s motion — 12 letters through four voices — costs 0.017 ms, and a frame of its effects — 60 parts under three effects — costs 0.134 ms, against a 16.7 ms budget. Under 1% of a frame is not worth the nicest property <code>at</code> has, which is being a pure function a test calls once. The p99 of the effects frame is 1.3 ms, which is the collector catching up on exactly these allocations, so a subject count an order of magnitude past klieg’s would reopen it. <code>packages/core/test/bench/frame-cost.bench.ts</code> in klieg is the measurement</li>
|
|
1066
1074
|
<li><b>A subject brought back, read back to while it was out.</b> Under <code>history</code> a voice keeps the record a subject left with, faded out or dropped, and a motion patch keeps its stretches, so a <code>seek</code> or <code>project</code> back to before it left finds both (built 2026-10-07). Untested: a read back to a moment between the subject leaving and a motion's <code>to</code> bringing it back, which may read it as never having left. Found 2026-10-02 while building subject fades.</li>
|
|
1067
1075
|
<li><b>Layered subjects.</b> A lamp that is part of a cell and also reached on its own, as a sparkle on one bulb of a chase, is its own subject and folds every voice of the cell again. Starting its fold from the cell's pose and adding only the voices that reach the lamp would save that, and only the mix knows which voices reach what. Unbuilt until a host needs it; the saving matters at thousands of subjects (<code>named N=10000 V=1</code> is 1.592 ms a frame on teitou, 2026-10-21). Raised 2026-10-22.</li>
|
|
1068
|
-
<li><b>The stock band's width.</b> Whether <code>gate</code>'s default band is symmetric around <code>at</code> and how wide it is. It has to clear the frame noise in a real signal, so it is a measurement from klieg's <code>near</code> and <code>dwell</code>, not a guess.</li>
|
|
1069
1076
|
</ul>
|
|
1070
1077
|
</main>
|
|
1071
1078
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msb235/blits",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animation",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"./lean": {
|
|
35
35
|
"types": "./dist/lean.d.ts",
|
|
36
36
|
"default": "./dist/lean.js"
|
|
37
|
+
},
|
|
38
|
+
"./fling": {
|
|
39
|
+
"types": "./dist/fling.d.ts",
|
|
40
|
+
"default": "./dist/fling.js"
|
|
37
41
|
}
|
|
38
42
|
},
|
|
39
43
|
"files": [
|