@kiosko/core 0.1.0 → 0.2.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/src/runtime.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  import { EventEmitter } from 'node:events';
13
13
  import { themeById, validate, type Theme } from '@kiosko/sdk';
14
14
  import type { Canvas } from '@napi-rs/canvas';
15
- import { composePage, composeWidget, rotateCanvas, type ComposeResult, type TileCache } from './compositor.ts';
15
+ import { AnimationScheduler, MAX_CONCURRENT, type PlayablePlan } from './animation.ts';
16
+ import { composePage, composeWidget, rotateCanvas, type Animatable, type ComposeResult, type TileCache } from './compositor.ts';
16
17
  import { isWorking, whenMatches, widgetsOf, type Config, type Page, type Screen, type WidgetInstance } from './config/model.ts';
17
18
  import { FetchHost, SourceHub } from './data.ts';
18
19
  import { encodeFor, openDevice, type Device, type DeviceStatus } from './devices.ts';
@@ -38,6 +39,8 @@ export interface FrameInfo {
38
39
  image: () => Buffer;
39
40
  tiles: ComposeResult['tiles'];
40
41
  interlude: string | null;
42
+ /** Type of the widget whose animation is playing (a frame of it), if any. */
43
+ animation?: string;
41
44
  sleeping: boolean;
42
45
  }
43
46
 
@@ -52,6 +55,30 @@ function encode(c: Canvas, kind: 'png' | 'jpeg', quality: number): Buffer {
52
55
  return kind === 'jpeg' ? c.toBuffer('image/jpeg', Math.max(1, Math.min(100, quality || 90))) : c.toBuffer('image/png');
53
56
  }
54
57
 
