@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/dist/animation.d.ts +60 -0
- package/dist/animation.js +111 -0
- package/dist/compositor.d.ts +42 -1
- package/dist/compositor.js +44 -14
- package/dist/config/diff.js +1 -1
- package/dist/config/model.d.ts +14 -0
- package/dist/config/model.js +3 -0
- package/dist/config/normalize.js +2 -1
- package/dist/config/schema.js +13 -0
- package/dist/config/store.js +6 -9
- package/dist/devices.d.ts +69 -1
- package/dist/devices.js +179 -2
- package/dist/env.js +16 -0
- package/dist/fsperm.d.ts +7 -0
- package/dist/fsperm.js +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/paths.d.ts +10 -3
- package/dist/paths.js +16 -6
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +187 -5
- package/package.json +5 -4
- package/src/animation.ts +140 -0
- package/src/compositor.ts +71 -15
- package/src/config/diff.ts +1 -1
- package/src/config/model.ts +12 -0
- package/src/config/normalize.ts +2 -1
- package/src/config/schema.ts +13 -0
- package/src/config/store.ts +6 -8
- package/src/devices.ts +177 -2
- package/src/env.ts +14 -0
- package/src/fsperm.ts +23 -0
- package/src/index.ts +2 -0
- package/src/paths.ts +15 -6
- package/src/runtime.ts +187 -6
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 {
|
|
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)
|
|
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 =
|
|
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 =
|
|
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,
|