@msb235/blits 0.2.0 → 0.3.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 +96 -0
- package/README.md +9 -2
- package/dist/channels.d.ts +41 -7
- package/dist/channels.js +184 -29
- package/dist/clock.d.ts +19 -0
- package/dist/clock.js +44 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/lanes.d.ts +207 -0
- package/dist/lanes.js +928 -0
- package/dist/mixer.d.ts +185 -2
- package/dist/mixer.js +1264 -139
- package/dist/motion.d.ts +169 -9
- package/dist/motion.js +564 -112
- package/dist/numbers.d.ts +22 -0
- package/dist/numbers.js +64 -0
- package/dist/patch.d.ts +13 -2
- package/dist/patch.js +44 -18
- package/dist/reading.d.ts +23 -0
- package/dist/reading.js +23 -0
- package/dist/types.d.ts +242 -11
- package/docs/schema.html +119 -24
- package/package.json +3 -6
package/docs/schema.html
CHANGED
|
@@ -141,6 +141,7 @@
|
|
|
141
141
|
<li class="grp">Behavior</li>
|
|
142
142
|
<li><a href="#time">Time</a></li>
|
|
143
143
|
<li><a href="#blend">Blending</a></li>
|
|
144
|
+
<li><a href="#score">Score</a></li>
|
|
144
145
|
<li><a href="#state">Patch state</a></li>
|
|
145
146
|
<li><a href="#engines">Engines</a></li>
|
|
146
147
|
<li class="grp">Consumers</li>
|
|
@@ -159,7 +160,7 @@
|
|
|
159
160
|
<h1>An effects schema, and the API around it</h1>
|
|
160
161
|
<p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
|
|
161
162
|
<p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
|
|
162
|
-
<span class="status">Built · v0.
|
|
163
|
+
<span class="status">Built · v0.3.0 · design 2026-09-15, package 2026-09-28</span>
|
|
163
164
|
</header>
|
|
164
165
|
|
|
165
166
|
<h2 id="picture">In one picture</h2>
|
|
@@ -238,7 +239,12 @@
|
|
|
238
239
|
<dt>Setting</dt><dd>What a patch may read and did not compute, for one subject this frame: the clock and <code>dt</code> the whole frame shares, the <code>elapsed</code>, <code>pass</code> and <code>weight</code> particular to the voice, its state, and whatever the host added.</dd>
|
|
239
240
|
<dt>System</dt><dd>One complete area of driven behavior: a kit, the subject type it drives, and the mix over them. klieg's motion, effects and lighting are three systems in one host. Two systems never share a mix, so a voice belongs to exactly one.</dd>
|
|
240
241
|
<dt>Source</dt><dd>Whatever decided a voice should exist: a score, a handler on an event, a chain handing over. There is no source type and nothing to implement — a source calls <code>cue</code> and gets a handle. The word is here so the vocabulary can name the seam it does not own.</dd>
|
|
241
|
-
<dt>Score</dt><dd>The source that holds a host's whole plan: what plays, over what, and when. It supplies the two things blits takes as given, the timing and the cast of subjects. klieg's is spread across handlers and a <code>Timeline</code>; another host's may be a document or a route table.
|
|
242
|
+
<dt>Score</dt><dd>The source that holds a host's whole plan: what plays, over what, and when. It supplies the two things blits takes as given, the timing and the cast of subjects. klieg's is spread across handlers and a <code>Timeline</code>; another host's may be a document or a route table. Inside blits it is a plan of voices placed on the mix clock, which knows when each plays and never what it contributes; see Score.</dd>
|
|
243
|
+
</dl>
|
|
244
|
+
|
|
245
|
+
<p>Informal terms are used in design work and in the code but appear nowhere in the public API, so a consumer never has to learn them.</p>
|
|
246
|
+
<dl>
|
|
247
|
+
<dt>Lane</dt><dd>A channel the mix computes for every subject at once, in flat arrays indexed by a number it gives each subject, rather than subject by subject. A channel runs as a lane only while every voice writing it qualifies; otherwise it takes the general path, which folds subject by subject as the mix always has. Lanes are on by default and give the same pose; <code>MixOptions.lanes: false</code> turns them off.</dd>
|
|
242
248
|
</dl>
|
|
243
249
|
|
|
244
250
|
<h2 id="channels">Channels</h2>
|
|
@@ -248,6 +254,9 @@
|
|
|
248
254
|
<span class="cm">/** Names the arithmetic: 'sum', 'vec(3, sum)'. Two channels of one kind fold identically. Absent, a channel
|
|
249
255
|
matches only itself. */</span>
|
|
250
256
|
kind?: string;
|
|
257
|
+
<span class="cm">/** The range a numeric value means anything in. The mix clamps the folded value to it, so stacked voices and a
|
|
258
|
+
retarget that carries speed stop flat at the bound. Part of the kind: mul({ bounds: [0, 1] }) is 'mul[0, 1]'. */</span>
|
|
259
|
+
bounds?: readonly [min: number, max: number];
|
|
251
260
|
<span class="cm">/** Identity. Absent means the channel has none: `color` replaces rather than contributes. */</span>
|
|
252
261
|
rest?: V;
|
|
253
262
|
<span class="cm">/** Fold two influences into one. */</span>
|
|
@@ -285,8 +294,9 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
285
294
|
<p>A patch is what an author writes. It is klieg's <code>EffectPiece</code> with three additions: the setting carries absolute time as well as normalized, a patch may declare state, and a patch declares which channels it contributes to.</p>
|
|
286
295
|
|
|
287
296
|
<pre><code>interface Patch<I, O, S = void> {
|
|
288
|
-
<span class="cm">/** Which authoring form built it
|
|
289
|
-
|
|
297
|
+
<span class="cm">/** Which authoring form built it: `'fn'` from `patch`, `'keys'` from `keys`, `'motion'` from `spring`,
|
|
298
|
+
`glide` or `tween`. An engine declares which forms it runs. */</span>
|
|
299
|
+
readonly form: 'fn' | 'keys' | 'motion';
|
|
290
300
|
<span class="cm">/** Milliseconds one pass lasts. 0 is aperiodic: one pass that never ends, so `phase` and `pass` stay 0,
|
|
291
301
|
`loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
|
|
292
302
|
period: number;
|
|
@@ -307,6 +317,10 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
307
317
|
|
|
308
318
|
<span class="cm">/** Present when the patch was authored as keyframes, so an engine that reads data can. */</span>
|
|
309
319
|
readonly keys?: readonly Keyframe<O>[];
|
|
320
|
+
<span class="cm">/** Present on a `'motion'` patch: its kind and constants, for an engine that reads data. */</span>
|
|
321
|
+
readonly motion?: MotionSpec;
|
|
322
|
+
<span class="cm">/** A copy of `state` sharing nothing with it, for a read at another time. Default `structuredClone`. */</span>
|
|
323
|
+
clone?(state: S): S;
|
|
310
324
|
}
|
|
311
325
|
|
|
312
326
|
interface Setting<S = void> {
|
|
@@ -335,16 +349,17 @@ interface Setting<S = void> {
|
|
|
335
349
|
send(event: unknown): void;
|
|
336
350
|
}</code></pre>
|
|
337
351
|
|
|
338
|
-
<p>
|
|
352
|
+
<p>Three authoring forms build a patch. All return the same interface, so a voice cannot tell them apart.</p>
|
|
339
353
|
<div class="tbl"><table>
|
|
340
354
|
<tr><th>form</th><th>signature</th><th>what it is</th></tr>
|
|
341
355
|
<tr><td><code>fn</code></td><td><code>patch(period, at, { writes, state?, step? })</code></td><td>Procedural. Flicker, roving, a hash walk, a starter strike. Only a JS engine can run one.</td></tr>
|
|
342
356
|
<tr><td><code>keys</code></td><td><code>keys(period, stops, { ease?, easeBy?, delayBy? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code> for an engine that reads data instead of calling functions.</td></tr>
|
|
357
|
+
<tr><td><code>motion</code></td><td><code>spring(writes, opts)</code>, <code>glide(writes, opts)</code>, <code>tween(writes, opts)</code></td><td>Momentum, or an eased curve between two values, solved in closed form per subject; <a href="#motion">below</a>. The kind and its constants are preserved on <code>patch.motion</code>, a <code>MotionSpec</code>.</td></tr>
|
|
343
358
|
</table></div>
|
|
344
359
|
|
|
345
360
|
<p><code>writes</code> is the patch's half of the contract the rest of the package already states: a channel kit declares what a consumer has, an engine declares its <code>runs</code> set, and both are checked at <code>cue</code>. Without it the only way to learn what a patch touches is to call it, per subject, per frame. With it the mix refuses a patch contributing a channel the kit lacks, resolves each voice's channels once at <code>cue</code> instead of walking the whole kit per probe, and can answer what a voice affects without probing it. A <code>keys</code> patch derives it from its stops.</p>
|
|
346
361
|
|
|
347
|
-
<p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be — CSS’s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read — or as a <code>(u: number) => number</code>, which only the CPU engine runs, and <code>Keyframe<O></code> is one stop, <code>{ at: number; delta: Partial<O>; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is the <code>hex</code> channel's <code>lerp</code>,
|
|
362
|
+
<p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be — CSS’s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read — or as a <code>(u: number) => number</code>, which only the CPU engine runs, and <code>Keyframe<O></code> is one stop, <code>{ at: number; delta: Partial<O>; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is the <code>hex</code> channel's <code>lerp</code>, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB’s gamut is clipped per channel. <code>hex({ space: 'srgb' })</code> blends red, green and blue each on its own instead, as klieg’s light channel does.</p>
|
|
348
363
|
|
|
349
364
|
<p>Wrappers stay wrappers. klieg's <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>hinge</code> and <code>partition</code> take a patch and return a patch, and none of them changes except to pass <code>writes</code> through — a wrapper reshapes one influence and never adds a channel to it. Composition at the patch level is for reshaping one influence; composition at the mix level is for running several. The two do not merge.</p>
|
|
350
365
|
|
|
@@ -356,6 +371,10 @@ interface Setting<S = void> {
|
|
|
356
371
|
<span class="cm">/** Which subjects this voice reaches. Default: all of them. The predicate is fixed at `cue`; it runs per
|
|
357
372
|
subject the first time the mix sees that subject, and the answer is kept for as long as the state is. */</span>
|
|
358
373
|
target?: (subject: I) => boolean;
|
|
374
|
+
<span class="cm">/** The subjects this voice reaches, fixed at `cue` and matched by identity; a probe of any other never
|
|
375
|
+
asks this voice. Takes the place of <code>target</code>, and a cue giving both is refused: a predicate over a
|
|
376
|
+
fixed list is that list filtered, which the host can do before cueing. */</span>
|
|
377
|
+
subjects?: readonly I[];
|
|
359
378
|
|
|
360
379
|
<span class="cm">// clock</span>
|
|
361
380
|
<span class="cm">/** When the voice starts, in ms on the clock the host passes <code>sync</code>. Default: the next sync. */</span>
|
|
@@ -386,6 +405,13 @@ interface Setting<S = void> {
|
|
|
386
405
|
from?: 'current';
|
|
387
406
|
<span class="cm">/** Words a source attaches to its voices, so their events can be drained as a set. */</span>
|
|
388
407
|
tags?: readonly string[];
|
|
408
|
+
<span class="cm">// score</span>
|
|
409
|
+
<span class="cm">/** What other voices call this one by. A label blits never reads. */</span>
|
|
410
|
+
name?: string;
|
|
411
|
+
<span class="cm">/** The plan it belongs to; a bare name in an anchor means a voice in the same score. */</span>
|
|
412
|
+
score?: string;
|
|
413
|
+
<span class="cm">/** start or in, and out or end: each a timestamp or an anchor to another voice's mark. See Score. */</span>
|
|
414
|
+
anchor?: Placement;
|
|
389
415
|
}
|
|
390
416
|
|
|
391
417
|
interface Handle<I> {
|
|
@@ -428,6 +454,11 @@ interface MixOptions {
|
|
|
428
454
|
maxDt?: number;
|
|
429
455
|
<span class="cm">/** Run every `step` once per interval of this many ms instead of once by the frame's gap. Off by default. */</span>
|
|
430
456
|
stepMs?: number;
|
|
457
|
+
<span class="cm">/** How far back `project` may read, and how often a stateful voice's state is copied on the way. Off by default. */</span>
|
|
458
|
+
history?: { ms: number; every?: number; inputs?: boolean };
|
|
459
|
+
<span class="cm">/** Whether a channel may run as a lane. On by default; the pose is the same either way, apart from
|
|
460
|
+
a stateless patch's `send` from `at` and a patch that starts keeping state partway through. */</span>
|
|
461
|
+
lanes?: boolean;
|
|
431
462
|
}
|
|
432
463
|
|
|
433
464
|
interface Mix<I, O> {
|
|
@@ -445,8 +476,17 @@ interface Mix<I, O> {
|
|
|
445
476
|
rebase(): void;
|
|
446
477
|
<span class="cm">/** The merged pose for one subject at the synced frame. `out` is written in place when given. */</span>
|
|
447
478
|
probe(subject: I, out?: O): O;
|
|
479
|
+
<span class="cm">/** Each subject's pose written into one Float64Array per channel, in the order given: what probe(subject, out)
|
|
480
|
+
gives, without a pose object per subject. A channel of n numbers takes n places a subject. */</span>
|
|
481
|
+
pull(subjects: Iterable<I>, into: { [K in numeric channels of O]?: Float64Array }): void;
|
|
448
482
|
<span class="cm">/** Every channel at rest for this subject this frame, so a host can skip the write. */</span>
|
|
449
483
|
atRest(subject: I): boolean;
|
|
484
|
+
<span class="cm">/** The mix read at another timestamp without moving it: ahead plays what is cued forward, behind needs `history`. */</span>
|
|
485
|
+
project(timestamp: number): Projection<I, O>;
|
|
486
|
+
<span class="cm">/** Puts a named mark on a score, now or at a timestamp, for anchors to target. */</span>
|
|
487
|
+
announce(name: string, opts?: { at?: number; score?: string; tags?: readonly string[] }): void;
|
|
488
|
+
<span class="cm">/** Every mark the plan knows between two timestamps, earliest first. */</span>
|
|
489
|
+
marks(from: number, to: number): Marked[];
|
|
450
490
|
|
|
451
491
|
<span class="cm">/** Anything still contributing, fading, or pending. A host stops its loop when this is false. `cue` and
|
|
452
492
|
`blend` make it true again at once; the mix never calls back into the host, so restarting the loop is
|
|
@@ -454,13 +494,13 @@ interface Mix<I, O> {
|
|
|
454
494
|
readonly live: boolean;
|
|
455
495
|
<span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
|
|
456
496
|
mute(opts?: { over?: number }): void;
|
|
457
|
-
<span class="cm">/** Forgets per-subject state. Call when a subject is retired, or let the WeakMap do it. */</span>
|
|
497
|
+
<span class="cm">/** Forgets per-subject state, a motion patch's included. Call when a subject is retired, or let the WeakMap do it. */</span>
|
|
458
498
|
drop(subject: I): void;
|
|
459
499
|
<span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
|
|
460
500
|
drain<E>(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
|
|
461
501
|
}</code></pre>
|
|
462
502
|
|
|
463
|
-
<p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95 ms a frame before the list and 0.45 ms after (<code>npm run bench</code>, rows <code>own</code>).</p>
|
|
503
|
+
<p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95 ms a frame before the list and 0.45 ms after (<code>npm run bench</code>, rows <code>own</code>). Building the list still asks every <code>target</code> of every new subject, so the first frame of that scene took 500–580 ms. A voice that names its subjects with <code>subjects</code> is filed under each of them at <code>cue</code>, and a subject's list is built from the voices filed under it and those that name none: the same scene's first frame took 1.0 ms, and 10.6 ms at 10,000 subjects (rows <code>named</code>).</p>
|
|
464
504
|
|
|
465
505
|
<p>Subjects are not registered. State lives in a <code>WeakMap</code> keyed by the subject object, created on first probe and stepped by the gap in the timestamp since that subject was last stepped. That is the rule klieg's dwell arrived at, and it means a part a wrapper leaves unasked for a while catches up rather than resuming where it paused.</p>
|
|
466
506
|
|
|
@@ -489,7 +529,8 @@ function gate<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
489
529
|
<li><b><code>dt</code> is derived, never supplied.</b> The gap between this sync and the last, per subject, and <code>Infinity</code> under reduced motion. A patch that integrates guards against that with a snap, as klieg's already do.</li>
|
|
490
530
|
<li><b>Time away is the host's to call.</b> A hidden tab gets no frames, so its next <code>sync</code> can be an hour past the last. By default that hour is a gap like any other: fades and finite loops finish, and every subject catches up by all of it. A host that calls <code>mix.rebase()</code> on <code>visibilitychange</code> says the gap does not count, and the mix clock — what <code>setting.timestamp</code> reads, and what voices, fades and subjects run on — resumes where it was left. It is then the host's timestamp less the time taken out, and a <code>start</code> the host gives is read on the host's clock and converted. <code>maxDt</code> caps the <code>dt</code> a <code>step</code> or signal is handed for any gap, the net an integrator wants; it is off by default because a patch with a closed form wants the whole gap.</li>
|
|
491
531
|
<li><b>There is no frame rate, only an optional step interval.</b> A frame is a reading, so a stateless patch reads the same at any rate. A <code>step</code> handed the frame's gap does not: an integrator gives a different answer at 30 and 144 fps. <code>stepMs</code> runs <code>step</code> once per whole interval instead, on a grid counted from when the subject's delay ran out, carrying the remainder, so the answer and every event's time are the same at any frame rate. <code>maxDt</code> then caps how many intervals one sample runs. Signals and <code>at</code> still read the frame. The host's clock is whatever it passes <code>sync</code>: a rAF timestamp and <code>performance.now()</code> share an origin, and <code>Date.now()</code> does not.</li>
|
|
492
|
-
<li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. This is the small half of weasel's events: no booking ahead against an outside clock, and no retraction.</li>
|
|
532
|
+
<li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. The exception is a stateless patch sending from <code>at</code> on a lane: a lane fills every subject the mix has met, so it sends for each of them every frame, probed or not. This is the small half of weasel's events: no booking ahead against an outside clock, and no retraction.</li>
|
|
533
|
+
<li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
|
|
493
534
|
<li><b>Calls land on the next sync.</b> A host writes <code>handle.weight</code> or calls <code>fade</code> on its own clock; the mix applies it at the first timestamp that sees it. Nothing changes mid-frame.</li>
|
|
494
535
|
</ul>
|
|
495
536
|
|
|
@@ -511,23 +552,55 @@ function gate<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
511
552
|
<p>A weight scales an influence toward its channel's rest. A lerp moves between two values. Every blend is one or the other, chosen by the channel. A consumer that needs a third arithmetic adds a channel, not a mix option.</p>
|
|
512
553
|
</div>
|
|
513
554
|
|
|
514
|
-
<h2 id="motion">Springs and
|
|
515
|
-
<p>Motion that has momentum is not a curve over a known duration, so it is neither <code>keys</code> nor <code>Easing</code>. Two patches carry it, each solved in closed form per subject, so where a subject is at a given moment does not depend on how the frames that sample it were spaced. Each keeps one stretch per subject: released at a voice time from a position and a velocity, heading somewhere. A change mid-flight ends the stretch and starts the next from where the subject is <em>and how fast it is moving</em>, which is what keeps a retarget from kinking.</p>
|
|
555
|
+
<h2 id="motion">Springs, glides and tweens</h2>
|
|
556
|
+
<p>Motion that has momentum is not a curve over a known duration, so it is neither <code>keys</code> nor <code>Easing</code>. Two patches carry it, both of form <code>motion</code>, each solved in closed form per subject, so where a subject is at a given moment does not depend on how the frames that sample it were spaced. Each keeps one stretch per subject: released at a voice time from a position and a velocity, heading somewhere. A change mid-flight ends the stretch and starts the next from where the subject is <em>and how fast it is moving</em>, which is what keeps a retarget from kinking.</p>
|
|
516
557
|
<pre><code>const s = spring<Node, Pose>('x', { from: 0, to: 100, stiffness: 170, damping: 26 });
|
|
517
558
|
mix.cue({ patch: s });
|
|
518
559
|
s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
|
|
519
560
|
s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
|
|
520
561
|
s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
|
|
521
562
|
|
|
522
|
-
const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span
|
|
563
|
+
const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span>
|
|
564
|
+
|
|
565
|
+
const t = tween<Node, Pose>('x', { from: (n) => n.x, to: (n) => n.goal, ms: 400, ease: 'ease-out' });
|
|
566
|
+
t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
|
|
567
|
+
<p>A <b>tween</b> has no momentum: it is an easing over <code>ms</code> from one value to another. It is a motion patch rather than a <code>keys</code> one because its endpoints are each subject's own data, read once when the mix first meets the subject, and it retargets one subject at a time like a spring. A retarget starts from where the subject is and runs the easing again from 0, so its velocity jumps where a spring's would not. A voice's <code>loop</code> and <code>period</code> do not repeat it. It takes the easings <code>keys</code> takes; its velocity, which <code>read</code> reports, is the easing's slope, which a lane never computes, since a mix only wants the value.</p>
|
|
523
568
|
<ul>
|
|
524
|
-
<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 at the subject's
|
|
525
|
-
<li><b>History is
|
|
526
|
-
<li><b>A
|
|
527
|
-
<li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. Cue each one on one voice.</li>
|
|
569
|
+
<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>
|
|
570
|
+
<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>
|
|
571
|
+
<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>
|
|
572
|
+
<li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. 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.</li>
|
|
573
|
+
<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>, 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>
|
|
528
574
|
<li><b>Handing motion between patches</b> is <code>read</code> on the old one and <code>from</code> plus <code>velocity</code> on the new: a fling that ends in a spring starts the spring at the glide's velocity.</li>
|
|
529
575
|
</ul>
|
|
530
576
|
|
|
577
|
+
<h2 id="score">Score</h2>
|
|
578
|
+
<p>The score is the plan: when each voice plays, for how long, and relative to which other voice. It never says what a voice contributes, which stays in the patch and the channels, and it never reads a channel's value. A voice has four <b>marks</b> on the mix clock: <code>start</code> (its fade in begins), <code>in</code> (full weight), <code>out</code> (its fade out begins) and <code>end</code> (gone).</p>
|
|
579
|
+
<pre><code>type Mark = 'start' | 'in' | 'out' | 'end';
|
|
580
|
+
interface Placement { start?: number | Anchor; in?: number | Anchor; out?: number | Anchor; end?: number | Anchor }
|
|
581
|
+
type Anchor =
|
|
582
|
+
| { of: string | Query; mark: Mark; by?: number }
|
|
583
|
+
| { after: string | Query; by?: number } <span class="cm">// its end</span>
|
|
584
|
+
| { with: string | Query; by?: number } <span class="cm">// its start</span>
|
|
585
|
+
| { before: string | Query; by?: number }; <span class="cm">// its start, less by</span>
|
|
586
|
+
interface Query { score?: string; name?: string; tag?: string; writes?: string; resolver?: 'last' | 'first' | 'next' | 'earliest' | 'latest' }
|
|
587
|
+
|
|
588
|
+
interface Projection<I, O> {
|
|
589
|
+
readonly timestamp: number;
|
|
590
|
+
probe(subject: I, out?: O): O;
|
|
591
|
+
assess(subject: I): { [K in keyof O]-?: 'exact' | 'stepped' | 'held' };
|
|
592
|
+
}</code></pre>
|
|
593
|
+
<ul>
|
|
594
|
+
<li><b>Placing.</b> A cue anchors at most one of <code>start</code> and <code>in</code>, and one of <code>out</code> and <code>end</code>, to a timestamp or to another voice's mark: <code>anchor: { in: 12_400 }</code> is fully in by 12.4 s, <code>anchor: { start: { after: 'intro' } }</code> starts once <code>intro</code> is gone. Anchoring <code>in</code> or <code>end</code> counts the voice's own fade. A query selects by <code>name</code>, <code>tag</code> or a channel the voice <code>writes</code>, and its resolver picks among several, <code>last</code> cued by default.</li>
|
|
595
|
+
<li><b>Several scores.</b> Each source keeps its own plan: a voice's <code>score</code> names it, and the mix plays every score it holds at once, the way a DAW plays every track. Names are namespaced by score, so a bare name in an anchor means a voice in the asker's score and a query names <code>score</code> to reach into another. A voice with none is in the mix's one unnamed score.</li>
|
|
596
|
+
<li><b>Waiting and following.</b> An anchor with no answer — a name not cued, the <code>out</code> of a voice that loops for good — leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh only while the mix's history reaches it. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
|
|
597
|
+
<li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own four or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
|
|
598
|
+
<li><b>What comes next.</b> <code>mix.marks(from, to)</code> lists every mark the plan has fixed in a range, with each voice's id, name and tags. One nothing has fixed is left out.</li>
|
|
599
|
+
<li><b>Reading another time.</b> <code>mix.project(t)</code> copies what each subject holds and reads it at <code>t</code>, sending nothing and committing nothing, so the live mix plays on exactly as if it had not been read. Ahead, it plays what is cued forward in one step across the gap. Behind, it needs <code>history</code>: the mix keeps every voice's control changes with when they landed, the voices that have left, and a copy of each stateful record per subject every <code>every</code> ms, and a read back restores the nearest copy at or before <code>t</code> and steps forward. A subject it kept nothing for starts from its voice's start with fresh state. A patch's own <code>clone</code> copies state that <code>structuredClone</code> cannot. With <code>inputs</code>, history also keeps what each input signal on a voice's weight read per subject, and a copy of the host fields patches <code>reads</code>, each time they changed, and a read back uses those instead of asking the signal or the host. Where anchors fixed a voice's start or out after the time read, it reads back as it stood then.</li>
|
|
600
|
+
<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 stateful signal did, and <code>exact</code> otherwise. 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.</li>
|
|
601
|
+
<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.</li>
|
|
602
|
+
</ul>
|
|
603
|
+
|
|
531
604
|
<h2 id="state">Patch state</h2>
|
|
532
605
|
<p>klieg built three stateful patches in one week, <code>power</code>, <code>kicks</code> and <code>dwell</code>, and each rediscovered the same three rules: key state by the subject object, step once per frame by the gap in the timestamp, snap under infinite <code>dt</code>. Those rules are the package's, applied by the mix, and a patch declares only what its state is and how it advances.</p>
|
|
533
606
|
|
|
@@ -547,21 +620,41 @@ const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <
|
|
|
547
620
|
<pre><code>interface Engine {
|
|
548
621
|
readonly name: string;
|
|
549
622
|
<span class="cm">/** Which patch forms this engine can run. A voice whose patch it cannot run is refused at `cue`, by name. */</span>
|
|
550
|
-
readonly runs: ReadonlySet<'fn' | 'keys'>;
|
|
623
|
+
readonly runs: ReadonlySet<'fn' | 'keys' | 'motion'>;
|
|
551
624
|
<span class="cm">/** Builds the mix. Everything on `Mix` is the engine's to implement. */</span>
|
|
552
625
|
create<I, O>(kit: Kit<O>, opts: MixOptions): Mix<I, O>;
|
|
553
626
|
}
|
|
554
627
|
|
|
555
628
|
export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
|
|
556
629
|
|
|
557
|
-
<p><b>mixer</b> runs
|
|
630
|
+
<p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each influence into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a locus folds to one influence before that influence is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
|
|
558
631
|
<ul>
|
|
559
|
-
<li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> patches at <code>cue</code>.</li>
|
|
632
|
+
<li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> and <code>motion</code> patches at <code>cue</code>.</li>
|
|
560
633
|
<li><b>A recording engine</b> wraps another engine and captures every pose into keyframes, turning a procedural patch into data for the baked one.</li>
|
|
561
634
|
<li><b>A GPU engine</b> writes per-subject deltas into a buffer instead of returning them, for a part count where a JS loop per frame is the cost. Same kit, same voices; <code>probe</code> becomes a read from the buffer.</li>
|
|
562
635
|
</ul>
|
|
563
636
|
<p>The seam holds only if nothing outside the engine assumes probing is a function call. Two consequences for the schema: <code>probe</code> takes an <code>out</code> parameter so a buffer-backed engine can fill in place, and <code>keys</code> patches keep their stops on the object rather than closing over them.</p>
|
|
564
637
|
|
|
638
|
+
<h2 id="lanes">Lanes</h2>
|
|
639
|
+
<p>Inside <code>mixer</code>, a channel can run as a <b>lane</b>: computed for every subject at once, in <code>Float64Array</code>s indexed by a number the mix gives each subject, instead of subject by subject. A host never asks for one. Lanes are on by default, and <code>MixOptions.lanes: false</code> turns them off, to rule one out or to compare against.</p>
|
|
640
|
+
<div class="tbl"><table>
|
|
641
|
+
<tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
|
|
642
|
+
<tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, and <code>vec</code> over one of them, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind</td></tr>
|
|
643
|
+
<tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; under <code>history.inputs</code>, a patch that <code>reads</code> host fields</td></tr>
|
|
644
|
+
<tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight, <code>locus</code>, <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
|
|
645
|
+
</table></div>
|
|
646
|
+
<p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A projection always reads through the general path.</p>
|
|
647
|
+
<ul>
|
|
648
|
+
<li><b>Subjects are numbered on first sight.</b> A subject's first probe takes the general path, where <code>target</code>, <code>stagger</code> and the fade-in origin are worked out, and gives it a number; so does each voice's first meeting with it later. <code>drop</code> frees the number, and for an object subject so does the host letting it go. A subject the host never probes is never numbered and costs a lane nothing.</li>
|
|
649
|
+
<li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches, then copies the subject's values into the pose; later probes that frame only copy. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
|
|
650
|
+
<li><b>A lane rests while its subjects go unprobed.</b> When fewer than 15% of a <code>keys</code> lane's subjects were probed in the last frame with a probe, or fewer than 60% of a <code>fn</code> or <code>motion</code> lane's, the lane stops filling and probes of its subjects take the general path; it fills again once that share reaches 25%, or 80%. Those lines sit either side of where filling and probing break even, measured at 1,000 and 10,000 subjects: about 19% for <code>keys</code>, 71–77% for a spring, and 63–100% for a stateless <code>fn</code>. Both paths give the same pose, so the switch only changes the cost.</li>
|
|
651
|
+
<li><b>Lanes give the same pose, to the bit.</b> Both paths call the same functions for phase, envelope, weight clamp, each stock channel's fold, the easing curves and the spring solver, and clamp <code>bounds</code> the same way after. <code>test/lanes.test.ts</code> runs every case both ways and compares each probe with <code>Object.is</code>. <code>weightOf</code>, <code>atRest</code> and a finite loop's end read what the fill keeps.</li>
|
|
652
|
+
<li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, probed or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host did not probe that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
|
|
653
|
+
<li><b>Motion runs on lanes as data.</b> <code>spring</code>, <code>glide</code> and <code>tween</code> are their own form so a lane can sample each subject's stretch from the patch's arrays rather than calling <code>at</code>. A fill samples only where sampling changes nothing; a subject with a change to apply is left to the general path, so a retarget lands where it would with lanes off.</li>
|
|
654
|
+
</ul>
|
|
655
|
+
<p>What they cost, measured on the fleet on 2026-10-02 against the build before them (the table is in <code>spikes/gpu-engine/README.md</code>, under Lanes, as built): three <code>keys</code> voices over 10,000 subjects fell from 6.1 to 2.6 ms a frame, and a stateless <code>fn</code> gained up to 37%. A spring frame first rose from 4.3 to 5.4 ms; once the spring solver stopped allocating per sample, a rerun put a spring frame at 0.53–0.72 of the earlier build's, <code>keys</code> at 0.38–0.57 and a stateless <code>fn</code> at 0.70–0.88. A host that probed 5% of the subjects it had met paid for all of them, 1.3 ms against 0.1 at 10,000, until resting lanes brought that to about 0.15 ms (one local machine). And a probe costs about 110–140 ns at 10,000 subjects even when a lane did all the work, so a host probing every subject pays 1.1–1.4 ms before any arithmetic.</p>
|
|
656
|
+
<p><b>Reading in bulk.</b> <code>mix.pull(subjects, { position, gain })</code> writes each subject's values into the host's own arrays instead of a pose object. It does everything a probe does for each subject in turn, so it gives the same values, to the bit; <code>test/lanes.test.ts</code> runs every case through it too. While every voice is on a lane, a subject the lanes filled is copied straight from them; any other subject is folded through the general path into one scratch pose and copied from that. Measured on the fleet on 2026-10-02 (msb-uai, three passes of <code>bench/frame.mjs</code>), a frame read through <code>pull</code> costs 0.84 of one read through <code>probe(subject, out)</code> for three <code>keys</code> voices over 10,000 subjects, 0.89 at 1,000, 0.91 for a spring and 0.93 for a stateless <code>fn</code>. What is left of a probe’s cost is per subject: finding its record, and the lane bookkeeping that decides which subjects a fill skips. Taking that out means keeping subject numbers between calls and doing the bookkeeping for a whole call at once; it is not built.</p>
|
|
657
|
+
|
|
565
658
|
<h2 id="kits">Channel kits</h2>
|
|
566
659
|
<p>What each consumer declares. Each row is one system — a kit, the subject type it drives, and the mix over them — and the rules are the ones each repo already applies; the kit makes them one line each.</p>
|
|
567
660
|
<div class="tbl"><table>
|
|
@@ -569,7 +662,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
569
662
|
<tr><td>klieg motion</td><td><code>LetterInfo</code></td><td>position vec3 sum · rotation vec3 sum · scale mul · opacity mul</td><td>Three voices in locus <code>phase</code>, fades of <code>blendMs</code>.</td></tr>
|
|
570
663
|
<tr><td>klieg effects</td><td><code>PartInfo</code></td><td>gain mul · scale mul · dark max · crawl sum · position vec3 sum · rotation vec3 sum · light vec3 sum · color last</td><td>Light is premultiplied by amount before it reaches the channel, as <code>mergeOffsets</code> does.</td></tr>
|
|
571
664
|
<tr><td>klieg lighting</td><td>unit</td><td>yaw sum · pitch sum</td><td>One subject, the frame. A mix with no subject dimension is a mix over one subject.</td></tr>
|
|
572
|
-
<tr><td>wod transitions</td><td>wedge <code>Segment</code></td><td>hold mul · opacity mul · scale mul · offset sum · rotate sum · aperture max · offsetAngle last</td><td>Every voice is <code>keys</code> with <code>from: 'current'</code>; the voices map goes away.</td></tr>
|
|
665
|
+
<tr><td>wod transitions</td><td>wedge <code>Segment</code></td><td>hold mul[0, 1] · opacity mul[0, 1] · scale mul · offset sum · rotate sum · aperture max[0, 1] · offsetAngle last</td><td>Every voice is <code>keys</code> with <code>from: 'current'</code>; the voices map goes away.</td></tr>
|
|
573
666
|
<tr><td>sherpa seams</td><td>page</td><td>opacity mul · transform (host's) · clip max</td><td>Exit and enter as two voices; overlap is whether both are live at once.</td></tr>
|
|
574
667
|
<tr><td>magicsmoke faults</td><td>fault</td><td>none: the patch writes no channels</td><td>One voice per fault, on a mix per fault that magicsmoke keeps to itself. Its weight is the intensity, a number or any signal. Its state is the seeded discharge process, stepped under <code>stepMs: 5</code>, easing exponentially toward <code>setting.weight</code> and sending each discharge as an event. Stopping is <code>handle.fade()</code>; <code>blow</code> writes into the fault's own state through magicsmoke's handle, not as a second voice. The mix never sets <code>reduce</code>, which would step off the grid, and <code>cue</code> passes the last synced timestamp as <code>start</code>, the old process's clock origin. Jolt, whine and hum stay off blits: the browser already composites jolt, and whine and hum fade on the audio clock. magicsmoke imports <code>Signal</code>, <code>Handle</code>, <code>Setting</code>, <code>slew</code> and <code>level</code>, so those are surface to keep stable.</td></tr>
|
|
575
668
|
</table></div>
|
|
@@ -580,7 +673,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
580
673
|
<li><b>A published patch names its kit.</b> <code>patch(…, { kit })</code> and <code>keys(…, { kit })</code> record the channels the patch was written against, and <code>cue</code> refuses a mix whose channel of the same name is another <code>kind</code>, naming both. Without it, a <code>sum</code> offset cued into a mix whose <code>offset</code> multiplies would fold wrong and say nothing. A custom channel has no kind and matches only itself, so a package that defines one exports the object for hosts to put in their kits.</li>
|
|
581
674
|
<li><b>A published patch names what it reads from the host.</b> <code>reads: ['pointer']</code>, and <code>cue</code> refuses a mix whose <code>host</code> lacks the field. A patch meant for anyone reads nothing from the host.</li>
|
|
582
675
|
<li><b>A package tags its voices and drains by its tag.</b> <code>cue({ …, tags: ['magicsmoke'] })</code>, then <code>mix.drain('magicsmoke')</code> takes that package's events and leaves every other package's queued. A tag is the vocabulary's word for a label a source attaches so a query can select voices as a set; draining is its first query.</li>
|
|
583
|
-
<li><b>A package that sends events publishes what handles them.</b> blits mixes numbers and times events; it draws nothing. A patch that only sends is half a piece, and the other half is the package's renderer, which the host would hand the drained events.
|
|
676
|
+
<li><b>A package that sends events publishes what handles them.</b> blits mixes numbers and times events; it draws nothing. A patch that only sends is half a piece, and the other half is the package's renderer, which the host would hand the drained events. The first is <code>@msb235/blits-quarks</code> in <code>packages/quarks</code>: a patch sends <code>{ count }</code> under a tag, and the driver drains that tag and emits the particles at the event's subject. magicsmoke still keeps its fault mix private, and the host calls <code>smoke.cue</code> and <code>smoke.sync</code> while magicsmoke syncs, probes, drains and renders inside. Exporting the patch and the renderer separately, for a host that owns the mix, is possible later and not designed.</li>
|
|
584
677
|
</ul>
|
|
585
678
|
<p>A host adds channels of its own beside the shared ones: a kit is a plain object, so <code>kit({ ...shared, light: vec(3, sum()), dark: max() })</code> is the whole mechanism, as sherpa's <code>transform</code> already is. Two conventions keep that safe. A host's own channel never takes a name the shared list holds, so klieg's <code>light</code> stays <code>light</code> only while the list has none. A package that names a kind of its own prefixes it with the package, <code>klieg:light</code> rather than <code>light</code>, because <code>cue</code> trusts two equal kinds to fold the same and nothing stops two packages claiming one bare word for different arithmetic.</p>
|
|
586
679
|
|
|
@@ -622,16 +715,18 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
622
715
|
<li><b>Locus fold.</b> Two voices in a locus driving the same value produce that value, on every stock channel — the invariant the old cap met on <code>sum</code> alone. At unequal values they produce the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the number scaling and merging already gives and is what keeps klieg's baselines green. A locus whose weights sum below 1 scales the folded value toward rest by the sum; above 1 it caps at 1.</li>
|
|
623
716
|
<li><b>Handover.</b> <code>fade({ at: 'rest' })</code> removes per subject at the first resting frame, and at the deadline for a patch that never rests.</li>
|
|
624
717
|
<li><b>Declared writes.</b> A patch's <code>at</code> sets no key outside its <code>writes</code>, on every subject and every pass. A voice whose patch writes a channel the kit lacks throws at <code>cue</code> naming both.</li>
|
|
718
|
+
<li><b>Reading another time.</b> A projection ahead gives what the mix probes when it gets there, and moves nothing: a mix read ahead every frame plays exactly as one never read. A read back under <code>stepMs</code> equals the pose the mix showed at every frame, through fades, rate and weight changes, seeks and voices that have left, and a round trip back and forth repeats it. History stays inside its horizon.</li>
|
|
719
|
+
<li><b>Placement.</b> astv's orb, landing text and tint, each anchored on the one before, list their marks and play to them; a waiting voice follows a target faded early; a placement that waits on itself is refused.</li>
|
|
720
|
+
<li><b>Lanes off against lanes on.</b> Every qualifying case runs with <code>lanes: false</code> and without, and each probe's channels must agree under <code>Object.is</code>: every patch form, <code>stagger</code>, <code>target</code>, <code>subjects</code>, loops, <code>rate</code>, <code>ramp</code>, <code>seek</code>, fades, <code>bounds</code>, a spring retargeted and pushed mid-flight, and a channel moving between a lane and the general path mid-animation. Subject numbers are freed and reused without showing an old subject's values.</li>
|
|
625
721
|
<li><b>Engine refusal.</b> An engine whose <code>runs</code> lacks <code>fn</code> throws at <code>cue</code> naming the voice.</li>
|
|
626
722
|
<li><b>The klieg extraction</b> is judged by klieg's own suites and the Playwright baselines, unchanged.</li>
|
|
627
723
|
</ul>
|
|
628
724
|
|
|
629
725
|
<h2 id="open">Open</h2>
|
|
630
726
|
<ul>
|
|
631
|
-
<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°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code>
|
|
632
|
-
<li><b>
|
|
727
|
+
<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°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> 0xrrggbb sRGB, blended in OKLCH. 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>
|
|
728
|
+
<li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>marks placed inside a voice</b>, such as a hit 340 ms in, as named marks beside its four. <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. <b>Splitting a read ahead at known events</b>, one step per interval between a voice starting, a fade ending and a loop running out; today a read ahead crosses the gap in one step and says <code>stepped</code> where that matters.</li>
|
|
633
729
|
<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>
|
|
634
|
-
<li><b>Color's lerp.</b> The stock <code>hex</code> channel ships sRGB, which is what klieg's light channel assumes and what keeps the port's arithmetic identical. OKLCH reads better for a crossfade between hues and would replace it rather than join it; the choice waits on seeing a hue crossfade in the port.</li>
|
|
635
730
|
<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>
|
|
636
731
|
<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>
|
|
637
732
|
</ul>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msb235/blits",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"build": "tsc -b",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"test:watch": "vitest",
|
|
40
|
-
"typecheck": "tsc -b && tsc -p tsconfig.test.json",
|
|
40
|
+
"typecheck": "tsc -b && tsc -p tsconfig.test.json && tsc -b packages/quarks && tsc -p packages/quarks/tsconfig.test.json",
|
|
41
41
|
"lint": "biome check .",
|
|
42
42
|
"check": "npm run lint && npm run typecheck && npm run test",
|
|
43
43
|
"prepack": "npm run build",
|
|
@@ -52,8 +52,5 @@
|
|
|
52
52
|
"@types/node": "^26.2.0",
|
|
53
53
|
"typescript": "^7.0.2",
|
|
54
54
|
"vitest": "^4.1.10"
|
|
55
|
-
}
|
|
56
|
-
"workspaces": [
|
|
57
|
-
"site"
|
|
58
|
-
]
|
|
55
|
+
}
|
|
59
56
|
}
|