@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/dist/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { EventEmitter } from 'node:events';
|
|
13
13
|
import { themeById, validate } from '@kiosko/sdk';
|
|
14
|
+
import { AnimationScheduler, MAX_CONCURRENT } from "./animation.js";
|
|
14
15
|
import { composePage, composeWidget, rotateCanvas } from "./compositor.js";
|
|
15
16
|
import { isWorking, whenMatches, widgetsOf } from "./config/model.js";
|
|
16
17
|
import { FetchHost, SourceHub } from "./data.js";
|
|
@@ -25,6 +26,29 @@ function lazyJpeg(c) {
|
|
|
25
26
|
function encode(c, kind, quality) {
|
|
26
27
|
return kind === 'jpeg' ? c.toBuffer('image/jpeg', Math.max(1, Math.min(100, quality || 90))) : c.toBuffer('image/png');
|
|
27
28
|
}
|
|
29
|
+
/** Lowest JPEG quality `encodeFit` goes down to. */
|
|
30
|
+
const MIN_FIT_QUALITY = 20;
|
|
31
|
+
/**
|
|
32
|
+
* Encodes a frame no bigger than `maxBytes`: when the JPEG at `quality` is
|
|
33
|
+
* over it, tries lower qualities (10 at a time, down to 20) until one fits.
|
|
34
|
+
* LY panels drop frames over ~500 KB and keep showing the previous one, so a
|
|
35
|
+
* photo-heavy page at quality 100 looked frozen. `over` says nothing fitted.
|
|
36
|
+
*/
|
|
37
|
+
export function encodeFit(c, kind, quality, maxBytes) {
|
|
38
|
+
const q0 = Math.max(1, Math.min(100, quality || 90));
|
|
39
|
+
let out = encode(c, kind, q0);
|
|
40
|
+
if (kind !== 'jpeg' || !(maxBytes > 0) || out.length <= maxBytes)
|
|
41
|
+
return { out, quality: q0, over: false };
|
|
42
|
+
let q = q0;
|
|
43
|
+
while (q > MIN_FIT_QUALITY) {
|
|
44
|
+
q = Math.max(MIN_FIT_QUALITY, q - 10);
|
|
45
|
+
out = encode(c, kind, q);
|
|
46
|
+
if (out.length <= maxBytes)
|
|
47
|
+
return { out, quality: q, over: false };
|
|
48
|
+
}
|
|
49
|
+
return { out, quality: q, over: true };
|
|
50
|
+
}
|
|
51
|
+
const kb = (n) => `${Math.round(n / 1000)} KB`;
|
|
28
52
|
class ScreenRunner {
|
|
29
53
|
device;
|
|
30
54
|
deviceKey;
|
|
@@ -36,26 +60,46 @@ class ScreenRunner {
|
|
|
36
60
|
pinned = null;
|
|
37
61
|
playedSlot = new Map();
|
|
38
62
|
interlude = null;
|
|
63
|
+
/** When `fit` last told the log that frames were shrunk, to say it now and then, not every frame. */
|
|
64
|
+
fitLoggedAt = 0;
|
|
39
65
|
/** Instance ids on the current page, to know if a data change concerns us. */
|
|
40
66
|
visibleIds = new Set();
|
|
41
67
|
sourceTypes = new Set();
|
|
42
68
|
page = null;
|
|
43
69
|
/** Rendered tiles reused between frames (see compositor's TileCache). */
|
|
44
70
|
tiles = new Map();
|
|
71
|
+
/** The widgets of the last painted page that can animate, and when to ask them (see animation.ts). */
|
|
72
|
+
animatable = [];
|
|
73
|
+
scheduler;
|
|
74
|
+
/** Set while a widget's animation plays: its widget type. */
|
|
75
|
+
animating = null;
|
|
76
|
+
closed = false;
|
|
45
77
|
screen;
|
|
46
78
|
rt;
|
|
47
79
|
constructor(screen, rt) {
|
|
48
80
|
this.screen = screen;
|
|
49
81
|
this.rt = rt;
|
|
50
82
|
this.deviceKey = JSON.stringify(screen.device);
|
|
51
|
-
this.device = openDevice(screen.device, (m) => rt.log(`screen:${screen.name}`, m));
|
|
83
|
+
this.device = openDevice(screen.device, (m) => rt.log(`screen:${screen.name}`, m), screen.rotate);
|
|
52
84
|
this.pinned = screen.pinnedPage ?? null;
|
|
85
|
+
this.scheduler = new AnimationScheduler(() => rt.rnd(), (type, why) => rt.log(type, `animation ignored: ${why}`));
|
|
86
|
+
}
|
|
87
|
+
/** Encodes a frame within the screen's `maxBytes`, saying so in the log (at most every 10 min) when it had to shrink it. */
|
|
88
|
+
fit(c, kind, quality, screen) {
|
|
89
|
+
const r = encodeFit(c, kind, quality, screen.maxBytes);
|
|
90
|
+
if ((r.quality !== Math.max(1, Math.min(100, quality || 90)) || r.over) && Date.now() - this.fitLoggedAt > 10 * 60_000) {
|
|
91
|
+
this.fitLoggedAt = Date.now();
|
|
92
|
+
this.rt.log(`screen:${screen.name}`, r.over
|
|
93
|
+
? `frames don't fit in ${kb(screen.maxBytes)} even at JPEG quality ${r.quality} (${kb(r.out.length)}); the panel may drop them`
|
|
94
|
+
: `frames over ${kb(screen.maxBytes)} at quality ${quality}: sent at quality ${r.quality} (${kb(r.out.length)})`);
|
|
95
|
+
}
|
|
96
|
+
return r.out;
|
|
53
97
|
}
|
|
54
98
|
reconfigure(screen) {
|
|
55
99
|
const key = JSON.stringify(screen.device);
|
|
56
100
|
if (key !== this.deviceKey) {
|
|
57
101
|
this.device.close();
|
|
58
|
-
this.device = openDevice(screen.device, (m) => this.rt.log(`screen:${screen.name}`, m));
|
|
102
|
+
this.device = openDevice(screen.device, (m) => this.rt.log(`screen:${screen.name}`, m), screen.rotate);
|
|
59
103
|
this.deviceKey = key;
|
|
60
104
|
this.last = null;
|
|
61
105
|
}
|
|
@@ -83,10 +127,13 @@ class ScreenRunner {
|
|
|
83
127
|
this.dirty = true;
|
|
84
128
|
this.rt.refreshWanted();
|
|
85
129
|
}
|
|
86
|
-
if (!this.dirty && now < this.nextAt)
|
|
130
|
+
if (!this.dirty && now < this.nextAt) {
|
|
131
|
+
await this.maybeAnimate(now);
|
|
87
132
|
return;
|
|
133
|
+
}
|
|
88
134
|
this.dirty = false;
|
|
89
135
|
await this.paint(page, now);
|
|
136
|
+
await this.maybeAnimate(now);
|
|
90
137
|
}
|
|
91
138
|
catch (err) {
|
|
92
139
|
this.rt.log(`screen:${this.screen.name}`, `paint failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -116,6 +163,7 @@ class ScreenRunner {
|
|
|
116
163
|
cache: this.tiles,
|
|
117
164
|
});
|
|
118
165
|
canvas = result.canvas;
|
|
166
|
+
this.animatable = result.animatable;
|
|
119
167
|
this.nextAt = Math.min(result.nextAt, now + 60_000);
|
|
120
168
|
// Pages and work hours change by the clock: check at least every minute.
|
|
121
169
|
}
|
|
@@ -124,6 +172,7 @@ class ScreenRunner {
|
|
|
124
172
|
const g = canvas.getContext('2d');
|
|
125
173
|
g.fillStyle = '#000';
|
|
126
174
|
g.fillRect(0, 0, screen.width, screen.height);
|
|
175
|
+
this.animatable = [];
|
|
127
176
|
this.nextAt = now + 60_000;
|
|
128
177
|
}
|
|
129
178
|
const sleeping = !!(screen.sleepWhenIdle && result?.idle);
|
|
@@ -133,7 +182,7 @@ class ScreenRunner {
|
|
|
133
182
|
g.fillRect(0, 0, screen.width, screen.height);
|
|
134
183
|
}
|
|
135
184
|
const kind = encodeFor(screen.device, screen.jpegQuality);
|
|
136
|
-
const out =
|
|
185
|
+
const out = this.fit(rotateCanvas(canvas, screen.rotate), kind, screen.jpegQuality, screen);
|
|
137
186
|
this.lastInfo = { screenId: screen.id, pageId: page?.id ?? null, at: now, image: lazyJpeg(canvas), tiles: result?.tiles ?? [], interlude: null, sleeping };
|
|
138
187
|
this.rt.emit('frame', this.lastInfo);
|
|
139
188
|
if (this.last?.equals(out))
|
|
@@ -189,7 +238,7 @@ class ScreenRunner {
|
|
|
189
238
|
finally {
|
|
190
239
|
g.restore();
|
|
191
240
|
}
|
|
192
|
-
const out =
|
|
241
|
+
const out = this.fit(rotateCanvas(canvas, this.screen.rotate), kind, this.screen.jpegQuality || 85, this.screen);
|
|
193
242
|
await this.device.send(out);
|
|
194
243
|
if (i % 3 === 0) {
|
|
195
244
|
// The interlude draws every frame on the same canvas: snapshot it.
|
|
@@ -207,6 +256,133 @@ class ScreenRunner {
|
|
|
207
256
|
this.dirty = true;
|
|
208
257
|
return true;
|
|
209
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Plays the animations the shown widgets ask for, if any is due (see
|
|
261
|
+
* animation.ts). Like an interlude it runs inside `update`, so nothing else
|
|
262
|
+
* is painted meanwhile, but unlike it the page stays in place: only the
|
|
263
|
+
* widgets' tiles change from frame to frame. How many play together is
|
|
264
|
+
* `Runtime.maxConcurrent` (one by default); the rest wait their turn.
|
|
265
|
+
*/
|
|
266
|
+
async maybeAnimate(now) {
|
|
267
|
+
if (!this.screen.enabled || this.closed || this.lastInfo?.sleeping || !this.animatable.length)
|
|
268
|
+
return;
|
|
269
|
+
const due = this.scheduler.next(this.animatable, now, this.screen.maxFps, this.rt.maxConcurrent);
|
|
270
|
+
if (!due.length)
|
|
271
|
+
return;
|
|
272
|
+
this.animating = due.map((d) => d.animatable.type).join(', ');
|
|
273
|
+
let completed = false;
|
|
274
|
+
try {
|
|
275
|
+
completed = await this.playAnimations(due, now);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
this.animating = null;
|
|
279
|
+
// Whatever happened, the next paint is a real one with the current data.
|
|
280
|
+
this.last = null;
|
|
281
|
+
this.dirty = true;
|
|
282
|
+
for (const d of due) {
|
|
283
|
+
this.scheduler.ended(d.animatable.instanceId, Date.now(), d.animatable);
|
|
284
|
+
d.animatable.ended(d.play.plan, completed);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* One timeline for all: the fastest plan sets the rate and each animation
|
|
290
|
+
* advances at its own, so they start together and the longest decides when
|
|
291
|
+
* it is over. The ones that finish earlier stay at rest. With only one it
|
|
292
|
+
* is just that animation's own frames.
|
|
293
|
+
*/
|
|
294
|
+
async playAnimations(items, now) {
|
|
295
|
+
const page = this.page;
|
|
296
|
+
if (!page)
|
|
297
|
+
return false;
|
|
298
|
+
const { screen } = this;
|
|
299
|
+
const fps = Math.max(...items.map((x) => x.play.fps));
|
|
300
|
+
const seconds = Math.max(...items.map((x) => (x.play.frames - 1) / x.play.fps));
|
|
301
|
+
const frames = Math.round(seconds * fps) + 1;
|
|
302
|
+
// Cut short by new data only if every one of them can be (an urgent one is not).
|
|
303
|
+
const interruptible = items.every((x) => x.play.interruptible);
|
|
304
|
+
// A hold has a meaning for a single animation: with several it is ignored.
|
|
305
|
+
const hold = items.length === 1 ? items[0].play.hold : undefined;
|
|
306
|
+
const abort = () => this.closed || (interruptible && this.dirty) || !this.screen.enabled;
|
|
307
|
+
this.dirty = false;
|
|
308
|
+
// The page as it was at the start: same clock, same data for every frame, so
|
|
309
|
+
// only the animated tiles move (a changing clock would flicker) and, if it
|
|
310
|
+
// can't be cut short, a data change during it doesn't leak in.
|
|
311
|
+
const memoData = new Map();
|
|
312
|
+
const memoSources = new Map();
|
|
313
|
+
const working = this.rt.working(now);
|
|
314
|
+
const kind = encodeFor(screen.device, screen.jpegQuality || 85);
|
|
315
|
+
const sent = [];
|
|
316
|
+
const t0 = Date.now();
|
|
317
|
+
// Every frame is painted before the first is sent: painting and sending
|
|
318
|
+
// at once is slower than either, and the panel drops what comes late.
|
|
319
|
+
for (let s = 1; s < frames - 1; s++) {
|
|
320
|
+
if (abort())
|
|
321
|
+
return false;
|
|
322
|
+
const animate = items.flatMap(({ animatable: a, play }) => {
|
|
323
|
+
// Frame of this animation at this moment of the timeline; at either end it is at rest.
|
|
324
|
+
const i = Math.round((s / fps) * play.fps);
|
|
325
|
+
return i <= 0 || i >= play.frames - 1 ? [] : [{ tileId: a.tileId, instanceId: a.instanceId, frame: { plan: play.plan, i, t: i / (play.frames - 1) } }];
|
|
326
|
+
});
|
|
327
|
+
const result = composePage({
|
|
328
|
+
cfg: this.rt.config,
|
|
329
|
+
screen,
|
|
330
|
+
page,
|
|
331
|
+
theme: this.rt.theme(),
|
|
332
|
+
registry: this.rt.registry,
|
|
333
|
+
now,
|
|
334
|
+
working,
|
|
335
|
+
dataOf: (inst) => {
|
|
336
|
+
if (!memoData.has(inst.id))
|
|
337
|
+
memoData.set(inst.id, this.rt.fetches.data(inst.id));
|
|
338
|
+
return memoData.get(inst.id);
|
|
339
|
+
},
|
|
340
|
+
sourcesOf: (types) => {
|
|
341
|
+
const k = JSON.stringify(types ?? []);
|
|
342
|
+
if (!memoSources.has(k))
|
|
343
|
+
memoSources.set(k, this.rt.hub.pick(types));
|
|
344
|
+
return memoSources.get(k);
|
|
345
|
+
},
|
|
346
|
+
log: this.rt.log,
|
|
347
|
+
demo: this.rt.demoMode,
|
|
348
|
+
cache: this.tiles,
|
|
349
|
+
animate,
|
|
350
|
+
});
|
|
351
|
+
sent.push({ out: this.fit(rotateCanvas(result.canvas, screen.rotate), kind, screen.jpegQuality || 85, screen), snap: s % 3 === 1 ? result.canvas : null });
|
|
352
|
+
// Let the loop breathe: a long animation must not freeze the timers and the other screens.
|
|
353
|
+
await new Promise((r) => setImmediate(r));
|
|
354
|
+
}
|
|
355
|
+
const what = items.map((x) => `${x.animatable.type} ${x.play.plan.id}`).join(' + ');
|
|
356
|
+
this.rt.log(`screen:${screen.name}`, `animating ${what}: ${sent.length} frames at ${fps} fps (painted in ${Date.now() - t0} ms)`);
|
|
357
|
+
const holdAt = hold ? Math.max(0, Math.min(sent.length - 1, hold.index - 1)) : -1;
|
|
358
|
+
let start = Date.now();
|
|
359
|
+
for (let k = 0; k < sent.length; k++) {
|
|
360
|
+
if (abort())
|
|
361
|
+
return false;
|
|
362
|
+
// Late: skip the frame rather than let the delay pile up.
|
|
363
|
+
const due = Math.floor(((Date.now() - start) * fps) / 1000);
|
|
364
|
+
if (k < due - 1 && k !== holdAt && k < sent.length - 1)
|
|
365
|
+
continue;
|
|
366
|
+
await this.device.send(sent[k].out);
|
|
367
|
+
const snap = sent[k].snap;
|
|
368
|
+
if (snap) {
|
|
369
|
+
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 };
|
|
370
|
+
this.rt.emit('frame', this.lastInfo);
|
|
371
|
+
}
|
|
372
|
+
if (hold && k === holdAt) {
|
|
373
|
+
// Still: the panel keeps the picture without being sent it again.
|
|
374
|
+
const until = Date.now() + hold.ms;
|
|
375
|
+
while (Date.now() < until && !abort())
|
|
376
|
+
await new Promise((r) => setTimeout(r, Math.min(250, until - Date.now())));
|
|
377
|
+
start = Date.now() - ((k + 1) * 1000) / fps;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const wait = start + ((k + 1) * 1000) / fps - Date.now();
|
|
381
|
+
if (wait > 0)
|
|
382
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
210
386
|
async playNow(slotId) {
|
|
211
387
|
if (this.busy)
|
|
212
388
|
return;
|
|
@@ -222,6 +398,7 @@ class ScreenRunner {
|
|
|
222
398
|
this.interlude = null;
|
|
223
399
|
}
|
|
224
400
|
close() {
|
|
401
|
+
this.closed = true;
|
|
225
402
|
this.interlude = null;
|
|
226
403
|
this.device.close();
|
|
227
404
|
}
|
|
@@ -233,6 +410,10 @@ export class Runtime extends EventEmitter {
|
|
|
233
410
|
runners = new Map();
|
|
234
411
|
timer = null;
|
|
235
412
|
logs = [];
|
|
413
|
+
/** How many animations play together on a screen (see animation.ts). */
|
|
414
|
+
maxConcurrent = MAX_CONCURRENT;
|
|
415
|
+
/** Random numbers for the animations; tests replace it with a seeded one. */
|
|
416
|
+
rnd = Math.random;
|
|
236
417
|
registry;
|
|
237
418
|
/**
|
|
238
419
|
* Demo mode: no source or fetch runs, every widget paints its sample data.
|
|
@@ -374,6 +555,7 @@ export class Runtime extends EventEmitter {
|
|
|
374
555
|
page: r.page?.id ?? null,
|
|
375
556
|
pinned: r.pinned,
|
|
376
557
|
interlude: r.interlude?.type ?? null,
|
|
558
|
+
animation: r.animating,
|
|
377
559
|
device: r.device.status,
|
|
378
560
|
lastFrameAt: r.lastInfo?.at ?? null,
|
|
379
561
|
sleeping: r.lastInfo?.sleeping ?? false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kiosko/core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Kiosko runtime: grid layout engine, plugin host, compositor and panel drivers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@loksly/lcd-canvas-kit": "0.1
|
|
31
|
-
"@loksly/trcc-client": "0.1
|
|
30
|
+
"@loksly/lcd-canvas-kit": "0.2.1",
|
|
31
|
+
"@loksly/trcc-client": "0.2.1",
|
|
32
|
+
"@loksly/usb-ly": "0.2.1",
|
|
32
33
|
"@napi-rs/canvas": "^1.0.10",
|
|
33
|
-
"@kiosko/sdk": "0.1
|
|
34
|
+
"@kiosko/sdk": "0.2.1",
|
|
34
35
|
"dejavu-fonts-ttf": "^2.37.3"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
package/src/animation.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a widget's animation plays. The planning is here, apart from the
|
|
3
|
+
* painting and the sending (runtime.ts), so it can be tested without a
|
|
4
|
+
* screen: which shown widget is asked, and why (`idle`, now and then, or
|
|
5
|
+
* `data`, because what it paints with changed), and what the answer is worth.
|
|
6
|
+
*
|
|
7
|
+
* One animation at a time per screen is the caller's business (it asks
|
|
8
|
+
* only while nothing plays); what is kept here is per widget instance: when
|
|
9
|
+
* it was last asked, when its last animation ended and what it had then.
|
|
10
|
+
*/
|
|
11
|
+
import type { AnimationPlan } from '@kiosko/sdk';
|
|
12
|
+
import type { Animatable } from './compositor.ts';
|
|
13
|
+
|
|
14
|
+
/** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
|
|
15
|
+
export const MAX_FRAMES = 600;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How many animations the runtime plays together on a screen. Widgets don't
|
|
19
|
+
* know: every one asks as if it were alone, and what doesn't fit waits its
|
|
20
|
+
* turn. The whole chain (planner, compositor, player) works for any number;
|
|
21
|
+
* one is what the panel's USB budget is sized for.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CONCURRENT = 1;
|
|
24
|
+
|
|
25
|
+
/** Default time between the `idle` questions of a widget. */
|
|
26
|
+
export const DEFAULT_GAP = { minMs: 20_000, maxMs: 60_000 };
|
|
27
|
+
|
|
28
|
+
/** A plan as the runtime plays it: checked, with the screen's fps ceiling applied. */
|
|
29
|
+
export interface PlayablePlan {
|
|
30
|
+
plan: AnimationPlan;
|
|
31
|
+
frames: number;
|
|
32
|
+
fps: number;
|
|
33
|
+
interruptible: boolean;
|
|
34
|
+
/** Index (into 0..frames-1) where it holds, and for how long. */
|
|
35
|
+
hold?: { index: number; ms: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Why a plan isn't playable, or the plan ready to play. */
|
|
39
|
+
export function checkPlan(plan: unknown, maxFps: number): PlayablePlan | string {
|
|
40
|
+
const p = plan as Partial<AnimationPlan> | null;
|
|
41
|
+
if (!p || typeof p !== 'object') return 'not an object';
|
|
42
|
+
if (typeof p.id !== 'string' || !p.id) return 'no id';
|
|
43
|
+
if (!Number.isInteger(p.frames) || p.frames! < 3) return `frames must be an integer of at least 3 (got ${String(p.frames)})`;
|
|
44
|
+
if (p.frames! > MAX_FRAMES) return `too many frames (${p.frames}, at most ${MAX_FRAMES})`;
|
|
45
|
+
if (typeof p.fps !== 'number' || !(p.fps > 0)) return `fps must be positive (got ${String(p.fps)})`;
|
|
46
|
+
const frames = p.frames!;
|
|
47
|
+
const hold = p.hold && Number.isFinite(p.hold.at) && p.hold.ms > 0 ? { index: Math.max(1, Math.min(frames - 2, Math.round(p.hold.at * (frames - 1)))), ms: p.hold.ms } : undefined;
|
|
48
|
+
return { plan: p as AnimationPlan, frames, fps: Math.max(1, Math.min(p.fps!, maxFps)), interruptible: p.interruptible !== false, ...(hold ? { hold } : {}) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Memory {
|
|
52
|
+
data: unknown;
|
|
53
|
+
sources: Record<string, unknown>;
|
|
54
|
+
nextIdleAt: number;
|
|
55
|
+
endedAt: number;
|
|
56
|
+
/** Since when it has had a question due that was not answered with a plan (Infinity: none). */
|
|
57
|
+
dueSince: number;
|
|
58
|
+
/** A data change that was due but had to wait its turn: what the widget had before it. */
|
|
59
|
+
pending?: { data: unknown; sources: Record<string, unknown> };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class AnimationScheduler {
|
|
63
|
+
private memory = new Map<string, Memory>();
|
|
64
|
+
|
|
65
|
+
private readonly rnd: () => number;
|
|
66
|
+
private readonly onInvalid: (type: string, why: string) => void;
|
|
67
|
+
|
|
68
|
+
constructor(rnd: () => number = Math.random, onInvalid: (type: string, why: string) => void = () => undefined) {
|
|
69
|
+
this.rnd = rnd;
|
|
70
|
+
this.onInvalid = onInvalid;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private gapAfter(a: Animatable, now: number): number {
|
|
74
|
+
const g = a.def.animationGap ?? DEFAULT_GAP;
|
|
75
|
+
const min = Math.max(0, g.minMs), max = Math.max(min, g.maxMs);
|
|
76
|
+
return now + min + this.rnd() * (max - min);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The animations to play now, up to `limit`, among the shown widgets that
|
|
81
|
+
* have one. Call it only while nothing is playing. A widget seen for the
|
|
82
|
+
* first time isn't asked: starting up must not set off a gesture. When more
|
|
83
|
+
* are due than fit, the one that has waited longest goes first (the order
|
|
84
|
+
* of the page doesn't decide), so none starves.
|
|
85
|
+
*/
|
|
86
|
+
next(list: Animatable[], now: number, maxFps: number, limit: number = MAX_CONCURRENT): { animatable: Animatable; play: PlayablePlan }[] {
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
const due: { a: Animatable; m: Memory; trigger: 'idle' | 'data'; prev: { data: unknown; sources: Record<string, unknown> } }[] = [];
|
|
89
|
+
for (const a of list) {
|
|
90
|
+
seen.add(a.instanceId);
|
|
91
|
+
const m = this.memory.get(a.instanceId);
|
|
92
|
+
if (!m) {
|
|
93
|
+
this.memory.set(a.instanceId, { data: a.data, sources: a.sources, nextIdleAt: this.gapAfter(a, now), endedAt: -Infinity, dueSince: Infinity });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const changed = a.data !== m.data || Object.keys(a.sources).some((k) => a.sources[k] !== m.sources[k]);
|
|
97
|
+
const prev = m.pending ?? { data: m.data, sources: m.sources };
|
|
98
|
+
m.data = a.data;
|
|
99
|
+
m.sources = a.sources;
|
|
100
|
+
if ((changed || m.pending) && now - m.endedAt >= (a.def.animationCooldownMs ?? 0)) {
|
|
101
|
+
m.pending = prev;
|
|
102
|
+
due.push({ a, m, trigger: 'data', prev });
|
|
103
|
+
}
|
|
104
|
+
else if (now >= m.nextIdleAt) due.push({ a, m, trigger: 'idle', prev });
|
|
105
|
+
}
|
|
106
|
+
// Forget the instances that are gone (removed, or no longer shown).
|
|
107
|
+
for (const id of this.memory.keys()) if (!seen.has(id)) this.memory.delete(id);
|
|
108
|
+
// Who has waited longest first; a tie keeps the page's order.
|
|
109
|
+
for (const d of due) d.m.dueSince = Math.min(d.m.dueSince, d.trigger === 'data' ? now : d.m.nextIdleAt);
|
|
110
|
+
due.sort((x, y) => x.m.dueSince - y.m.dueSince);
|
|
111
|
+
const out: { animatable: Animatable; play: PlayablePlan }[] = [];
|
|
112
|
+
for (const { a, m, trigger, prev } of due) {
|
|
113
|
+
if (out.length >= limit) break;
|
|
114
|
+
if (trigger === 'idle') m.nextIdleAt = this.gapAfter(a, now);
|
|
115
|
+
else m.pending = undefined;
|
|
116
|
+
const plan = a.ask(trigger, this.rnd, trigger === 'data' ? prev : undefined);
|
|
117
|
+
if (plan === null) {
|
|
118
|
+
m.dueSince = Infinity;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const ok = checkPlan(plan, maxFps);
|
|
122
|
+
if (typeof ok === 'string') {
|
|
123
|
+
this.onInvalid(a.type, ok);
|
|
124
|
+
m.dueSince = Infinity;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
out.push({ animatable: a, play: ok });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
|
|
133
|
+
ended(instanceId: string, now: number, a?: Animatable): void {
|
|
134
|
+
const m = this.memory.get(instanceId);
|
|
135
|
+
if (!m) return;
|
|
136
|
+
m.endedAt = now;
|
|
137
|
+
m.dueSince = Infinity;
|
|
138
|
+
if (a) m.nextIdleAt = Math.max(m.nextIdleAt, this.gapAfter(a, now));
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/compositor.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createCanvas, loadImage, type Canvas, type Image, type SKRSContext2D } from '@napi-rs/canvas';
|
|
8
8
|
import { pixelShift } from '@loksly/lcd-canvas-kit';
|
|
9
|
-
import { localize, validate, type Box, type Redraw, type RenderContext, type Theme, type WidgetDefinition } from '@kiosko/sdk';
|
|
9
|
+
import { localize, validate, type AnimationFrame, type AnimationPlan, type Box, type Redraw, type RenderContext, type Theme, type WidgetDefinition } from '@kiosko/sdk';
|
|
10
10
|
import { toPx, type PxRect } from './layout/grid.ts';
|
|
11
11
|
import { nextTurnAt, pickInStack } from './layout/stack.ts';
|
|
12
12
|
import { whenMatches, zoomOf, type Config, type Page, type Screen, type Tile, type WidgetInstance } from './config/model.ts';
|
|
@@ -39,6 +39,29 @@ export interface ComposeInput {
|
|
|
39
39
|
noShift?: boolean;
|
|
40
40
|
/** Reuse tiles whose inputs didn't change (the panel; not previews). */
|
|
41
41
|
cache?: TileCache;
|
|
42
|
+
/**
|
|
43
|
+
* Paint these frames of animations, each in its own tile (see
|
|
44
|
+
* `AnimationFrame`); every other tile as usual. Any number at once: how
|
|
45
|
+
* many the runtime plays together is its decision (`MAX_CONCURRENT`).
|
|
46
|
+
*/
|
|
47
|
+
animate?: { tileId: string; instanceId: string; frame: AnimationFrame }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A widget on screen that has an `animation` hook: asks it, with the state
|
|
52
|
+
* the page was painted with. The runtime decides when to ask.
|
|
53
|
+
*/
|
|
54
|
+
export interface Animatable {
|
|
55
|
+
tileId: string;
|
|
56
|
+
instanceId: string;
|
|
57
|
+
type: string;
|
|
58
|
+
def: Pick<WidgetDefinition<any, any>, 'animationGap' | 'animationCooldownMs'>;
|
|
59
|
+
/** What the widget painted with, by reference (replaced, never mutated), to tell when it changed. */
|
|
60
|
+
data: unknown;
|
|
61
|
+
sources: Record<string, unknown>;
|
|
62
|
+
ask: (trigger: 'idle' | 'data', rnd: () => number, prev?: { data: unknown; sources: Record<string, unknown> }) => AnimationPlan | null;
|
|
63
|
+
/** Tells the widget an animation of its has ended (see `WidgetDefinition.animationEnded`). */
|
|
64
|
+
ended: (plan: AnimationPlan, completed: boolean) => void;
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
export interface TileResult {
|
|
@@ -56,6 +79,8 @@ export interface ComposeResult {
|
|
|
56
79
|
idle: boolean;
|
|
57
80
|
/** When something will change by itself (clock, rotation): next repaint. */
|
|
58
81
|
nextAt: number;
|
|
82
|
+
/** The shown widgets that can animate. */
|
|
83
|
+
animatable: Animatable[];
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
const images = new Map<string, Image | null>();
|
|
@@ -172,7 +197,7 @@ function drawCard(g: SKRSContext2D, rect: PxRect, theme: Theme): void {
|
|
|
172
197
|
g.stroke();
|
|
173
198
|
}
|
|
174
199
|
|
|
175
|
-
function paintTile(g: SKRSContext2D, input: ComposeInput, tile: Tile, rect: PxRect, out: TileResult, nextAt: { v: number }, idles: boolean[]): void {
|
|
200
|
+
function paintTile(g: SKRSContext2D, input: ComposeInput, tile: Tile, rect: PxRect, out: TileResult, nextAt: { v: number }, idles: boolean[], animatable: Animatable[]): void {
|
|
176
201
|
const { theme, now, working } = input;
|
|
177
202
|
const candidates = tile.widgets
|
|
178
203
|
.filter((w) => !w.disabled && whenMatches(w.when, working))
|
|
@@ -202,9 +227,27 @@ function paintTile(g: SKRSContext2D, input: ComposeInput, tile: Tile, rect: PxRe
|
|
|
202
227
|
idles.push(safe(() => r.def.idle?.(state) ?? false, false));
|
|
203
228
|
const step = redrawStep(r.def, state);
|
|
204
229
|
if (step) nextAt.v = Math.min(nextAt.v, (Math.floor(now / step) + 1) * step);
|
|
230
|
+
const animation = r.def.animation;
|
|
231
|
+
if (animation && !input.preview) {
|
|
232
|
+
animatable.push({
|
|
233
|
+
tileId: tile.id,
|
|
234
|
+
instanceId: r.inst.id,
|
|
235
|
+
type: r.inst.type,
|
|
236
|
+
def: r.def,
|
|
237
|
+
data: r.data,
|
|
238
|
+
sources: r.sources,
|
|
239
|
+
ended: (plan, completed) => {
|
|
240
|
+
const [plugin] = r.inst.type.split('/');
|
|
241
|
+
safe(() => r.def.animationEnded?.({ ...baseContext(input.cfg, plugin!, input.log ?? (() => undefined), input.now), options: r.options, plan, completed, data: r.data }), undefined);
|
|
242
|
+
},
|
|
243
|
+
ask: (trigger, rnd, prev) => safe(() => animation({ ...state, box: tileBoxes(theme, input.screen.height, r.inst, rect, chrome).box, trigger, rnd, ...(prev !== undefined ? { prev } : {}) }), null),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
205
246
|
|
|
206
|
-
|
|
207
|
-
|
|
247
|
+
// The tile whose animation is being painted: this frame, never from or into the cache.
|
|
248
|
+
const frame = input.animate?.find((a) => a.tileId === tile.id && a.instanceId === r.inst.id)?.frame;
|
|
249
|
+
const draw = (tg: SKRSContext2D) => drawTileContent(tg, input, r, rect, chrome, out, frame);
|
|
250
|
+
if (frame || !input.cache) {
|
|
208
251
|
draw(g);
|
|
209
252
|
return;
|
|
210
253
|
}
|
|
@@ -227,26 +270,37 @@ function paintTile(g: SKRSContext2D, input: ComposeInput, tile: Tile, rect: PxRe
|
|
|
227
270
|
input.cache.set(tile.id, { key, refs, canvas, ...(out.error ? { error: out.error } : {}) });
|
|
228
271
|
}
|
|
229
272
|
|
|
230
|
-
|
|
273
|
+
/**
|
|
274
|
+
* Where a widget paints inside its tile: `outer` is the area left by the
|
|
275
|
+
* card's padding and title, and `box` is what the widget sees: the same area
|
|
276
|
+
* `zoom` times smaller when zoomed (it keeps its top-left corner).
|
|
277
|
+
*/
|
|
278
|
+
export function tileBoxes(theme: Theme, screenH: number, inst: WidgetInstance, rect: PxRect, chrome: 'card' | 'bare'): { outer: Box; box: Box; zoom: number } {
|
|
279
|
+
const m = chromeMetrics(theme, screenH, rect);
|
|
280
|
+
const pad = chrome === 'card' ? m.pad : 0;
|
|
281
|
+
const top = rect.y + pad + (inst.title?.trim() && chrome === 'card' ? m.titleBar : 0);
|
|
282
|
+
const outer: Box = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
|
|
283
|
+
// Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
|
|
284
|
+
// times bigger, so it fills the same space with bigger (or smaller) type.
|
|
285
|
+
// Its box keeps its top-left corner, so widgets see nothing unusual.
|
|
286
|
+
const zoom = zoomOf(inst);
|
|
287
|
+
const box: Box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
|
|
288
|
+
return { outer, box, zoom };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function drawTileContent(g: SKRSContext2D, input: ComposeInput, r: Resolved, rect: PxRect, chrome: 'card' | 'bare', out: TileResult, anim?: AnimationFrame): void {
|
|
231
292
|
const { theme, now, working } = input;
|
|
232
293
|
const m = chromeMetrics(theme, input.screen.height, rect);
|
|
233
294
|
if (chrome === 'card') drawCard(g, rect, theme);
|
|
234
295
|
const pad = chrome === 'card' ? m.pad : 0;
|
|
235
|
-
let top = rect.y + pad;
|
|
236
296
|
const title = r.inst.title?.trim();
|
|
237
297
|
if (title && chrome === 'card') {
|
|
238
298
|
g.font = `${m.title}px "${theme.fonts.sans}"`;
|
|
239
299
|
g.fillStyle = theme.colors.inkDim;
|
|
240
300
|
g.textBaseline = 'alphabetic';
|
|
241
301
|
g.fillText(fitText(g, title, rect.w - 2 * pad), rect.x + pad, rect.y + pad + m.title * 0.8);
|
|
242
|
-
top = rect.y + pad + m.titleBar;
|
|
243
302
|
}
|
|
244
|
-
const
|
|
245
|
-
// Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
|
|
246
|
-
// times bigger, so it fills the same space with bigger (or smaller) type.
|
|
247
|
-
// Its box keeps its top-left corner, so widgets see nothing unusual.
|
|
248
|
-
const zoom = zoomOf(r.inst);
|
|
249
|
-
const box: Box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
|
|
303
|
+
const { outer, box, zoom } = tileBoxes(theme, input.screen.height, r.inst, rect, chrome);
|
|
250
304
|
const [plugin] = r.inst.type.split('/');
|
|
251
305
|
const ctx: RenderContext<any, any> = {
|
|
252
306
|
...baseContext(input.cfg, plugin!, input.log ?? (() => undefined), now),
|
|
@@ -257,6 +311,7 @@ function drawTileContent(g: SKRSContext2D, input: ComposeInput, r: Resolved, rec
|
|
|
257
311
|
kit,
|
|
258
312
|
canvas: CANVAS,
|
|
259
313
|
preview: !!input.preview,
|
|
314
|
+
...(anim ? { anim, frame: anim.i } : {}),
|
|
260
315
|
};
|
|
261
316
|
g.save();
|
|
262
317
|
roundRect(g, rect.x, rect.y, rect.w, rect.h, chrome === 'card' ? theme.radius : 0);
|
|
@@ -297,16 +352,17 @@ export function composePage(input: ComposeInput): ComposeResult {
|
|
|
297
352
|
const nextAt = { v: Infinity };
|
|
298
353
|
const idles: boolean[] = [];
|
|
299
354
|
const tiles: TileResult[] = [];
|
|
355
|
+
const animatable: Animatable[] = [];
|
|
300
356
|
for (const tile of page.tiles) {
|
|
301
357
|
const rect = toPx(tile, page.grid, screen.width, screen.height);
|
|
302
358
|
const out: TileResult = { id: tile.id, rect, shown: null };
|
|
303
359
|
tiles.push(out);
|
|
304
|
-
paintTile(g, input, tile, rect, out, nextAt, idles);
|
|
360
|
+
paintTile(g, input, tile, rect, out, nextAt, idles, animatable);
|
|
305
361
|
}
|
|
306
362
|
if (input.cache) for (const id of input.cache.keys()) if (!page.tiles.some((t) => t.id === id)) input.cache.delete(id);
|
|
307
363
|
g.restore();
|
|
308
364
|
if (screen.pixelShift.enabled && !input.preview) nextAt.v = Math.min(nextAt.v, (Math.floor(now / screen.pixelShift.periodMs) + 1) * screen.pixelShift.periodMs);
|
|
309
|
-
return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v };
|
|
365
|
+
return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v, animatable };
|
|
310
366
|
}
|
|
311
367
|
|
|
312
368
|
/** A canvas rotated for a panel mounted at `rotate` degrees. */
|
package/src/config/diff.ts
CHANGED
|
@@ -50,7 +50,7 @@ const pageText = (p: Page) => {
|
|
|
50
50
|
const ts = pageTypes(p);
|
|
51
51
|
return `page ${q(p.name)}: ${plural(p.tiles.length, 'tile')}${ts.length ? ` (${ts.slice(0, 6).join(', ')}${ts.length > 6 ? `, +${ts.length - 6} more` : ''})` : ''}`;
|
|
52
52
|
};
|
|
53
|
-
const deviceText = (d: DeviceConfig) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver);
|
|
53
|
+
const deviceText = (d: DeviceConfig) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver === 'usb' ? `usb${d.serial ? ` ${d.serial}` : ''}` : d.driver);
|
|
54
54
|
const changedKeys = (a: Record<string, unknown>, b: Record<string, unknown>) => [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((k) => !same(a[k], b[k]));
|
|
55
55
|
|
|
56
56
|
function diffTiles(before: Page, after: Page, depth: number, out: DiffLine[]): void {
|
package/src/config/model.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type DeviceConfig =
|
|
|
31
31
|
| { driver: 'virtual' }
|
|
32
32
|
/** `trcc serve` REST API (thermalright-trcc-linux). */
|
|
33
33
|
| { driver: 'trcc'; url: string; tokenFile?: string; deviceKey?: string; serial?: string; keepalive?: boolean }
|
|
34
|
+
/** Straight over USB from Node (LY panels, Trofeo Vision…): no trcc, no Python. */
|
|
35
|
+
| { driver: 'usb'; serial?: string; keepalive?: number }
|
|
34
36
|
/** Writes `frame.jpg` atomically to `dir`; a sender (e.g. by USB serial) takes it to the panel. */
|
|
35
37
|
| { driver: 'file'; dir: string; format?: 'jpeg' | 'png'; quality?: number };
|
|
36
38
|
|
|
@@ -129,10 +131,19 @@ export interface Screen {
|
|
|
129
131
|
sleepWhenIdle: boolean;
|
|
130
132
|
/** JPEG quality for photo-like pages; 0 = PNG. */
|
|
131
133
|
jpegQuality: number;
|
|
134
|
+
/**
|
|
135
|
+
* Largest frame to send, in bytes; a bigger one is re-encoded at a lower
|
|
136
|
+
* JPEG quality until it fits. LY panels (Trofeo Vision and friends) silently
|
|
137
|
+
* drop frames over ~500 KB and keep showing the last one. 0 = no limit.
|
|
138
|
+
*/
|
|
139
|
+
maxBytes: number;
|
|
132
140
|
/** Frames per second ceiling for animations. */
|
|
133
141
|
maxFps: number;
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
/** Default `maxBytes`: under the ~500 KB LY panels accept, with some margin. */
|
|
145
|
+
export const DEFAULT_MAX_BYTES = 480_000;
|
|
146
|
+
|
|
136
147
|
export interface Config {
|
|
137
148
|
/** JSON Schema of the file, for editors. Kept as written; `CONFIG_SCHEMA_ID` when missing. */
|
|
138
149
|
$schema?: string;
|
|
@@ -186,6 +197,7 @@ export function newScreen(name: string, width = 1920, height = 462, device: Devi
|
|
|
186
197
|
pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000 },
|
|
187
198
|
sleepWhenIdle: false,
|
|
188
199
|
jpegQuality: 90,
|
|
200
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
189
201
|
maxFps: 12,
|
|
190
202
|
};
|
|
191
203
|
}
|