@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
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
/** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
|
|
14
|
+
export declare const MAX_FRAMES = 600;
|
|
15
|
+
/**
|
|
16
|
+
* How many animations the runtime plays together on a screen. Widgets don't
|
|
17
|
+
* know: every one asks as if it were alone, and what doesn't fit waits its
|
|
18
|
+
* turn. The whole chain (planner, compositor, player) works for any number;
|
|
19
|
+
* one is what the panel's USB budget is sized for.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_CONCURRENT = 1;
|
|
22
|
+
/** Default time between the `idle` questions of a widget. */
|
|
23
|
+
export declare const DEFAULT_GAP: {
|
|
24
|
+
minMs: number;
|
|
25
|
+
maxMs: number;
|
|
26
|
+
};
|
|
27
|
+
/** A plan as the runtime plays it: checked, with the screen's fps ceiling applied. */
|
|
28
|
+
export interface PlayablePlan {
|
|
29
|
+
plan: AnimationPlan;
|
|
30
|
+
frames: number;
|
|
31
|
+
fps: number;
|
|
32
|
+
interruptible: boolean;
|
|
33
|
+
/** Index (into 0..frames-1) where it holds, and for how long. */
|
|
34
|
+
hold?: {
|
|
35
|
+
index: number;
|
|
36
|
+
ms: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Why a plan isn't playable, or the plan ready to play. */
|
|
40
|
+
export declare function checkPlan(plan: unknown, maxFps: number): PlayablePlan | string;
|
|
41
|
+
export declare class AnimationScheduler {
|
|
42
|
+
private memory;
|
|
43
|
+
private readonly rnd;
|
|
44
|
+
private readonly onInvalid;
|
|
45
|
+
constructor(rnd?: () => number, onInvalid?: (type: string, why: string) => void);
|
|
46
|
+
private gapAfter;
|
|
47
|
+
/**
|
|
48
|
+
* The animations to play now, up to `limit`, among the shown widgets that
|
|
49
|
+
* have one. Call it only while nothing is playing. A widget seen for the
|
|
50
|
+
* first time isn't asked: starting up must not set off a gesture. When more
|
|
51
|
+
* are due than fit, the one that has waited longest goes first (the order
|
|
52
|
+
* of the page doesn't decide), so none starves.
|
|
53
|
+
*/
|
|
54
|
+
next(list: Animatable[], now: number, maxFps: number, limit?: number): {
|
|
55
|
+
animatable: Animatable;
|
|
56
|
+
play: PlayablePlan;
|
|
57
|
+
}[];
|
|
58
|
+
/** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
|
|
59
|
+
ended(instanceId: string, now: number, a?: Animatable): void;
|
|
60
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
|
|
2
|
+
export const MAX_FRAMES = 600;
|
|
3
|
+
/**
|
|
4
|
+
* How many animations the runtime plays together on a screen. Widgets don't
|
|
5
|
+
* know: every one asks as if it were alone, and what doesn't fit waits its
|
|
6
|
+
* turn. The whole chain (planner, compositor, player) works for any number;
|
|
7
|
+
* one is what the panel's USB budget is sized for.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_CONCURRENT = 1;
|
|
10
|
+
/** Default time between the `idle` questions of a widget. */
|
|
11
|
+
export const DEFAULT_GAP = { minMs: 20_000, maxMs: 60_000 };
|
|
12
|
+
/** Why a plan isn't playable, or the plan ready to play. */
|
|
13
|
+
export function checkPlan(plan, maxFps) {
|
|
14
|
+
const p = plan;
|
|
15
|
+
if (!p || typeof p !== 'object')
|
|
16
|
+
return 'not an object';
|
|
17
|
+
if (typeof p.id !== 'string' || !p.id)
|
|
18
|
+
return 'no id';
|
|
19
|
+
if (!Number.isInteger(p.frames) || p.frames < 3)
|
|
20
|
+
return `frames must be an integer of at least 3 (got ${String(p.frames)})`;
|
|
21
|
+
if (p.frames > MAX_FRAMES)
|
|
22
|
+
return `too many frames (${p.frames}, at most ${MAX_FRAMES})`;
|
|
23
|
+
if (typeof p.fps !== 'number' || !(p.fps > 0))
|
|
24
|
+
return `fps must be positive (got ${String(p.fps)})`;
|
|
25
|
+
const frames = p.frames;
|
|
26
|
+
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;
|
|
27
|
+
return { plan: p, frames, fps: Math.max(1, Math.min(p.fps, maxFps)), interruptible: p.interruptible !== false, ...(hold ? { hold } : {}) };
|
|
28
|
+
}
|
|
29
|
+
export class AnimationScheduler {
|
|
30
|
+
memory = new Map();
|
|
31
|
+
rnd;
|
|
32
|
+
onInvalid;
|
|
33
|
+
constructor(rnd = Math.random, onInvalid = () => undefined) {
|
|
34
|
+
this.rnd = rnd;
|
|
35
|
+
this.onInvalid = onInvalid;
|
|
36
|
+
}
|
|
37
|
+
gapAfter(a, now) {
|
|
38
|
+
const g = a.def.animationGap ?? DEFAULT_GAP;
|
|
39
|
+
const min = Math.max(0, g.minMs), max = Math.max(min, g.maxMs);
|
|
40
|
+
return now + min + this.rnd() * (max - min);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The animations to play now, up to `limit`, among the shown widgets that
|
|
44
|
+
* have one. Call it only while nothing is playing. A widget seen for the
|
|
45
|
+
* first time isn't asked: starting up must not set off a gesture. When more
|
|
46
|
+
* are due than fit, the one that has waited longest goes first (the order
|
|
47
|
+
* of the page doesn't decide), so none starves.
|
|
48
|
+
*/
|
|
49
|
+
next(list, now, maxFps, limit = MAX_CONCURRENT) {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const due = [];
|
|
52
|
+
for (const a of list) {
|
|
53
|
+
seen.add(a.instanceId);
|
|
54
|
+
const m = this.memory.get(a.instanceId);
|
|
55
|
+
if (!m) {
|
|
56
|
+
this.memory.set(a.instanceId, { data: a.data, sources: a.sources, nextIdleAt: this.gapAfter(a, now), endedAt: -Infinity, dueSince: Infinity });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const changed = a.data !== m.data || Object.keys(a.sources).some((k) => a.sources[k] !== m.sources[k]);
|
|
60
|
+
const prev = m.pending ?? { data: m.data, sources: m.sources };
|
|
61
|
+
m.data = a.data;
|
|
62
|
+
m.sources = a.sources;
|
|
63
|
+
if ((changed || m.pending) && now - m.endedAt >= (a.def.animationCooldownMs ?? 0)) {
|
|
64
|
+
m.pending = prev;
|
|
65
|
+
due.push({ a, m, trigger: 'data', prev });
|
|
66
|
+
}
|
|
67
|
+
else if (now >= m.nextIdleAt)
|
|
68
|
+
due.push({ a, m, trigger: 'idle', prev });
|
|
69
|
+
}
|
|
70
|
+
// Forget the instances that are gone (removed, or no longer shown).
|
|
71
|
+
for (const id of this.memory.keys())
|
|
72
|
+
if (!seen.has(id))
|
|
73
|
+
this.memory.delete(id);
|
|
74
|
+
// Who has waited longest first; a tie keeps the page's order.
|
|
75
|
+
for (const d of due)
|
|
76
|
+
d.m.dueSince = Math.min(d.m.dueSince, d.trigger === 'data' ? now : d.m.nextIdleAt);
|
|
77
|
+
due.sort((x, y) => x.m.dueSince - y.m.dueSince);
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const { a, m, trigger, prev } of due) {
|
|
80
|
+
if (out.length >= limit)
|
|
81
|
+
break;
|
|
82
|
+
if (trigger === 'idle')
|
|
83
|
+
m.nextIdleAt = this.gapAfter(a, now);
|
|
84
|
+
else
|
|
85
|
+
m.pending = undefined;
|
|
86
|
+
const plan = a.ask(trigger, this.rnd, trigger === 'data' ? prev : undefined);
|
|
87
|
+
if (plan === null) {
|
|
88
|
+
m.dueSince = Infinity;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const ok = checkPlan(plan, maxFps);
|
|
92
|
+
if (typeof ok === 'string') {
|
|
93
|
+
this.onInvalid(a.type, ok);
|
|
94
|
+
m.dueSince = Infinity;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
out.push({ animatable: a, play: ok });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
|
|
102
|
+
ended(instanceId, now, a) {
|
|
103
|
+
const m = this.memory.get(instanceId);
|
|
104
|
+
if (!m)
|
|
105
|
+
return;
|
|
106
|
+
m.endedAt = now;
|
|
107
|
+
m.dueSince = Infinity;
|
|
108
|
+
if (a)
|
|
109
|
+
m.nextIdleAt = Math.max(m.nextIdleAt, this.gapAfter(a, now));
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/compositor.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* go through here and can't disagree.
|
|
6
6
|
*/
|
|
7
7
|
import { type Canvas, type SKRSContext2D } from '@napi-rs/canvas';
|
|
8
|
-
import { type Theme, type WidgetDefinition } from '@kiosko/sdk';
|
|
8
|
+
import { type AnimationFrame, type AnimationPlan, type Box, type Theme, type WidgetDefinition } from '@kiosko/sdk';
|
|
9
9
|
import { type PxRect } from './layout/grid.ts';
|
|
10
10
|
import { type Config, type Page, type Screen, type Tile, type WidgetInstance } from './config/model.ts';
|
|
11
11
|
import { type LogFn } from './env.ts';
|
|
@@ -35,6 +35,35 @@ export interface ComposeInput {
|
|
|
35
35
|
noShift?: boolean;
|
|
36
36
|
/** Reuse tiles whose inputs didn't change (the panel; not previews). */
|
|
37
37
|
cache?: TileCache;
|
|
38
|
+
/**
|
|
39
|
+
* Paint these frames of animations, each in its own tile (see
|
|
40
|
+
* `AnimationFrame`); every other tile as usual. Any number at once: how
|
|
41
|
+
* many the runtime plays together is its decision (`MAX_CONCURRENT`).
|
|
42
|
+
*/
|
|
43
|
+
animate?: {
|
|
44
|
+
tileId: string;
|
|
45
|
+
instanceId: string;
|
|
46
|
+
frame: AnimationFrame;
|
|
47
|
+
}[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A widget on screen that has an `animation` hook: asks it, with the state
|
|
51
|
+
* the page was painted with. The runtime decides when to ask.
|
|
52
|
+
*/
|
|
53
|
+
export interface Animatable {
|
|
54
|
+
tileId: string;
|
|
55
|
+
instanceId: string;
|
|
56
|
+
type: string;
|
|
57
|
+
def: Pick<WidgetDefinition<any, any>, 'animationGap' | 'animationCooldownMs'>;
|
|
58
|
+
/** What the widget painted with, by reference (replaced, never mutated), to tell when it changed. */
|
|
59
|
+
data: unknown;
|
|
60
|
+
sources: Record<string, unknown>;
|
|
61
|
+
ask: (trigger: 'idle' | 'data', rnd: () => number, prev?: {
|
|
62
|
+
data: unknown;
|
|
63
|
+
sources: Record<string, unknown>;
|
|
64
|
+
}) => AnimationPlan | null;
|
|
65
|
+
/** Tells the widget an animation of its has ended (see `WidgetDefinition.animationEnded`). */
|
|
66
|
+
ended: (plan: AnimationPlan, completed: boolean) => void;
|
|
38
67
|
}
|
|
39
68
|
export interface TileResult {
|
|
40
69
|
id: string;
|
|
@@ -50,6 +79,8 @@ export interface ComposeResult {
|
|
|
50
79
|
idle: boolean;
|
|
51
80
|
/** When something will change by itself (clock, rotation): next repaint. */
|
|
52
81
|
nextAt: number;
|
|
82
|
+
/** The shown widgets that can animate. */
|
|
83
|
+
animatable: Animatable[];
|
|
53
84
|
}
|
|
54
85
|
/**
|
|
55
86
|
* The instance's switches over the widget's hooks: `takeOver` (keep the tile
|
|
@@ -76,6 +107,16 @@ export type TileCache = Map<string, {
|
|
|
76
107
|
canvas: Canvas;
|
|
77
108
|
error?: string;
|
|
78
109
|
}>;
|
|
110
|
+
/**
|
|
111
|
+
* Where a widget paints inside its tile: `outer` is the area left by the
|
|
112
|
+
* card's padding and title, and `box` is what the widget sees: the same area
|
|
113
|
+
* `zoom` times smaller when zoomed (it keeps its top-left corner).
|
|
114
|
+
*/
|
|
115
|
+
export declare function tileBoxes(theme: Theme, screenH: number, inst: WidgetInstance, rect: PxRect, chrome: 'card' | 'bare'): {
|
|
116
|
+
outer: Box;
|
|
117
|
+
box: Box;
|
|
118
|
+
zoom: number;
|
|
119
|
+
};
|
|
79
120
|
/** Paints `page` on a new canvas of the screen size. */
|
|
80
121
|
export declare function composePage(input: ComposeInput): ComposeResult;
|
|
81
122
|
/** A canvas rotated for a panel mounted at `rotate` degrees. */
|
package/dist/compositor.js
CHANGED
|
@@ -116,7 +116,7 @@ function drawCard(g, rect, theme) {
|
|
|
116
116
|
roundRect(g, rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1, theme.radius);
|
|
117
117
|
g.stroke();
|
|
118
118
|
}
|
|
119
|
-
function paintTile(g, input, tile, rect, out, nextAt, idles) {
|
|
119
|
+
function paintTile(g, input, tile, rect, out, nextAt, idles, animatable) {
|
|
120
120
|
const { theme, now, working } = input;
|
|
121
121
|
const candidates = tile.widgets
|
|
122
122
|
.filter((w) => !w.disabled && whenMatches(w.when, working))
|
|
@@ -148,8 +148,26 @@ function paintTile(g, input, tile, rect, out, nextAt, idles) {
|
|
|
148
148
|
const step = redrawStep(r.def, state);
|
|
149
149
|
if (step)
|
|
150
150
|
nextAt.v = Math.min(nextAt.v, (Math.floor(now / step) + 1) * step);
|
|
151
|
-
const
|
|
152
|
-
if (!input.
|
|
151
|
+
const animation = r.def.animation;
|
|
152
|
+
if (animation && !input.preview) {
|
|
153
|
+
animatable.push({
|
|
154
|
+
tileId: tile.id,
|
|
155
|
+
instanceId: r.inst.id,
|
|
156
|
+
type: r.inst.type,
|
|
157
|
+
def: r.def,
|
|
158
|
+
data: r.data,
|
|
159
|
+
sources: r.sources,
|
|
160
|
+
ended: (plan, completed) => {
|
|
161
|
+
const [plugin] = r.inst.type.split('/');
|
|
162
|
+
safe(() => r.def.animationEnded?.({ ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), input.now), options: r.options, plan, completed, data: r.data }), undefined);
|
|
163
|
+
},
|
|
164
|
+
ask: (trigger, rnd, prev) => safe(() => animation({ ...state, box: tileBoxes(theme, input.screen.height, r.inst, rect, chrome).box, trigger, rnd, ...(prev !== undefined ? { prev } : {}) }), null),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// The tile whose animation is being painted: this frame, never from or into the cache.
|
|
168
|
+
const frame = input.animate?.find((a) => a.tileId === tile.id && a.instanceId === r.inst.id)?.frame;
|
|
169
|
+
const draw = (tg) => drawTileContent(tg, input, r, rect, chrome, out, frame);
|
|
170
|
+
if (frame || !input.cache) {
|
|
153
171
|
draw(g);
|
|
154
172
|
return;
|
|
155
173
|
}
|
|
@@ -172,27 +190,37 @@ function paintTile(g, input, tile, rect, out, nextAt, idles) {
|
|
|
172
190
|
g.drawImage(canvas, rect.x, rect.y);
|
|
173
191
|
input.cache.set(tile.id, { key, refs, canvas, ...(out.error ? { error: out.error } : {}) });
|
|
174
192
|
}
|
|
175
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Where a widget paints inside its tile: `outer` is the area left by the
|
|
195
|
+
* card's padding and title, and `box` is what the widget sees: the same area
|
|
196
|
+
* `zoom` times smaller when zoomed (it keeps its top-left corner).
|
|
197
|
+
*/
|
|
198
|
+
export function tileBoxes(theme, screenH, inst, rect, chrome) {
|
|
199
|
+
const m = chromeMetrics(theme, screenH, rect);
|
|
200
|
+
const pad = chrome === 'card' ? m.pad : 0;
|
|
201
|
+
const top = rect.y + pad + (inst.title?.trim() && chrome === 'card' ? m.titleBar : 0);
|
|
202
|
+
const outer = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
|
|
203
|
+
// Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
|
|
204
|
+
// times bigger, so it fills the same space with bigger (or smaller) type.
|
|
205
|
+
// Its box keeps its top-left corner, so widgets see nothing unusual.
|
|
206
|
+
const zoom = zoomOf(inst);
|
|
207
|
+
const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
|
|
208
|
+
return { outer, box, zoom };
|
|
209
|
+
}
|
|
210
|
+
function drawTileContent(g, input, r, rect, chrome, out, anim) {
|
|
176
211
|
const { theme, now, working } = input;
|
|
177
212
|
const m = chromeMetrics(theme, input.screen.height, rect);
|
|
178
213
|
if (chrome === 'card')
|
|
179
214
|
drawCard(g, rect, theme);
|
|
180
215
|
const pad = chrome === 'card' ? m.pad : 0;
|
|
181
|
-
let top = rect.y + pad;
|
|
182
216
|
const title = r.inst.title?.trim();
|
|
183
217
|
if (title && chrome === 'card') {
|
|
184
218
|
g.font = `${m.title}px "${theme.fonts.sans}"`;
|
|
185
219
|
g.fillStyle = theme.colors.inkDim;
|
|
186
220
|
g.textBaseline = 'alphabetic';
|
|
187
221
|
g.fillText(fitText(g, title, rect.w - 2 * pad), rect.x + pad, rect.y + pad + m.title * 0.8);
|
|
188
|
-
top = rect.y + pad + m.titleBar;
|
|
189
222
|
}
|
|
190
|
-
const
|
|
191
|
-
// Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
|
|
192
|
-
// times bigger, so it fills the same space with bigger (or smaller) type.
|
|
193
|
-
// Its box keeps its top-left corner, so widgets see nothing unusual.
|
|
194
|
-
const zoom = zoomOf(r.inst);
|
|
195
|
-
const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
|
|
223
|
+
const { outer, box, zoom } = tileBoxes(theme, input.screen.height, r.inst, rect, chrome);
|
|
196
224
|
const [plugin] = r.inst.type.split('/');
|
|
197
225
|
const ctx = {
|
|
198
226
|
...baseContext(input.cfg, plugin, input.log ?? (() => undefined), now),
|
|
@@ -203,6 +231,7 @@ function drawTileContent(g, input, r, rect, chrome, out) {
|
|
|
203
231
|
kit,
|
|
204
232
|
canvas: CANVAS,
|
|
205
233
|
preview: !!input.preview,
|
|
234
|
+
...(anim ? { anim, frame: anim.i } : {}),
|
|
206
235
|
};
|
|
207
236
|
g.save();
|
|
208
237
|
roundRect(g, rect.x, rect.y, rect.w, rect.h, chrome === 'card' ? theme.radius : 0);
|
|
@@ -243,11 +272,12 @@ export function composePage(input) {
|
|
|
243
272
|
const nextAt = { v: Infinity };
|
|
244
273
|
const idles = [];
|
|
245
274
|
const tiles = [];
|
|
275
|
+
const animatable = [];
|
|
246
276
|
for (const tile of page.tiles) {
|
|
247
277
|
const rect = toPx(tile, page.grid, screen.width, screen.height);
|
|
248
278
|
const out = { id: tile.id, rect, shown: null };
|
|
249
279
|
tiles.push(out);
|
|
250
|
-
paintTile(g, input, tile, rect, out, nextAt, idles);
|
|
280
|
+
paintTile(g, input, tile, rect, out, nextAt, idles, animatable);
|
|
251
281
|
}
|
|
252
282
|
if (input.cache)
|
|
253
283
|
for (const id of input.cache.keys())
|
|
@@ -256,7 +286,7 @@ export function composePage(input) {
|
|
|
256
286
|
g.restore();
|
|
257
287
|
if (screen.pixelShift.enabled && !input.preview)
|
|
258
288
|
nextAt.v = Math.min(nextAt.v, (Math.floor(now / screen.pixelShift.periodMs) + 1) * screen.pixelShift.periodMs);
|
|
259
|
-
return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v };
|
|
289
|
+
return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v, animatable };
|
|
260
290
|
}
|
|
261
291
|
/** A canvas rotated for a panel mounted at `rotate` degrees. */
|
|
262
292
|
export function rotateCanvas(src, rotate) {
|
package/dist/config/diff.js
CHANGED
|
@@ -35,7 +35,7 @@ const pageText = (p) => {
|
|
|
35
35
|
const ts = pageTypes(p);
|
|
36
36
|
return `page ${q(p.name)}: ${plural(p.tiles.length, 'tile')}${ts.length ? ` (${ts.slice(0, 6).join(', ')}${ts.length > 6 ? `, +${ts.length - 6} more` : ''})` : ''}`;
|
|
37
37
|
};
|
|
38
|
-
const deviceText = (d) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver);
|
|
38
|
+
const deviceText = (d) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver === 'usb' ? `usb${d.serial ? ` ${d.serial}` : ''}` : d.driver);
|
|
39
39
|
const changedKeys = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((k) => !same(a[k], b[k]));
|
|
40
40
|
function diffTiles(before, after, depth, out) {
|
|
41
41
|
const m = match(before.tiles, after.tiles, rect);
|
package/dist/config/model.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export type DeviceConfig = {
|
|
|
34
34
|
serial?: string;
|
|
35
35
|
keepalive?: boolean;
|
|
36
36
|
}
|
|
37
|
+
/** Straight over USB from Node (LY panels, Trofeo Vision…): no trcc, no Python. */
|
|
38
|
+
| {
|
|
39
|
+
driver: 'usb';
|
|
40
|
+
serial?: string;
|
|
41
|
+
keepalive?: number;
|
|
42
|
+
}
|
|
37
43
|
/** Writes `frame.jpg` atomically to `dir`; a sender (e.g. by USB serial) takes it to the panel. */
|
|
38
44
|
| {
|
|
39
45
|
driver: 'file';
|
|
@@ -136,9 +142,17 @@ export interface Screen {
|
|
|
136
142
|
sleepWhenIdle: boolean;
|
|
137
143
|
/** JPEG quality for photo-like pages; 0 = PNG. */
|
|
138
144
|
jpegQuality: number;
|
|
145
|
+
/**
|
|
146
|
+
* Largest frame to send, in bytes; a bigger one is re-encoded at a lower
|
|
147
|
+
* JPEG quality until it fits. LY panels (Trofeo Vision and friends) silently
|
|
148
|
+
* drop frames over ~500 KB and keep showing the last one. 0 = no limit.
|
|
149
|
+
*/
|
|
150
|
+
maxBytes: number;
|
|
139
151
|
/** Frames per second ceiling for animations. */
|
|
140
152
|
maxFps: number;
|
|
141
153
|
}
|
|
154
|
+
/** Default `maxBytes`: under the ~500 KB LY panels accept, with some margin. */
|
|
155
|
+
export declare const DEFAULT_MAX_BYTES = 480000;
|
|
142
156
|
export interface Config {
|
|
143
157
|
/** JSON Schema of the file, for editors. Kept as written; `CONFIG_SCHEMA_ID` when missing. */
|
|
144
158
|
$schema?: string;
|
package/dist/config/model.js
CHANGED
|
@@ -16,6 +16,8 @@ export function zoomOf(inst) {
|
|
|
16
16
|
return 1;
|
|
17
17
|
return Math.round(Math.min(ZOOM.max, Math.max(ZOOM.min, z)) * 100) / 100;
|
|
18
18
|
}
|
|
19
|
+
/** Default `maxBytes`: under the ~500 KB LY panels accept, with some margin. */
|
|
20
|
+
export const DEFAULT_MAX_BYTES = 480_000;
|
|
19
21
|
let counter = 0;
|
|
20
22
|
/** Short unique id. Not crypto: only needs to be unique inside one config. */
|
|
21
23
|
export function newId(prefix = 'id') {
|
|
@@ -46,6 +48,7 @@ export function newScreen(name, width = 1920, height = 462, device = { driver: '
|
|
|
46
48
|
pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000 },
|
|
47
49
|
sleepWhenIdle: false,
|
|
48
50
|
jpegQuality: 90,
|
|
51
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
49
52
|
maxFps: 12,
|
|
50
53
|
};
|
|
51
54
|
}
|
package/dist/config/normalize.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { TIME_FORMATS } from '@kiosko/sdk/format';
|
|
7
7
|
import { DEFAULT_WORKDAY } from '@kiosko/sdk/workday';
|
|
8
8
|
import { suggestGrid } from "../layout/grid.js";
|
|
9
|
-
import { CONFIG_VERSION, DEFAULT_CONFIG } from "./model.js";
|
|
9
|
+
import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_MAX_BYTES } from "./model.js";
|
|
10
10
|
/**
|
|
11
11
|
* Fills gaps of a config written by hand or by an older version. Never throws.
|
|
12
12
|
* The top-level shape it accepts is described, for other tools, by
|
|
@@ -56,6 +56,7 @@ function normalizeScreen(s, i) {
|
|
|
56
56
|
pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000, ...(s.pixelShift ?? {}) },
|
|
57
57
|
sleepWhenIdle: s.sleepWhenIdle ?? false,
|
|
58
58
|
jpegQuality: s.jpegQuality ?? 90,
|
|
59
|
+
maxBytes: Math.max(0, Math.round(s.maxBytes ?? DEFAULT_MAX_BYTES)),
|
|
59
60
|
maxFps: s.maxFps ?? 12,
|
|
60
61
|
};
|
|
61
62
|
}
|
package/dist/config/schema.js
CHANGED
|
@@ -243,6 +243,18 @@ export function configSchema(plugins) {
|
|
|
243
243
|
keepalive: { type: 'boolean', default: true, description: 'Re-send the last frame now and then so the panel keeps it.' },
|
|
244
244
|
},
|
|
245
245
|
},
|
|
246
|
+
{
|
|
247
|
+
title: 'USB (LY panels)',
|
|
248
|
+
description: 'Talks to the panel over USB from Kiosko itself (Thermalright Trofeo Vision and other LY panels): no trcc, no Python. On Windows the device needs the WinUSB driver; on Linux a udev rule.',
|
|
249
|
+
type: 'object',
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
required: ['driver'],
|
|
252
|
+
properties: {
|
|
253
|
+
driver: { const: 'usb' },
|
|
254
|
+
serial: { type: 'string', description: 'USB serial number of the panel. With several identical panels it is the only way to tell them apart, and a serial that is not found is an error, never "another panel". Empty: the first one found.' },
|
|
255
|
+
keepalive: { type: 'number', minimum: 0.1, maximum: 2, default: 1, description: 'Seconds between re-sends of an unchanged frame; the firmware drops the picture after 2-3 s without one.' },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
246
258
|
{
|
|
247
259
|
title: 'Frame file',
|
|
248
260
|
description: 'Writes frame.jpg atomically into a folder; an external sender takes it to the panel.',
|
|
@@ -341,6 +353,7 @@ export function configSchema(plugins) {
|
|
|
341
353
|
},
|
|
342
354
|
sleepWhenIdle: { type: 'boolean', default: false, description: 'Black panel while every widget of the page has nothing worth showing.' },
|
|
343
355
|
jpegQuality: { type: 'integer', minimum: 0, maximum: 100, default: 90, description: 'JPEG quality of the frames; 0 = PNG.' },
|
|
356
|
+
maxBytes: { type: 'integer', minimum: 0, maximum: 20_000_000, default: 480_000, description: 'Largest frame to send, in bytes: a bigger one is re-encoded at a lower JPEG quality until it fits. LY panels (Trofeo Vision…) drop frames over ~500 KB. 0 = no limit.' },
|
|
344
357
|
maxFps: { type: 'integer', minimum: 1, maximum: 30, default: 12, description: 'Frames per second ceiling for animations.' },
|
|
345
358
|
},
|
|
346
359
|
},
|
package/dist/config/store.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading and writing the config file. The file holds secrets (calendar
|
|
3
|
-
* URLs, tokens), so it's written
|
|
3
|
+
* URLs, tokens), so it's written atomically and readable by its owner only (0600; an ACL
|
|
4
|
+
* on Windows).
|
|
4
5
|
*/
|
|
5
|
-
import {
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
7
|
import { dirname } from 'node:path';
|
|
8
|
+
import { restrictToOwner } from "../fsperm.js";
|
|
7
9
|
import { paths } from "../paths.js";
|
|
8
10
|
import { CONFIG_SCHEMA_ID } from "./model.js";
|
|
9
11
|
import { normalize } from "./normalize.js";
|
|
@@ -24,7 +26,7 @@ export function saveConfig(config, path = paths.config) {
|
|
|
24
26
|
if (existsSync(path)) {
|
|
25
27
|
try {
|
|
26
28
|
copyFileSync(path, `${path}.bak`);
|
|
27
|
-
|
|
29
|
+
restrictToOwner(`${path}.bak`);
|
|
28
30
|
}
|
|
29
31
|
catch {
|
|
30
32
|
/* best effort */
|
|
@@ -36,10 +38,5 @@ export function saveConfig(config, path = paths.config) {
|
|
|
36
38
|
const out = { $schema: $schema || CONFIG_SCHEMA_ID, ...rest };
|
|
37
39
|
writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
|
|
38
40
|
renameSync(tmp, path);
|
|
39
|
-
|
|
40
|
-
chmodSync(path, 0o600);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* Windows */
|
|
44
|
-
}
|
|
41
|
+
restrictToOwner(path);
|
|
45
42
|
}
|
package/dist/devices.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LyPanel } from '@loksly/usb-ly';
|
|
1
2
|
import type { DeviceConfig } from './config/model.ts';
|
|
2
3
|
export interface DeviceStatus {
|
|
3
4
|
driver: DeviceConfig['driver'];
|
|
@@ -18,7 +19,74 @@ export interface Device {
|
|
|
18
19
|
close(): void;
|
|
19
20
|
}
|
|
20
21
|
export declare function encodeFor(device: DeviceConfig, jpegQuality: number): 'png' | 'jpeg';
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `rename` over a file another process has open fails on Windows with
|
|
24
|
+
* EPERM/EBUSY/EACCES (the sender is reading it right now): it passes in a few
|
|
25
|
+
* milliseconds, so try again a few times before giving up.
|
|
26
|
+
*/
|
|
27
|
+
export declare function renameReplacing(from: string, to: string, rename?: (a: string, b: string) => void, tries?: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* A panel over USB, owned by this process. Two things the firmware asks for:
|
|
30
|
+
* a new frame goes out as soon as it exists, and an unchanged one is sent
|
|
31
|
+
* again every `keepalive` seconds, or the panel falls back to the factory logo
|
|
32
|
+
* after 2-3 s. A bus error drops the connection and reconnects with a growing
|
|
33
|
+
* pause; a panel with the configured serial number that is not there is
|
|
34
|
+
* waited for, never replaced by another one.
|
|
35
|
+
*/
|
|
36
|
+
export declare class UsbDevice implements Device {
|
|
37
|
+
status: DeviceStatus;
|
|
38
|
+
private readonly cfg;
|
|
39
|
+
private readonly log;
|
|
40
|
+
private readonly rotate;
|
|
41
|
+
private readonly open;
|
|
42
|
+
private readonly retryMs;
|
|
43
|
+
private panel;
|
|
44
|
+
private latest;
|
|
45
|
+
private fresh;
|
|
46
|
+
private lastWrite;
|
|
47
|
+
private retryAt;
|
|
48
|
+
private failures;
|
|
49
|
+
private closed;
|
|
50
|
+
private pumping;
|
|
51
|
+
private timer;
|
|
52
|
+
private lastMessage;
|
|
53
|
+
constructor(cfg: Extract<DeviceConfig, {
|
|
54
|
+
driver: 'usb';
|
|
55
|
+
}>, log: (m: string) => void, o?: {
|
|
56
|
+
rotate?: number;
|
|
57
|
+
open?: (serial?: string) => Promise<LyPanel>;
|
|
58
|
+
retryMs?: number;
|
|
59
|
+
});
|
|
60
|
+
private say;
|
|
61
|
+
private due;
|
|
62
|
+
/** Writes what is pending; concurrent callers share one run. */
|
|
63
|
+
private pump;
|
|
64
|
+
private describe;
|
|
65
|
+
private connect;
|
|
66
|
+
send(frame: Buffer): Promise<void>;
|
|
67
|
+
close(): void;
|
|
68
|
+
}
|
|
69
|
+
/** The LY panels libusb sees (serial and where), without claiming any: for `kiosko doctor`. */
|
|
70
|
+
export declare function listUsb(): Promise<{
|
|
71
|
+
ok: boolean;
|
|
72
|
+
panels: {
|
|
73
|
+
serial?: string;
|
|
74
|
+
pid: number;
|
|
75
|
+
location: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
}[];
|
|
78
|
+
error?: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** The LY panels libusb sees right now, for the "add screen" menu. Never throws. */
|
|
81
|
+
export declare function discoverUsb(): Promise<{
|
|
82
|
+
serial: string;
|
|
83
|
+
key: string;
|
|
84
|
+
width: number;
|
|
85
|
+
height: number;
|
|
86
|
+
location: string;
|
|
87
|
+
error?: string;
|
|
88
|
+
}[]>;
|
|
89
|
+
export declare function openDevice(cfg: DeviceConfig, log: (m: string) => void, rotate?: number): Device;
|
|
22
90
|
/** Panels `trcc serve` can see right now (for the "add screen" dialog). */
|
|
23
91
|
export declare function discoverTrcc(url?: string, tokenFile?: string): Promise<{
|
|
24
92
|
key: string;
|