@msb235/blits 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ This package follows [semver](https://semver.org). Below 1.0.0, a breaking chang
4
4
  version and everything else the patch. Each release lists its changes as **Breaking**, **Added** and
5
5
  **Fixed**, and the release workflow refuses a tag with no section here.
6
6
 
7
+ ## 0.8.1
8
+
9
+ ### Fixed
10
+
11
+ - A frame under `history` costs what it did in 0.7.1 again. 0.8.0 added two costs to every frame:
12
+ a sync shifted the whole list of frames history can reach to let go of the oldest, 7,200 of
13
+ them at 60 fps under the default 120 s; and an object a voice gave a channel with no `rest` to
14
+ fold into, such as `last<Row>()`, was copied for every subject on every frame. The frames now
15
+ leave from the front without a shift, and such a channel's pose holds the patch's own object, as
16
+ in 0.7.1; an array is still copied. astv's motion bench on one machine, mean µs a frame over
17
+ two runs, 0.7.1, 0.8.0, then this: one window landing 6 text changes of 5 rows, 19.6, 28.8,
18
+ 19.5; its busy stage, 354.6, 402.2, 340.7; 10 flights a second, 6.4, 8.0, 6.6.
19
+
7
20
  ## 0.8.0
8
21
 
9
22
  ### Breaking
package/README.md CHANGED
@@ -118,7 +118,7 @@ so the engine keeps no dependencies; its README says how it works.
118
118
 
119
119
  ## Status
120
120
 
121
- v0.8.0, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
121
+ v0.8.1, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
122
122
  transitions, weasel its animator's tweens, sherpa its seams, and
123
123
  [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
124
124
  part effects, and the environment a sign is lit by — on a mix each.
package/dist/clone.d.ts CHANGED
@@ -6,5 +6,10 @@
6
6
  export declare function clone<T>(v: T): T;
7
7
  /** A copy a fold may write into: arrays, typed arrays and plain objects copied, the rest as is. */
8
8
  export declare function copy(v: unknown): unknown;
9
+ /**
10
+ * A value that starts a channel no fold writes into: only `merged` touches it after, and that
11
+ * writes into arrays alone, so an object is the patch's own, not a copy made each frame.
12
+ */
13
+ export declare function first(v: unknown): unknown;
9
14
  /** Whether `copy` can copy `v` faithfully, when it is an object. */
10
15
  export declare function copyable(v: object): boolean;
package/dist/clone.js CHANGED
@@ -62,6 +62,13 @@ export function copy(v) {
62
62
  return v.slice();
63
63
  return plainObject(v) ? clone(v) : v;
64
64
  }
65
+ /**
66
+ * A value that starts a channel no fold writes into: only `merged` touches it after, and that
67
+ * writes into arrays alone, so an object is the patch's own, not a copy made each frame.
68
+ */
69
+ export function first(v) {
70
+ return Array.isArray(v) ? [...v] : v;
71
+ }
65
72
  /** Whether `copy` can copy `v` faithfully, when it is an object. */
66
73
  export function copyable(v) {
67
74
  return Array.isArray(v) || ArrayBuffer.isView(v) || plainObject(v);
package/dist/fold.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { frozenTime, passAt, phaseAt, silent } from './clock.js';
2
- import { copy as copyValue } from './clone.js';
2
+ import { copy as copyValue, first } from './clone.js';
3
3
  import { open as openRecord, shut as shutRecord, weighAt } from './everyone.js';
4
4
  import { recordHost, remember } from './history.js';
5
5
  import { stateful } from './hosts.js';
@@ -298,7 +298,7 @@ export function apply(pose, voice, held, delta, weight) {
298
298
  const on = this.passes(band === 0 ? undefined : band === 1, weight);
299
299
  bands[i] = on ? 1 : 2;
300
300
  if (on)
301
- pose[key] = pose[key] === undefined ? copy(value) : merged(channel, pose[key], value);
301
+ pose[key] = pose[key] === undefined ? first(value) : merged(channel, pose[key], value);
302
302
  }
303
303
  }
304
304
  /** Clamps every bounded channel of a folded pose into its range, in place. */
@@ -617,7 +617,7 @@ export function foldLoci(subject, pose, head, laned, dry, except, owed) {
617
617
  const passes = this.passes(band === 0 ? undefined : band === 1, w);
618
618
  on[slot] = passes ? 1 : 2;
619
619
  if (passes)
620
- pose[key] = pose[key] === undefined ? copy(value) : merged(channel, pose[key], value);
620
+ pose[key] = pose[key] === undefined ? first(value) : merged(channel, pose[key], value);
621
621
  }
622
622
  }
623
623
  return pose;
@@ -0,0 +1,21 @@
1
+ import type { Frame } from './transport.js';
2
+ /**
3
+ * The frames a transport's history can still reach, oldest first. A frame leaves the front on
4
+ * most syncs, so the front is an index that moves and the array closes up only now and then.
5
+ */
6
+ export declare class Frames {
7
+ private list;
8
+ private head;
9
+ get length(): number;
10
+ push(frame: Frame): void;
11
+ /** Lets go of every frame before the last one at or before `reach`, giving each to `each`. */
12
+ shed(reach: number, each?: (frame: Frame) => void): void;
13
+ /**
14
+ * The frame a seek or read to mix time `t` lands on: the last one standing at or before `t`,
15
+ * since rate 0 can hold the clock at `t` for many.
16
+ */
17
+ at(t: number): Frame | undefined;
18
+ /** Every frame held, oldest first. */
19
+ all(): Frame[];
20
+ set(frames: Frame[]): void;
21
+ }
package/dist/frames.js ADDED
@@ -0,0 +1,59 @@
1
+ /** Past this many frames let go of at the front, the list is closed up, once they are half of it. */
2
+ const SLACK = 512;
3
+ /**
4
+ * The frames a transport's history can still reach, oldest first. A frame leaves the front on
5
+ * most syncs, so the front is an index that moves and the array closes up only now and then.
6
+ */
7
+ export class Frames {
8
+ list = [];
9
+ head = 0;
10
+ get length() {
11
+ return this.list.length - this.head;
12
+ }
13
+ push(frame) {
14
+ this.list.push(frame);
15
+ }
16
+ /** Lets go of every frame before the last one at or before `reach`, giving each to `each`. */
17
+ shed(reach, each) {
18
+ const list = this.list;
19
+ let head = this.head;
20
+ while (head + 1 < list.length && list[head + 1].at <= reach) {
21
+ each?.(list[head]);
22
+ head++;
23
+ }
24
+ if (head >= SLACK && head * 2 >= list.length) {
25
+ list.splice(0, head);
26
+ head = 0;
27
+ }
28
+ this.head = head;
29
+ }
30
+ /**
31
+ * The frame a seek or read to mix time `t` lands on: the last one standing at or before `t`,
32
+ * since rate 0 can hold the clock at `t` for many.
33
+ */
34
+ at(t) {
35
+ const list = this.list;
36
+ let lo = this.head;
37
+ let hi = list.length - 1;
38
+ let found;
39
+ while (lo <= hi) {
40
+ const mid = (lo + hi) >> 1;
41
+ const f = list[mid];
42
+ if (f.at <= t) {
43
+ found = f;
44
+ lo = mid + 1;
45
+ }
46
+ else
47
+ hi = mid - 1;
48
+ }
49
+ return found;
50
+ }
51
+ /** Every frame held, oldest first. */
52
+ all() {
53
+ return this.list.slice(this.head);
54
+ }
55
+ set(frames) {
56
+ this.list = frames;
57
+ this.head = 0;
58
+ }
59
+ }
package/dist/locus.js CHANGED
@@ -1,4 +1,4 @@
1
- import { copy } from './clone.js';
1
+ import { first } from './clone.js';
2
2
  export function locusScratch(channels) {
3
3
  return {
4
4
  n: 0,
@@ -70,7 +70,7 @@ export function foldLocus(k, group) {
70
70
  k.values[slot] =
71
71
  this.lerpsInto[slot] !== undefined && channel.scale && Array.isArray(value)
72
72
  ? own(k, slot, value, channel.rest)
73
- : copy(value);
73
+ : first(value);
74
74
  continue;
75
75
  }
76
76
  const total = taken[slot] + weight;
package/dist/paging.js CHANGED
@@ -110,12 +110,7 @@ export function pageTransport(transport) {
110
110
  return;
111
111
  const reach = transport.now - history.ms;
112
112
  const records = [];
113
- const frames = transport.frames;
114
- let n = 0;
115
- while (n + 1 < frames.length && frames[n + 1].at <= reach)
116
- n++;
117
- for (const f of frames.splice(0, n))
118
- records.push({ mix: '', stream: 'frame', at: f.at, seq: f.seq, data: f.u });
113
+ transport.frames.shed(reach, (f) => records.push({ mix: '', stream: 'frame', at: f.at, seq: f.seq, data: f.u }));
119
114
  const pace = transport.pace;
120
115
  const timeOf = (u) => (pace === null ? u : pace.reading(u));
121
116
  if (transport.announced.some((a) => timeOf(a.at) < reach)) {
package/dist/project.js CHANGED
@@ -31,7 +31,7 @@ export function projectAll(transport, t) {
31
31
  const kept = transport.history !== undefined;
32
32
  if (!ahead && kept) {
33
33
  cover(transport, t);
34
- const frame = transport.frameAt(t);
34
+ const frame = transport.frames.at(t);
35
35
  if (frame !== undefined) {
36
36
  cut = { seq: frame.seq, strict: frame.at === t };
37
37
  if (frame.at === t)
package/dist/seek.js CHANGED
@@ -155,7 +155,7 @@ export function seek(transport, t) {
155
155
  move(m, t);
156
156
  }
157
157
  else {
158
- const frame = transport.frameAt(t);
158
+ const frame = transport.frames.at(t);
159
159
  if (frame === undefined)
160
160
  throw new Error(`blits: ${t} is older than this mix's history reaches`);
161
161
  // On the frame itself, its own host time: rate 0 may have held the clock there for several.
@@ -165,7 +165,7 @@ export function seek(transport, t) {
165
165
  transport.undrop(seq);
166
166
  // What history kept after the frame is let go; the tape holds the calls that made it.
167
167
  transport.announced = transport.announced.filter((a) => a.seq <= seq);
168
- transport.frames = transport.frames.filter((f) => f.seq <= seq);
168
+ transport.frames.set(transport.frames.all().filter((f) => f.seq <= seq));
169
169
  if (transport.pacedSeq > seq) {
170
170
  transport.pace = null;
171
171
  transport.pacedSeq = Number.POSITIVE_INFINITY;
package/dist/stack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { copy } from './clone.js';
1
+ import { copy, first } from './clone.js';
2
2
  import { bound, merged } from './fold.js';
3
3
  /** The weight a mix's default band first switches a rest-less channel on at. */
4
4
  const ON = 0.6;
@@ -35,7 +35,7 @@ export function fold(kit, deltas, weights) {
35
35
  ? channel.fold(pose[key], value, w)
36
36
  : merged(channel, pose[key], channel.scale(value, w));
37
37
  else if (w >= ON)
38
- pose[key] = pose[key] === undefined ? copy(value) : merged(channel, pose[key], value);
38
+ pose[key] = pose[key] === undefined ? first(value) : merged(channel, pose[key], value);
39
39
  }
40
40
  }
41
41
  for (const key of names) {
@@ -1,3 +1,4 @@
1
+ import { Frames } from './frames.js';
1
2
  import type { Mixer } from './mixer.js';
2
3
  import { Pace } from './pace.js';
3
4
  import { Pager } from './paging.js';
@@ -68,7 +69,7 @@ export declare class Transport implements TransportApi {
68
69
  */
69
70
  seq: number;
70
71
  /** Under history, the frames a seek or read back may reach, oldest first. */
71
- frames: Frame[];
72
+ frames: Frames;
72
73
  /** Host time at the last sync: the host's timestamp less every gap `rebase` took out. */
73
74
  u: number;
74
75
  offset: number;
@@ -131,11 +132,6 @@ export declare class Transport implements TransportApi {
131
132
  private syncAt;
132
133
  /** Begins a frame at mix time `at` and the host time the transport stands at. */
133
134
  tick(at: number): void;
134
- /**
135
- * The frame a seek or read to mix time `t` lands on: the last one standing at or before `t`,
136
- * since rate 0 can hold the clock at `t` for many.
137
- */
138
- frameAt(t: number): Frame | undefined;
139
135
  rebase(): void;
140
136
  /**
141
137
  * Moves `floor` past what the mix clock standing at `now` has let go of. With a store nothing is
package/dist/transport.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Frames } from './frames.js';
1
2
  import { listedAll } from './marks.js';
2
3
  import { move, nextFrame, waits } from './move.js';
3
4
  import { Pace } from './pace.js';
@@ -39,7 +40,7 @@ export class Transport {
39
40
  */
40
41
  seq = 0;
41
42
  /** Under history, the frames a seek or read back may reach, oldest first. */
42
- frames = [];
43
+ frames = new Frames();
43
44
  /** Host time at the last sync: the host's timestamp less every gap `rebase` took out. */
44
45
  u = Number.NaN;
45
46
  offset = 0;
@@ -220,12 +221,7 @@ export class Transport {
220
221
  }
221
222
  if (history !== undefined && this.dropped.length > 0)
222
223
  this.dropped = this.dropped.filter((d) => d.at >= reach);
223
- const frames = this.frames;
224
- let n = 0;
225
- while (n + 1 < frames.length && frames[n + 1].at <= reach)
226
- n++;
227
- if (n > 0)
228
- frames.splice(0, n);
224
+ this.frames.shed(reach);
229
225
  pageTransport(this);
230
226
  this.woken = false;
231
227
  // It is a frame too, which asks every weight signal again, so one reading input follows it.
@@ -249,27 +245,6 @@ export class Transport {
249
245
  for (const m of this.members)
250
246
  m.looked = false;
251
247
  }
252
- /**
253
- * The frame a seek or read to mix time `t` lands on: the last one standing at or before `t`,
254
- * since rate 0 can hold the clock at `t` for many.
255
- */
256
- frameAt(t) {
257
- const frames = this.frames;
258
- let lo = 0;
259
- let hi = frames.length - 1;
260
- let found;
261
- while (lo <= hi) {
262
- const mid = (lo + hi) >> 1;
263
- const f = frames[mid];
264
- if (f.at <= t) {
265
- found = f;
266
- lo = mid + 1;
267
- }
268
- else
269
- hi = mid - 1;
270
- }
271
- return found;
272
- }
273
248
  rebase() {
274
249
  this.rebasing = true;
275
250
  this.settle();
package/dist/unpage.js CHANGED
@@ -69,7 +69,7 @@ function unpageTransport(transport, records) {
69
69
  const frames = own
70
70
  .filter((r) => r.stream === 'frame')
71
71
  .map((r) => ({ seq: r.seq, at: r.at, u: r.data }));
72
- transport.frames = before(frames, transport.frames);
72
+ transport.frames.set(before(frames, transport.frames.all()));
73
73
  const held = new Set(transport.announced.map((a) => a.order));
74
74
  const marks = own
75
75
  .filter((r) => r.stream === 'mark' && !held.has(r.data.order))
package/docs/schema.html CHANGED
@@ -161,7 +161,7 @@
161
161
  <h1>An effects schema, and the API around it</h1>
162
162
  <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>
163
163
  <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>
164
- <span class="status">Built · v0.8.0 · design 2026-09-15, package 2026-09-28</span>
164
+ <span class="status">Built · v0.8.1 · design 2026-09-15, package 2026-09-28</span>
165
165
  </header>
166
166
 
167
167
  <h2 id="picture">In one picture</h2>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",