@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/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 = encode(rotateCanvas(canvas, screen.rotate), kind, screen.jpegQuality);
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 = encode(rotateCanvas(canvas, this.screen.rotate), kind, this.screen.jpegQuality || 85);
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.0",
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.0",
31
- "@loksly/trcc-client": "0.1.0",
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.0",
34
+ "@kiosko/sdk": "0.2.1",
34
35
  "dejavu-fonts-ttf": "^2.37.3"
35
36
  },
36
37
  "scripts": {
@@ -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
- const draw = (tg: SKRSContext2D) => drawTileContent(tg, input, r, rect, chrome, out);
207
- if (!input.cache) {
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
- function drawTileContent(g: SKRSContext2D, input: ComposeInput, r: Resolved, rect: PxRect, chrome: 'card' | 'bare', out: TileResult): void {
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 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) };
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. */
@@ -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 {
@@ -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
  }