58
+ /** Lowest JPEG quality `encodeFit` goes down to. */
59
+ const MIN_FIT_QUALITY = 20;
60
+
61
+ /**
62
+ * Encodes a frame no bigger than `maxBytes`: when the JPEG at `quality` is
63
+ * over it, tries lower qualities (10 at a time, down to 20) until one fits.
64
+ * LY panels drop frames over ~500 KB and keep showing the previous one, so a
65
+ * photo-heavy page at quality 100 looked frozen. `over` says nothing fitted.
66
+ */
67
+ export function encodeFit(c: Canvas, kind: 'png' | 'jpeg', quality: number, maxBytes: number): { out: Buffer; quality: number; over: boolean } {
68
+ const q0 = Math.max(1, Math.min(100, quality || 90));
69
+ let out = encode(c, kind, q0);
70
+ if (kind !== 'jpeg' || !(maxBytes > 0) || out.length <= maxBytes) return { out, quality: q0, over: false };
71
+ let q = q0;
72
+ while (q > MIN_FIT_QUALITY) {
73
+ q = Math.max(MIN_FIT_QUALITY, q - 10);
74
+ out = encode(c, kind, q);
75
+ if (out.length <= maxBytes) return { out, quality: q, over: false };
76
+ }
77
+ return { out, quality: q, over: true };
78
+ }
79
+
80
+ const kb = (n: number) => `${Math.round(n / 1000)} KB`;
81
+
55
82
  class ScreenRunner {
56
83
  device: Device;
57
84
  private deviceKey: string;
@@ -63,12 +90,20 @@ class ScreenRunner {
63
90
  pinned: string | null = null;
64
91
  private playedSlot = new Map<string, number>();
65
92
  interlude: { slotId: string; type: string; stopAt: number } | null = null;
93
+ /** When `fit` last told the log that frames were shrunk, to say it now and then, not every frame. */
94
+ private fitLoggedAt = 0;
66
95
  /** Instance ids on the current page, to know if a data change concerns us. */
67
96
  visibleIds = new Set<string>();
68
97
  sourceTypes = new Set<string>();
69
98
  page: Page | null = null;
70
99
  /** Rendered tiles reused between frames (see compositor's TileCache). */
71
100
  private tiles: TileCache = new Map();
101
+ /** The widgets of the last painted page that can animate, and when to ask them (see animation.ts). */
102
+ private animatable: Animatable[] = [];
103
+ private scheduler: AnimationScheduler;
104
+ /** Set while a widget's animation plays: its widget type. */
105
+ animating: string | null = null;
106
+ private closed = false;
72
107
 
73
108
  screen: Screen;
74
109
  private readonly rt: Runtime;
@@ -76,15 +111,31 @@ class ScreenRunner {
76
111
  this.screen = screen;
77
112
  this.rt = rt;
78
113
  this.deviceKey = JSON.stringify(screen.device);
79
- this.device = openDevice(screen.device, (m) => rt.log(`screen:${screen.name}`, m));
114
+ this.device = openDevice(screen.device, (m) => rt.log(`screen:${screen.name}`, m), screen.rotate);
80
115
  this.pinned = screen.pinnedPage ?? null;
116
+ this.scheduler = new AnimationScheduler(() => rt.rnd(), (type, why) => rt.log(type, `animation ignored: ${why}`));
117
+ }
118
+
119
+ /** Encodes a frame within the screen's `maxBytes`, saying so in the log (at most every 10 min) when it had to shrink it. */
120
+ private fit(c: Canvas, kind: 'png' | 'jpeg', quality: number, screen: Screen): Buffer {
121
+ const r = encodeFit(c, kind, quality, screen.maxBytes);
122
+ if ((r.quality !== Math.max(1, Math.min(100, quality || 90)) || r.over) && Date.now() - this.fitLoggedAt > 10 * 60_000) {
123
+ this.fitLoggedAt = Date.now();
124
+ this.rt.log(
125
+ `screen:${screen.name}`,
126
+ r.over
127
+ ? `frames don't fit in ${kb(screen.maxBytes)} even at JPEG quality ${r.quality} (${kb(r.out.length)}); the panel may drop them`
128
+ : `frames over ${kb(screen.maxBytes)} at quality ${quality}: sent at quality ${r.quality} (${kb(r.out.length)})`,
129
+ );
130
+ }
131
+ return r.out;
81
132
  }
82
133
 
83
134
  reconfigure(screen: Screen): void {
84
135
  const key = JSON.stringify(screen.device);
85
136
  if (key !== this.deviceKey) {
86
137
  this.device.close();
87
- this.device = openDevice(screen.device, (m) => this.rt.log(`screen:${screen.name}`, m));
138
+ this.device = openDevice(screen.device, (m) => this.rt.log(`screen:${screen.name}`, m), screen.rotate);
88
139
  this.deviceKey = key;
89
140
  this.last = null;
90
141
  }
@@ -111,9 +162,13 @@ class ScreenRunner {
111
162
  this.dirty = true;
112
163
  this.rt.refreshWanted();
113
164
  }
114
- if (!this.dirty && now < this.nextAt) return;
165
+ if (!this.dirty && now < this.nextAt) {
166
+ await this.maybeAnimate(now);
167
+ return;
168
+ }
115
169
  this.dirty = false;
116
170
  await this.paint(page, now);
171
+ await this.maybeAnimate(now);
117
172
  } catch (err) {
118
173
  this.rt.log(`screen:${this.screen.name}`, `paint failed: ${err instanceof Error ? err.message : String(err)}`);
119
174
  } finally {
@@ -142,6 +197,7 @@ class ScreenRunner {
142
197
  cache: this.tiles,
143
198
  });
144
199
  canvas = result.canvas;
200
+ this.animatable = result.animatable;
145
201
  this.nextAt = Math.min(result.nextAt, now + 60_000);
146
202
  // Pages and work hours change by the clock: check at least every minute.
147
203
  } else {
@@ -149,6 +205,7 @@ class ScreenRunner {
149
205
  const g = canvas.getContext('2d');
150
206
  g.fillStyle = '#000';
151
207
  g.fillRect(0, 0, screen.width, screen.height);
208
+ this.animatable = [];
152
209
  this.nextAt = now + 60_000;
153
210
  }
154
211
  const sleeping = !!(screen.sleepWhenIdle && result?.idle);
@@ -158,7 +215,7 @@ class ScreenRunner {
158
215
  g.fillRect(0, 0, screen.width, screen.height);
159
216
  }
160
217
  const kind = encodeFor(screen.device, screen.jpegQuality);
161
- const out = encode(rotateCanvas(canvas, screen.rotate), kind, screen.jpegQuality);
218
+ const out = this.fit(rotateCanvas(canvas, screen.rotate), kind, screen.jpegQuality, screen);
162
219
  this.lastInfo = { screenId: screen.id, pageId: page?.id ?? null, at: now, image: lazyJpeg(canvas), tiles: result?.tiles ?? [], interlude: null, sleeping };
163
220
  this.rt.emit('frame', this.lastInfo);
164
221
  if (this.last?.equals(out)) return;
@@ -207,7 +264,7 @@ class ScreenRunner {
207
264
  } finally {
208
265
  g.restore();
209
266
  }
210
- const out = encode(rotateCanvas(canvas, this.screen.rotate), kind, this.screen.jpegQuality || 85);
267
+ const out = this.fit(rotateCanvas(canvas, this.screen.rotate), kind, this.screen.jpegQuality || 85, this.screen);
211
268
  await this.device.send(out);
212
269
  if (i % 3 === 0) {
213
270
  // The interlude draws every frame on the same canvas: snapshot it.
@@ -225,6 +282,124 @@ class ScreenRunner {
225
282
  return true;
226
283
  }
227
284
 
285
+ /**
286
+ * Plays the animations the shown widgets ask for, if any is due (see
287
+ * animation.ts). Like an interlude it runs inside `update`, so nothing else
288
+ * is painted meanwhile, but unlike it the page stays in place: only the
289
+ * widgets' tiles change from frame to frame. How many play together is
290
+ * `Runtime.maxConcurrent` (one by default); the rest wait their turn.
291
+ */
292
+ private async maybeAnimate(now: number): Promise<void> {
293
+ if (!this.screen.enabled || this.closed || this.lastInfo?.sleeping || !this.animatable.length) return;
294
+ const due = this.scheduler.next(this.animatable, now, this.screen.maxFps, this.rt.maxConcurrent);
295
+ if (!due.length) return;
296
+ this.animating = due.map((d) => d.animatable.type).join(', ');
297
+ let completed = false;
298
+ try {
299
+ completed = await this.playAnimations(due, now);
300
+ } finally {
301
+ this.animating = null;
302
+ // Whatever happened, the next paint is a real one with the current data.
303
+ this.last = null;
304
+ this.dirty = true;
305
+ for (const d of due) {
306
+ this.scheduler.ended(d.animatable.instanceId, Date.now(), d.animatable);
307
+ d.animatable.ended(d.play.plan, completed);
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * One timeline for all: the fastest plan sets the rate and each animation
314
+ * advances at its own, so they start together and the longest decides when
315
+ * it is over. The ones that finish earlier stay at rest. With only one it
316
+ * is just that animation's own frames.
317
+ */
318
+ private async playAnimations(items: { animatable: Animatable; play: PlayablePlan }[], now: number): Promise<boolean> {
319
+ const page = this.page;
320
+ if (!page) return false;
321
+ const { screen } = this;
322
+ const fps = Math.max(...items.map((x) => x.play.fps));
323
+ const seconds = Math.max(...items.map((x) => (x.play.frames - 1) / x.play.fps));
324
+ const frames = Math.round(seconds * fps) + 1;
325
+ // Cut short by new data only if every one of them can be (an urgent one is not).
326
+ const interruptible = items.every((x) => x.play.interruptible);
327
+ // A hold has a meaning for a single animation: with several it is ignored.
328
+ const hold = items.length === 1 ? items[0]!.play.hold : undefined;
329
+ const abort = () => this.closed || (interruptible && this.dirty) || !this.screen.enabled;
330
+ this.dirty = false;
331
+ // The page as it was at the start: same clock, same data for every frame, so
332
+ // only the animated tiles move (a changing clock would flicker) and, if it
333
+ // can't be cut short, a data change during it doesn't leak in.
334
+ const memoData = new Map<string, unknown>();
335
+ const memoSources = new Map<string, Record<string, unknown>>();
336
+ const working = this.rt.working(now);
337
+ const kind = encodeFor(screen.device, screen.jpegQuality || 85);
338
+ const sent: { out: Buffer; snap: Canvas | null }[] = [];
339
+ const t0 = Date.now();
340
+ // Every frame is painted before the first is sent: painting and sending
341
+ // at once is slower than either, and the panel drops what comes late.
342
+ for (let s = 1; s < frames - 1; s++) {
343
+ if (abort()) return false;
344
+ const animate = items.flatMap(({ animatable: a, play }) => {
345
+ // Frame of this animation at this moment of the timeline; at either end it is at rest.
346
+ const i = Math.round((s / fps) * play.fps);
347
+ return i <= 0 || i >= play.frames - 1 ? [] : [{ tileId: a.tileId, instanceId: a.instanceId, frame: { plan: play.plan, i, t: i / (play.frames - 1) } }];
348
+ });
349
+ const result = composePage({
350
+ cfg: this.rt.config,
351
+ screen,
352
+ page,
353
+ theme: this.rt.theme(),
354
+ registry: this.rt.registry,
355
+ now,
356
+ working,
357
+ dataOf: (inst) => {
358
+ if (!memoData.has(inst.id)) memoData.set(inst.id, this.rt.fetches.data(inst.id));
359
+ return memoData.get(inst.id);
360
+ },
361
+ sourcesOf: (types) => {
362
+ const k = JSON.stringify(types ?? []);
363
+ if (!memoSources.has(k)) memoSources.set(k, this.rt.hub.pick(types));
364
+ return memoSources.get(k)!;
365
+ },
366
+ log: this.rt.log,
367
+ demo: this.rt.demoMode,
368
+ cache: this.tiles,
369
+ animate,
370
+ });
371
+ sent.push({ out: this.fit(rotateCanvas(result.canvas, screen.rotate), kind, screen.jpegQuality || 85, screen), snap: s % 3 === 1 ? result.canvas : null });
372
+ // Let the loop breathe: a long animation must not freeze the timers and the other screens.
373
+ await new Promise((r) => setImmediate(r));
374
+ }
375
+ const what = items.map((x) => `${x.animatable.type} ${x.play.plan.id}`).join(' + ');
376
+ this.rt.log(`screen:${screen.name}`, `animating ${what}: ${sent.length} frames at ${fps} fps (painted in ${Date.now() - t0} ms)`);
377
+ const holdAt = hold ? Math.max(0, Math.min(sent.length - 1, hold.index - 1)) : -1;
378
+ let start = Date.now();
379
+ for (let k = 0; k < sent.length; k++) {
380
+ if (abort()) return false;
381
+ // Late: skip the frame rather than let the delay pile up.
382
+ const due = Math.floor(((Date.now() - start) * fps) / 1000);
383
+ if (k < due - 1 && k !== holdAt && k < sent.length - 1) continue;
384
+ await this.device.send(sent[k]!.out);
385
+ const snap = sent[k]!.snap;
386
+ if (snap) {
387
+ this.lastInfo = { screenId: screen.id, pageId: page.id, at: Date.now(), image: lazyJpeg(snap), tiles: [], interlude: null, animation: items.map((x) => x.animatable.type).join(', '), sleeping: false };
388
+ this.rt.emit('frame', this.lastInfo);
389
+ }
390
+ if (hold && k === holdAt) {
391
+ // Still: the panel keeps the picture without being sent it again.
392
+ const until = Date.now() + hold.ms;
393
+ while (Date.now() < until && !abort()) await new Promise((r) => setTimeout(r, Math.min(250, until - Date.now())));
394
+ start = Date.now() - ((k + 1) * 1000) / fps;
395
+ continue;
396
+ }
397
+ const wait = start + ((k + 1) * 1000) / fps - Date.now();
398
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
399
+ }
400
+ return true;
401
+ }
402
+
228
403
  async playNow(slotId: string): Promise<void> {
229
404
  if (this.busy) return;
230
405
  this.busy = true;
@@ -240,6 +415,7 @@ class ScreenRunner {
240
415
  }
241
416
 
242
417
  close(): void {
418
+ this.closed = true;
243
419
  this.interlude = null;
244
420
  this.device.close();
245
421
  }
@@ -252,6 +428,10 @@ export class Runtime extends EventEmitter {
252
428
  private runners = new Map<string, ScreenRunner>();
253
429
  private timer: NodeJS.Timeout | null = null;
254
430
  readonly logs: LogEntry[] = [];
431
+ /** How many animations play together on a screen (see animation.ts). */
432
+ maxConcurrent: number = MAX_CONCURRENT;
433
+ /** Random numbers for the animations; tests replace it with a seeded one. */
434
+ rnd: () => number = Math.random;
255
435
 
256
436
  readonly registry: Registry;
257
437
  /**
@@ -394,6 +574,7 @@ export class Runtime extends EventEmitter {
394
574
  page: r.page?.id ?? null,
395
575
  pinned: r.pinned,
396
576
  interlude: r.interlude?.type ?? null,
577
+ animation: r.animating,
397
578
  device: r.device.status as DeviceStatus,
398
579
  lastFrameAt: r.lastInfo?.at ?? null,
399
580
  sleeping: r.lastInfo?.sleeping ?? false,