@msb235/blits 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. A mix folds concurrent effects into one value per subject per frame: each effect runs
6
+ on its own clock with its own state and weight, and the rules for combining them belong to the
7
+ channel being written rather than to the effect writing it.
8
+
9
+ What is here: the stock channels (`sum`, `mul`, `max`, `last`, `vec`, `hex`), both authoring forms
10
+ for a patch (a function of phase, or a list of keyframes), voices with rates, loops, stagger, fade
11
+ envelopes and loci, the five signals (`peak`, `slew`, `lag`, `level`, `gate`) with their state
12
+ kept per voice and subject, per-subject state that catches up by its own gap or, under `stepMs`, at a
13
+ fixed interval so it plays the same at any frame rate, events a patch sends for the host to `drain`,
14
+ `handle.weightOf` for reading a voice's weight per subject, the checks that let one package's
15
+ patches mix into another's (`Channel.kind`, `Patch.kit` and `Patch.reads` checked at `cue`, and
16
+ `tags` to drain one package's events), and the `Engine` seam behind `mix`.
17
+
18
+ [klieg](https://github.com/orochi235/klieg) composes its motion, its effects and its lighting on
19
+ this, which is where the arithmetic was proven: that port changed no number and moved no baseline.
20
+
21
+ Zero runtime dependencies. ESM only. The design is `docs/schema.html`, shipped alongside.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Baker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # blits
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Concurrent effects, mixed. Each effect runs on its own clock with its own state and weight, and a
4
+ mix folds them into one value per subject per frame by rules that belong to the channel, not the
5
+ effect.
6
+
7
+ It is the thing klieg does three times over — motion, effects, lighting — and wod does once for
8
+ transitions, extracted so those repos, sherpa and magicsmoke can share one vocabulary and one
9
+ engine.
10
+
11
+ ```ts
12
+ import { hex, kit, max, mix, mul, patch, sum, vec } from '@msb235/blits';
13
+
14
+ // What a part of a sign can move on. The arithmetic lives here and nowhere else.
15
+ const PART = kit<PartPose>({
16
+ gain: mul(),
17
+ dark: max(),
18
+ position: vec(3, sum()),
19
+ color: hex(),
20
+ });
21
+
22
+ const flicker = patch<Part, PartPose>(
23
+ 180,
24
+ (phase, part) => ({ gain: 0.2 + 0.8 * Math.abs(Math.sin(phase * Math.PI + part.seed)) }),
25
+ { writes: ['gain'] },
26
+ );
27
+
28
+ const m = mix<Part, PartPose>(PART);
29
+ const handle = m.cue({ patch: flicker, fade: { in: 300, out: 300 } });
30
+
31
+ // Once a frame: tell the mix what time it is, then ask for the parts you are about to draw.
32
+ function frame(timestamp: number) {
33
+ m.sync(timestamp);
34
+ for (const part of visible) apply(part, m.probe(part, scratch));
35
+ if (m.live) requestAnimationFrame(frame);
36
+ }
37
+
38
+ // A hidden tab gets no frames; say the time away should not count, or every fade finishes at once.
39
+ document.addEventListener('visibilitychange', () => document.hidden || m.rebase());
40
+
41
+ handle.fade({ over: 500 }); // the voice ramps out and leaves
42
+ ```
43
+
44
+ ## The words
45
+
46
+ Ten of them carry the rest: **delta** (a partial record of channel values), **channel** (one field
47
+ with its own arithmetic), **kit** (the channel set for one kind of delta), **subject** (what is
48
+ driven), **patch** (a pure function of phase and a subject), **voice** (a patch with its own clock
49
+ and weight), **mix** (the live voices over one kit), **signal** (a 0..1 scalar from outside the
50
+ clock), **handle** (the live controls on one voice), **engine** (the implementation behind a mix).
51
+
52
+ The design — the full vocabulary, the channel table, the time model, blending, the engine seam, and
53
+ what is still open — is `docs/schema.html`, which ships in the package: open it from
54
+ `node_modules/@msb235/blits/`. The naming work behind it is
55
+ `docs/vocabulary.json`, which semanticore serves as a page.
56
+ That repo is private; the JSON stands on its own.
57
+
58
+ ## The site
59
+
60
+ `site/` is a docs site with a live explainer for each word and the API reference, generated from the
61
+ doc comments in `src/`. It runs the working tree, not a build:
62
+
63
+ ```
64
+ npm run site # dev server on port 4880
65
+ npm run site:smoke # build, then load every page headless and fail on any error
66
+ ```
67
+
68
+ `.github/workflows/site.yml` deploys it to GitHub Pages. It runs only by hand until Pages is enabled
69
+ on the repo.
70
+
71
+ ## Status
72
+
73
+ v0.1.0, and one real consumer: [klieg](https://github.com/orochi235/klieg) composes all three of
74
+ its systems — letter motion, part effects, and the environment a sign is lit by — on a mix each.
75
+ That port was an extraction rather than a rewrite, so it is also the evidence that the arithmetic
76
+ here is the arithmetic a working renderer already had: klieg's suite of 1974 cases and its
77
+ screenshot baselines came through it unchanged.
78
+
79
+ One thing the port sent back. klieg's three motion phases layer, so they cue as separate voices; a
80
+ `locus` folds its members through each channel's `lerp` instead, which is what a crossfade between
81
+ alternatives wants and is not the same number on a multiplicative channel. Reach for a locus when
82
+ one voice should replace another, not when both should be heard.
83
+
84
+ Zero runtime dependencies, ESM only, types included.
85
+
86
+ ```
87
+ npm install
88
+ npm run check # lint, typecheck, test
89
+ ```
90
+
91
+ MIT.
@@ -0,0 +1,54 @@
1
+ import type { Channel, Kit } from './types.js';
2
+ /**
3
+ * Position axes, rotation, crawl, yaw, pitch, offset.
4
+ *
5
+ * @category channel
6
+ */
7
+ export declare function sum(): Channel<number>;
8
+ /**
9
+ * Gain, scale, opacity, hold.
10
+ *
11
+ * @category channel
12
+ */
13
+ export declare function mul(): Channel<number>;
14
+ /**
15
+ * Dark, aperture.
16
+ *
17
+ * @category channel
18
+ */
19
+ export declare function max(): Channel<number>;
20
+ /**
21
+ * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
22
+ * default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
23
+ * consumer with a real interpolation passes its own, as `hex` does.
24
+ *
25
+ * @category channel
26
+ */
27
+ export declare function last<V>(opts?: {
28
+ lerp?: (a: V, b: V, u: number) => V;
29
+ }): Channel<V>;
30
+ /**
31
+ * Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
32
+ *
33
+ * @category channel
34
+ */
35
+ export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
36
+ /**
37
+ * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
38
+ * unchanged. OKLCH reads better across hues and is the open question, not a second function.
39
+ *
40
+ * @category channel
41
+ */
42
+ export declare function mixHex(a: number, b: number, u: number): number;
43
+ /**
44
+ * Color, as 0xrrggbb.
45
+ *
46
+ * @category channel
47
+ */
48
+ export declare function hex(): Channel<number>;
49
+ /**
50
+ * A kit is plain data; this only fixes the type.
51
+ *
52
+ * @category channel
53
+ */
54
+ export declare function kit<O>(channels: Kit<O>): Kit<O>;
@@ -0,0 +1,131 @@
1
+ const mix = (a, b, u) => a + (b - a) * u;
2
+ /**
3
+ * Position axes, rotation, crawl, yaw, pitch, offset.
4
+ *
5
+ * @category channel
6
+ */
7
+ export function sum() {
8
+ return {
9
+ kind: 'sum',
10
+ rest: 0,
11
+ merge: (a, b) => a + b,
12
+ scale: (v, w) => v * w,
13
+ lerp: mix,
14
+ };
15
+ }
16
+ /**
17
+ * Gain, scale, opacity, hold.
18
+ *
19
+ * @category channel
20
+ */
21
+ export function mul() {
22
+ return {
23
+ kind: 'mul',
24
+ rest: 1,
25
+ merge: (a, b) => a * b,
26
+ scale: (v, w) => 1 + (v - 1) * w,
27
+ lerp: mix,
28
+ };
29
+ }
30
+ /**
31
+ * Dark, aperture.
32
+ *
33
+ * @category channel
34
+ */
35
+ export function max() {
36
+ return {
37
+ kind: 'max',
38
+ rest: 0,
39
+ merge: (a, b) => (a > b ? a : b),
40
+ scale: (v, w) => v * w,
41
+ lerp: mix,
42
+ };
43
+ }
44
+ /**
45
+ * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
46
+ * default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
47
+ * consumer with a real interpolation passes its own, as `hex` does.
48
+ *
49
+ * @category channel
50
+ */
51
+ export function last(opts) {
52
+ return {
53
+ // A lerp of its own is arithmetic the name cannot vouch for.
54
+ kind: opts?.lerp ? undefined : 'last',
55
+ merge: (_a, b) => b,
56
+ lerp: opts?.lerp ?? ((a, b, u) => (u < 0.5 ? a : b)),
57
+ };
58
+ }
59
+ /**
60
+ * Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
61
+ *
62
+ * @category channel
63
+ */
64
+ export function vec(n, of) {
65
+ const rest = of.rest === undefined ? undefined : new Array(n).fill(of.rest);
66
+ const scale = of.scale;
67
+ const fill = of.rest ?? 0;
68
+ return {
69
+ kind: of.kind === undefined ? undefined : `vec(${n}, ${of.kind})`,
70
+ rest,
71
+ merge: (a, b) => {
72
+ const out = new Array(n);
73
+ for (let i = 0; i < n; i++)
74
+ out[i] = of.merge(a[i] ?? fill, b[i] ?? fill);
75
+ return out;
76
+ },
77
+ scale: scale === undefined
78
+ ? undefined
79
+ : (v, w) => {
80
+ const out = new Array(n);
81
+ for (let i = 0; i < n; i++)
82
+ out[i] = scale(v[i] ?? fill, w);
83
+ return out;
84
+ },
85
+ fold: scale === undefined
86
+ ? undefined
87
+ : (into, v, w) => {
88
+ for (let i = 0; i < n; i++)
89
+ into[i] = of.merge(into[i] ?? fill, scale(v[i] ?? fill, w));
90
+ return into;
91
+ },
92
+ lerp: (a, b, u) => {
93
+ const out = new Array(n);
94
+ for (let i = 0; i < n; i++)
95
+ out[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
96
+ return out;
97
+ },
98
+ };
99
+ }
100
+ const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
101
+ /**
102
+ * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
103
+ * unchanged. OKLCH reads better across hues and is the open question, not a second function.
104
+ *
105
+ * @category channel
106
+ */
107
+ export function mixHex(a, b, u) {
108
+ const ar = (a >> 16) & 0xff;
109
+ const ag = (a >> 8) & 0xff;
110
+ const ab = a & 0xff;
111
+ const br = (b >> 16) & 0xff;
112
+ const bg = (b >> 8) & 0xff;
113
+ const bb = b & 0xff;
114
+ return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
115
+ }
116
+ /**
117
+ * Color, as 0xrrggbb.
118
+ *
119
+ * @category channel
120
+ */
121
+ export function hex() {
122
+ return { ...last({ lerp: mixHex }), kind: 'hex' };
123
+ }
124
+ /**
125
+ * A kit is plain data; this only fixes the type.
126
+ *
127
+ * @category channel
128
+ */
129
+ export function kit(channels) {
130
+ return channels;
131
+ }
@@ -0,0 +1,4 @@
1
+ import type { Easing } from './types.js';
2
+ export type Curve = (u: number) => number;
3
+ /** The function an easing names. Resolved once where a patch or voice is built, not per frame. */
4
+ export declare function curve(easing: Easing): Curve;
package/dist/easing.js ADDED
@@ -0,0 +1,75 @@
1
+ const linear = (u) => u;
2
+ /** A CSS `cubic-bezier`: solves x(t) = u for t, then reads y(t). */
3
+ function bezier(x1, y1, x2, y2) {
4
+ const cx = 3 * x1;
5
+ const bx = 3 * (x2 - x1) - cx;
6
+ const ax = 1 - cx - bx;
7
+ const cy = 3 * y1;
8
+ const by = 3 * (y2 - y1) - cy;
9
+ const ay = 1 - cy - by;
10
+ const x = (t) => ((ax * t + bx) * t + cx) * t;
11
+ const dx = (t) => (3 * ax * t + 2 * bx) * t + cx;
12
+ const y = (t) => ((ay * t + by) * t + cy) * t;
13
+ return (u) => {
14
+ if (u <= 0)
15
+ return 0;
16
+ if (u >= 1)
17
+ return 1;
18
+ let t = u;
19
+ for (let i = 0; i < 8; i++) {
20
+ const err = x(t) - u;
21
+ if (Math.abs(err) < 1e-7)
22
+ return y(t);
23
+ const d = dx(t);
24
+ if (Math.abs(d) < 1e-6)
25
+ break;
26
+ t -= err / d;
27
+ }
28
+ let lo = 0;
29
+ let hi = 1;
30
+ t = u;
31
+ while (hi - lo > 1e-7) {
32
+ if (x(t) < u)
33
+ lo = t;
34
+ else
35
+ hi = t;
36
+ t = (lo + hi) / 2;
37
+ }
38
+ return y(t);
39
+ };
40
+ }
41
+ const named = {
42
+ linear,
43
+ ease: bezier(0.25, 0.1, 0.25, 1),
44
+ 'ease-in': bezier(0.42, 0, 1, 1),
45
+ 'ease-out': bezier(0, 0, 0.58, 1),
46
+ 'ease-in-out': bezier(0.42, 0, 0.58, 1),
47
+ };
48
+ const built = new WeakMap();
49
+ /** The function an easing names. Resolved once where a patch or voice is built, not per frame. */
50
+ export function curve(easing) {
51
+ if (typeof easing === 'function')
52
+ return easing;
53
+ if (typeof easing === 'string') {
54
+ const found = named[easing];
55
+ if (!found)
56
+ throw new Error(`blits: no easing named ${easing}`);
57
+ return found;
58
+ }
59
+ const held = built.get(easing);
60
+ if (held)
61
+ return held;
62
+ let made;
63
+ if ('bezier' in easing)
64
+ made = bezier(...easing.bezier);
65
+ else {
66
+ const n = easing.steps;
67
+ const start = easing.jump === 'start';
68
+ made = (u) => {
69
+ const c = u <= 0 ? 0 : u >= 1 ? 1 : u;
70
+ return (start ? Math.ceil(c * n) : Math.floor(c * n)) / n;
71
+ };
72
+ }
73
+ built.set(easing, made);
74
+ return made;
75
+ }
@@ -0,0 +1,6 @@
1
+ export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
2
+ export { mix, mixer } from './mixer.js';
3
+ export type { KeysOptions, PatchOptions } from './patch.js';
4
+ export { keys, patch } from './patch.js';
5
+ export { gate, lag, level, peak, slew } from './signals.js';
6
+ export type { Channel, Easing, Engine, FadeOptions, FadeSpec, Handle, Keyframe, Kit, Mix, MixOptions, Patch, Sent, Setting, Signal, VoiceSpec, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
2
+ export { mix, mixer } from './mixer.js';
3
+ export { keys, patch } from './patch.js';
4
+ export { gate, lag, level, peak, slew } from './signals.js';
@@ -0,0 +1,13 @@
1
+ import type { Engine, Kit, Mix, MixOptions } from './types.js';
2
+ /**
3
+ * The one that ships: both forms on the CPU, probed per subject on demand.
4
+ *
5
+ * @category engine
6
+ */
7
+ export declare const mixer: Engine;
8
+ /**
9
+ * Makes a mix over a kit, on the engine the options name or the stock one.
10
+ *
11
+ * @category mix
12
+ */
13
+ export declare function mix<I, O>(kit: Kit<O>, opts?: MixOptions): Mix<I, O>;