ntk 8.23.4 → 8.25.0
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/lib/app.js +20 -0
- package/lib/index.js +6 -0
- package/lib/widgets/svgview.js +56 -0
- package/lib/xv.js +604 -0
- package/package.json +3 -2
package/lib/app.js
CHANGED
|
@@ -614,6 +614,21 @@ export default class App {
|
|
|
614
614
|
return this._extension('composite');
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The XVideo extension, or `null` where the server has none: adaptors that
|
|
619
|
+
* convert a YCbCr frame — and scale it — on the GPU. `openVideoPort` in
|
|
620
|
+
* lib/xv.js is the way in (docs/xv.md); this is the bare extension for a
|
|
621
|
+
* caller that wants the requests themselves.
|
|
622
|
+
*
|
|
623
|
+
* Present is not usable: XQuartz and Xvfb both answer the extension and
|
|
624
|
+
* then "no adaptors present", which is why `openVideoPort` asks further.
|
|
625
|
+
*
|
|
626
|
+
* @returns {Promise<object|null>}
|
|
627
|
+
*/
|
|
628
|
+
xv() {
|
|
629
|
+
return this._extension('xv');
|
|
630
|
+
}
|
|
631
|
+
|
|
617
632
|
/**
|
|
618
633
|
* The DAMAGE extension, or `null` where the server has none: what turns
|
|
619
634
|
* "this drawable changed" into an event, so a compositor repaints the
|
|
@@ -1211,6 +1226,11 @@ export default class App {
|
|
|
1211
1226
|
this._cursors.dispose();
|
|
1212
1227
|
this._cursors = null;
|
|
1213
1228
|
}
|
|
1229
|
+
// ungrabbed and their segments detached before the uploader's go
|
|
1230
|
+
// (lib/xv.js); each one leaves the set as it closes
|
|
1231
|
+
if (this._videoPorts) {
|
|
1232
|
+
for (const port of [...this._videoPorts]) port.close();
|
|
1233
|
+
}
|
|
1214
1234
|
if (this._shm) {
|
|
1215
1235
|
this._shm.dispose();
|
|
1216
1236
|
this._shm = undefined;
|
package/lib/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
unpackIcons
|
|
31
31
|
} from './imagedata.js';
|
|
32
32
|
import { Surface } from './surface.js';
|
|
33
|
+
import { VIDEO_FOURCC, VideoPort, VideoTarget, openVideoPort } from './xv.js';
|
|
33
34
|
import { Path2D, parseSvgPath } from './path.js';
|
|
34
35
|
import Font from './text/font.js';
|
|
35
36
|
import FontManager from './text/fontmanager.js';
|
|
@@ -261,6 +262,11 @@ export {
|
|
|
261
262
|
// app.createRegion(rects) makes one, ctx.clipRegion(region) clips to it
|
|
262
263
|
Region,
|
|
263
264
|
Surface,
|
|
265
|
+
// XVideo (docs/xv.md): also available as `ntk/xv`
|
|
266
|
+
openVideoPort,
|
|
267
|
+
VideoPort,
|
|
268
|
+
VideoTarget,
|
|
269
|
+
VIDEO_FOURCC,
|
|
264
270
|
// fill/stroke styles the 2d context hands back (docs/context-2d.md) —
|
|
265
271
|
// exported for `instanceof`, not to be constructed directly
|
|
266
272
|
CanvasGradient,
|
package/lib/widgets/svgview.js
CHANGED
|
@@ -33,6 +33,8 @@ const INHERITED = {
|
|
|
33
33
|
lineCap: 'butt',
|
|
34
34
|
lineJoin: 'miter',
|
|
35
35
|
miterLimit: 4,
|
|
36
|
+
dashArray: 'none',
|
|
37
|
+
dashOffset: 0,
|
|
36
38
|
fillRule: 'nonzero',
|
|
37
39
|
clipRule: 'nonzero',
|
|
38
40
|
fillOpacity: 1,
|
|
@@ -61,6 +63,8 @@ const STYLE_ATTRS = {
|
|
|
61
63
|
'stroke-linecap': 'lineCap',
|
|
62
64
|
'stroke-linejoin': 'lineJoin',
|
|
63
65
|
'stroke-miterlimit': 'miterLimit',
|
|
66
|
+
'stroke-dasharray': 'dashArray',
|
|
67
|
+
'stroke-dashoffset': 'dashOffset',
|
|
64
68
|
'fill-rule': 'fillRule',
|
|
65
69
|
'clip-rule': 'clipRule',
|
|
66
70
|
'fill-opacity': 'fillOpacity',
|
|
@@ -578,6 +582,12 @@ function resolveStyle(node, parent) {
|
|
|
578
582
|
case 'textAnchor':
|
|
579
583
|
value = v.toLowerCase();
|
|
580
584
|
break;
|
|
585
|
+
case 'dashArray':
|
|
586
|
+
value = dashArrayValue(v);
|
|
587
|
+
break;
|
|
588
|
+
case 'dashOffset':
|
|
589
|
+
value = dashLength(v, true) ?? undefined;
|
|
590
|
+
break;
|
|
581
591
|
case 'markerStart':
|
|
582
592
|
case 'markerMid':
|
|
583
593
|
case 'markerEnd':
|
|
@@ -596,6 +606,42 @@ function resolveStyle(node, parent) {
|
|
|
596
606
|
return style;
|
|
597
607
|
}
|
|
598
608
|
|
|
609
|
+
/**
|
|
610
|
+
* A length in a dash pattern or its offset, in user units: a number, or one
|
|
611
|
+
* in `px` (SVG 2, 13.5.6). A percentage of the viewport's diagonal, or a
|
|
612
|
+
* length in another unit, is none this reads. A negative one is an error in
|
|
613
|
+
* a pattern, and fine in an offset (`signed`).
|
|
614
|
+
*/
|
|
615
|
+
function dashLength(value, signed = false) {
|
|
616
|
+
const m = /^([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(px)?$/i.exec(value.trim());
|
|
617
|
+
if (!m) return null;
|
|
618
|
+
const n = Number(m[1]);
|
|
619
|
+
if (!Number.isFinite(n) || (!signed && n < 0)) return null;
|
|
620
|
+
return n;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* `stroke-dasharray` as `setLineDash` takes it (SVG 2, 13.5.5): `none`, or
|
|
625
|
+
* its lengths, split on commas and white space. One that is not a length,
|
|
626
|
+
* or is negative, makes the value an error, which leaves the property as it
|
|
627
|
+
* was (undefined); lengths that come to nothing draw the stroke solid, as
|
|
628
|
+
* `none` does. An odd count is repeated by `setLineDash`, as the property
|
|
629
|
+
* has it.
|
|
630
|
+
*/
|
|
631
|
+
function dashArrayValue(value) {
|
|
632
|
+
const v = value.trim();
|
|
633
|
+
if (v.toLowerCase() === 'none') return 'none';
|
|
634
|
+
const parts = v.split(/[\s,]+/).filter(Boolean);
|
|
635
|
+
if (!parts.length) return undefined;
|
|
636
|
+
const out = [];
|
|
637
|
+
for (const part of parts) {
|
|
638
|
+
const n = dashLength(part);
|
|
639
|
+
if (n === null) return undefined;
|
|
640
|
+
out.push(n);
|
|
641
|
+
}
|
|
642
|
+
return out.some((n) => n > 0) ? out : 'none';
|
|
643
|
+
}
|
|
644
|
+
|
|
599
645
|
/**
|
|
600
646
|
* How many distinct colours a document actually commits to, decided once at
|
|
601
647
|
* parse time.
|
|
@@ -1179,7 +1225,17 @@ export default class SvgView {
|
|
|
1179
1225
|
ctx.lineCap = style.lineCap;
|
|
1180
1226
|
ctx.lineJoin = style.lineJoin;
|
|
1181
1227
|
ctx.miterLimit = style.miterLimit;
|
|
1228
|
+
// dashed where it says so, and nothing after it is
|
|
1229
|
+
const dashed = style.dashArray !== 'none' && typeof ctx.setLineDash === 'function';
|
|
1230
|
+
if (dashed) {
|
|
1231
|
+
ctx.setLineDash(style.dashArray);
|
|
1232
|
+
ctx.lineDashOffset = style.dashOffset;
|
|
1233
|
+
}
|
|
1182
1234
|
this._ink(ctx, 'stroke', style.stroke, style, path, nodeAlpha * style.strokeOpacity, () => ctx.stroke(path));
|
|
1235
|
+
if (dashed) {
|
|
1236
|
+
ctx.setLineDash([]);
|
|
1237
|
+
ctx.lineDashOffset = 0;
|
|
1238
|
+
}
|
|
1183
1239
|
}
|
|
1184
1240
|
ctx.globalAlpha = 1;
|
|
1185
1241
|
// over its fill and its stroke, which is the default paint order
|
package/lib/xv.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// XVideo: a YCbCr frame handed to the server, which converts it — and can
|
|
2
|
+
// scale it — on the GPU (docs/xv.md).
|
|
3
|
+
//
|
|
4
|
+
// A thin layer over node-x11's Xv requests (x11 >= 4.2 has the image half:
|
|
5
|
+
// QueryImageAttributes, PutImage, ShmPutImage, StopVideo), with what every
|
|
6
|
+
// caller would otherwise write again:
|
|
7
|
+
//
|
|
8
|
+
// - **a port**, chosen and grabbed once: the first image adaptor that draws
|
|
9
|
+
// at the depth asked for, and the first port on it no other client holds;
|
|
10
|
+
// - **the formats ntk can lay out**, by name — `'NV12'`, `'I420'`, `'YV12'`,
|
|
11
|
+
// `'UYVY'`, `'YUY2'`, `'AR24'` — and the layout the port wants each in at a
|
|
12
|
+
// size, `QueryImageAttributes` asked once per size and kept;
|
|
13
|
+
// - **the transport**: a frame goes through a shared-memory segment where
|
|
14
|
+
// MIT-SHM attaches, and down the socket as `PutImage` where it does not and
|
|
15
|
+
// the frame fits one request;
|
|
16
|
+
// - **a target**, because a pixmap is not one. Measured on Xwayland's glamor
|
|
17
|
+
// adaptor: `PutImage` into a depth-24 pixmap is `BadWindow`, into a depth-32
|
|
18
|
+
// one `BadMatch` — the textured adaptors take windows only. A window draws
|
|
19
|
+
// on screen and takes the pointer, which is what a video in a layout must
|
|
20
|
+
// not do, so `VideoTarget` is a child window that is **manually
|
|
21
|
+
// redirected** and placed outside its parent: Composite gives it a pixmap
|
|
22
|
+
// of its own that nothing shows, the adaptor draws into that, and RENDER
|
|
23
|
+
// reads it back out wherever the caller composites it, in paint order.
|
|
24
|
+
//
|
|
25
|
+
// And a clean decline: `openVideoPort` answers `null` where there is no
|
|
26
|
+
// XVideo, no image adaptor at the depth, no port free, or an x11 too old for
|
|
27
|
+
// the image requests — XQuartz and Xvfb advertise the extension and answer
|
|
28
|
+
// "no adaptors present", so a caller always has a path without this one.
|
|
29
|
+
//
|
|
30
|
+
// Ordering: every request here is a void request issued in line with the
|
|
31
|
+
// caller's drawing. A frame through shared memory uses only a segment that is
|
|
32
|
+
// already attached (the discipline of lib/shm-upload.js), so a put lands
|
|
33
|
+
// exactly where the caller made it, and a composite issued after it reads the
|
|
34
|
+
// new frame.
|
|
35
|
+
|
|
36
|
+
import Picture, { OWN_PICTURE } from './picture.js';
|
|
37
|
+
import { safeRelease } from './cleanup.js';
|
|
38
|
+
|
|
39
|
+
/** The formats ntk lays out, by the FOURCC each is known by. */
|
|
40
|
+
export const VIDEO_FOURCC = Object.freeze({
|
|
41
|
+
I420: 0x30323449,
|
|
42
|
+
YV12: 0x32315659,
|
|
43
|
+
NV12: 0x3231564e,
|
|
44
|
+
UYVY: 0x59565955,
|
|
45
|
+
YUY2: 0x32595559,
|
|
46
|
+
AR24: 0x34325241
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const NAME_OF = new Map(Object.entries(VIDEO_FOURCC).map(([name, id]) => [id, name]));
|
|
50
|
+
|
|
51
|
+
// Segments a port keeps for frames in flight: one the server is reading, one
|
|
52
|
+
// being written, and one spare for a frame pushed before the first came back.
|
|
53
|
+
const MAX_SEGMENTS = 3;
|
|
54
|
+
const SEGMENT_QUANTUM = 64 * 1024;
|
|
55
|
+
|
|
56
|
+
const roundUp = (n) => Math.max(SEGMENT_QUANTUM, Math.ceil(n / SEGMENT_QUANTUM) * SEGMENT_QUANTUM);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The planes of a `format` frame at `width` x `height`, in the format's own
|
|
60
|
+
* order: the bytes of picture a row of each holds, and how many rows. Chroma
|
|
61
|
+
* subsampled by two is half the size, rounded up; a 4:2:2 packed row is a
|
|
62
|
+
* whole number of pixel pairs.
|
|
63
|
+
*/
|
|
64
|
+
export function videoPlanes(format, width, height) {
|
|
65
|
+
const cw = Math.ceil(width / 2);
|
|
66
|
+
const ch = Math.ceil(height / 2);
|
|
67
|
+
switch (format) {
|
|
68
|
+
case 'I420':
|
|
69
|
+
case 'YV12':
|
|
70
|
+
return [
|
|
71
|
+
{ rowBytes: width, rows: height },
|
|
72
|
+
{ rowBytes: cw, rows: ch },
|
|
73
|
+
{ rowBytes: cw, rows: ch }
|
|
74
|
+
];
|
|
75
|
+
case 'NV12':
|
|
76
|
+
return [
|
|
77
|
+
{ rowBytes: width, rows: height },
|
|
78
|
+
{ rowBytes: cw * 2, rows: ch }
|
|
79
|
+
];
|
|
80
|
+
case 'UYVY':
|
|
81
|
+
case 'YUY2':
|
|
82
|
+
return [{ rowBytes: cw * 4, rows: height }];
|
|
83
|
+
case 'AR24':
|
|
84
|
+
return [{ rowBytes: width * 4, rows: height }];
|
|
85
|
+
}
|
|
86
|
+
throw new TypeError(`ntk: Xv format ${JSON.stringify(format)} is not one ntk lays out`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copy a frame's planes into `out` at the port's layout: plane `i` at
|
|
91
|
+
* `layout.offsets[i]`, `layout.pitches[i]` bytes a row. `planes` are in the
|
|
92
|
+
* format's own order, `strides` their bytes per row (packed when left out).
|
|
93
|
+
* A plane whose rows already sit at the port's pitch goes in one copy.
|
|
94
|
+
*/
|
|
95
|
+
export function packVideoFrame(format, width, height, planes, strides, layout, out) {
|
|
96
|
+
const geometry = videoPlanes(format, width, height);
|
|
97
|
+
for (let i = 0; i < geometry.length; i++) {
|
|
98
|
+
const { rowBytes, rows } = geometry[i];
|
|
99
|
+
const src = planes[i];
|
|
100
|
+
const stride = strides?.[i] ?? rowBytes;
|
|
101
|
+
const pitch = layout.pitches[i];
|
|
102
|
+
const at = layout.offsets[i];
|
|
103
|
+
if (stride === pitch) {
|
|
104
|
+
const length = pitch * (rows - 1) + rowBytes;
|
|
105
|
+
out.set(src.subarray(0, length), at);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (let row = 0; row < rows; row++) {
|
|
109
|
+
const from = row * stride;
|
|
110
|
+
out.set(src.subarray(from, from + rowBytes), at + row * pitch);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const call = (fn, ...args) =>
|
|
117
|
+
new Promise((resolve, reject) => fn(...args, (err, value) => (err ? reject(err) : resolve(value))));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Open a port that draws frames at `depth` (24, the depth every textured
|
|
121
|
+
* adaptor measured offers), or `null` where this server has none to give.
|
|
122
|
+
*
|
|
123
|
+
* Grabs the port: Xv lets any client put through an ungrabbed one, but an
|
|
124
|
+
* attribute another client sets — the colour space — would then change our
|
|
125
|
+
* frames under us. Release it with `close()`; the server releases it with
|
|
126
|
+
* the connection.
|
|
127
|
+
*
|
|
128
|
+
* @param {import('./app.js').default} app
|
|
129
|
+
* @param {{ depth?: number }} [options]
|
|
130
|
+
* @returns {Promise<VideoPort|null>}
|
|
131
|
+
*/
|
|
132
|
+
export async function openVideoPort(app, { depth = 24 } = {}) {
|
|
133
|
+
const Xv = await app.xv();
|
|
134
|
+
// x11 < 4.2 answers the extension with the query half only
|
|
135
|
+
if (!Xv || typeof Xv.PutImage !== 'function' || typeof Xv.QueryImageAttributes !== 'function') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const screen = app.display.screen[0];
|
|
139
|
+
let adaptors;
|
|
140
|
+
try {
|
|
141
|
+
adaptors = await call(Xv.QueryAdaptors, screen.root);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const IMAGE = Xv.Type.ImageMask | Xv.Type.InputMask;
|
|
146
|
+
for (const adaptor of adaptors) {
|
|
147
|
+
if ((adaptor.type & IMAGE) !== IMAGE) continue;
|
|
148
|
+
const drawsAt = adaptor.formats.find((f) => f.depth === depth);
|
|
149
|
+
if (!drawsAt) continue;
|
|
150
|
+
for (let port = adaptor.baseId; port < adaptor.baseId + adaptor.numPorts; port++) {
|
|
151
|
+
let status;
|
|
152
|
+
try {
|
|
153
|
+
status = await call(Xv.GrabPort, port, 0);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (status !== Xv.GrabPortStatus.Success) continue;
|
|
158
|
+
try {
|
|
159
|
+
const [formats, attributes, composite, pictFormat] = await Promise.all([
|
|
160
|
+
call(Xv.ListImageFormats, port),
|
|
161
|
+
call(Xv.QueryPortAttributes, port),
|
|
162
|
+
app.composite(),
|
|
163
|
+
app.pictFormatFor(drawsAt.visual, { depth })
|
|
164
|
+
]);
|
|
165
|
+
const colorspace = attributes.find(
|
|
166
|
+
(a) => a.name === 'XV_COLORSPACE' && a.flags & Xv.AttributeFlag.Settable && a.min <= 0 && a.max >= 1
|
|
167
|
+
);
|
|
168
|
+
const colorspaceAtom = colorspace ? await call(app.X.InternAtom.bind(app.X), false, 'XV_COLORSPACE') : null;
|
|
169
|
+
await new Promise((resolve) => app.shm.resolve(resolve));
|
|
170
|
+
const opened = new VideoPort(app, Xv, {
|
|
171
|
+
id: port,
|
|
172
|
+
adaptor: adaptor.name,
|
|
173
|
+
depth,
|
|
174
|
+
visual: drawsAt.visual,
|
|
175
|
+
pictFormat,
|
|
176
|
+
formats: formats.map((f) => NAME_OF.get(f.id)).filter(Boolean),
|
|
177
|
+
colorspaceAtom,
|
|
178
|
+
composite
|
|
179
|
+
});
|
|
180
|
+
// the app closes what is still open with the connection
|
|
181
|
+
(app._videoPorts ??= new Set()).add(opened);
|
|
182
|
+
return opened;
|
|
183
|
+
} catch {
|
|
184
|
+
safeRelease(app.X, () => Xv.UngrabPort(port, 0));
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One grabbed Xv port: what it takes, and `putImage` into a window — most
|
|
194
|
+
* usefully a `VideoTarget`'s (`createTarget`).
|
|
195
|
+
*/
|
|
196
|
+
export class VideoPort {
|
|
197
|
+
constructor(app, Xv, { id, adaptor, depth, visual, pictFormat, formats, colorspaceAtom, composite }) {
|
|
198
|
+
this.app = app;
|
|
199
|
+
this.X = app.X;
|
|
200
|
+
this.Xv = Xv;
|
|
201
|
+
/** the port's XID */
|
|
202
|
+
this.id = id;
|
|
203
|
+
/** the adaptor's name, as the server gives it — `'glamor textured video'` */
|
|
204
|
+
this.adaptor = adaptor;
|
|
205
|
+
/** the depth of every drawable this port draws into */
|
|
206
|
+
this.depth = depth;
|
|
207
|
+
this.visual = visual;
|
|
208
|
+
this.pictFormat = pictFormat;
|
|
209
|
+
/** the formats the port takes that ntk can lay out, by name */
|
|
210
|
+
this.formats = Object.freeze(formats);
|
|
211
|
+
/**
|
|
212
|
+
* The colour matrices a frame can be put in. Every adaptor reads BT.601
|
|
213
|
+
* when told nothing; one with a settable `XV_COLORSPACE` — glamor's, and
|
|
214
|
+
* the Intel drivers' — reads BT.709 at 1. Every adaptor ntk knows reads
|
|
215
|
+
* video range, black at 16: a full-range frame is not one to put here.
|
|
216
|
+
*/
|
|
217
|
+
this.colorSpaces = Object.freeze(colorspaceAtom ? ['bt601', 'bt709'] : ['bt601']);
|
|
218
|
+
this.closed = false;
|
|
219
|
+
this._composite = composite;
|
|
220
|
+
this._colorspaceAtom = colorspaceAtom;
|
|
221
|
+
// Unknown until set: a port's attributes are the server's and outlive
|
|
222
|
+
// the client that set them, so the last one to hold the port — this
|
|
223
|
+
// program's last run, as often as not — left it at whatever it used.
|
|
224
|
+
this._colorspace = null;
|
|
225
|
+
this._layouts = new Map();
|
|
226
|
+
this._pool = [];
|
|
227
|
+
this._segments = 0;
|
|
228
|
+
this._warming = false;
|
|
229
|
+
this._gc = 0;
|
|
230
|
+
this._colormap = 0;
|
|
231
|
+
this._drawables = new Set();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether `createTarget` can make a target here: it needs Composite. */
|
|
235
|
+
get targets() {
|
|
236
|
+
return Boolean(this._composite) && !this.closed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The port's layout for a `format` frame at `width` x `height` —
|
|
241
|
+
* `{ width, height, dataSize, pitches, offsets }`, the size possibly
|
|
242
|
+
* rounded up — or `null` the first time it is asked, while the query is in
|
|
243
|
+
* flight. `prepare` is the same question awaited.
|
|
244
|
+
*/
|
|
245
|
+
layout(format, width, height) {
|
|
246
|
+
const key = `${format} ${width}x${height}`;
|
|
247
|
+
const known = this._layouts.get(key);
|
|
248
|
+
if (known && typeof known.then !== 'function') return known;
|
|
249
|
+
if (!known) this._query(key, format, width, height).catch(() => {});
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `layout`, awaited: resolves once the port has said. */
|
|
254
|
+
prepare(format, width, height) {
|
|
255
|
+
const key = `${format} ${width}x${height}`;
|
|
256
|
+
const known = this._layouts.get(key);
|
|
257
|
+
if (known) return Promise.resolve(known);
|
|
258
|
+
return this._query(key, format, width, height);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_query(key, format, width, height) {
|
|
262
|
+
const fourcc = VIDEO_FOURCC[format];
|
|
263
|
+
if (!fourcc || !this.formats.includes(format)) {
|
|
264
|
+
return Promise.reject(new Error(`ntk: Xv port ${this.id} takes no ${format}`));
|
|
265
|
+
}
|
|
266
|
+
const pending = call(this.Xv.QueryImageAttributes, this.id, fourcc, width, height).then(
|
|
267
|
+
(layout) => {
|
|
268
|
+
this._layouts.set(key, layout);
|
|
269
|
+
return layout;
|
|
270
|
+
},
|
|
271
|
+
(err) => {
|
|
272
|
+
this._layouts.delete(key);
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
this._layouts.set(key, pending);
|
|
277
|
+
return pending;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Set the matrix the next frame is read with. False for one this port
|
|
282
|
+
* cannot be told — and BT.601 is what an adaptor with nothing to set reads.
|
|
283
|
+
*/
|
|
284
|
+
setColorSpace(colorSpace) {
|
|
285
|
+
if (!this.colorSpaces.includes(colorSpace)) return false;
|
|
286
|
+
if (!this._colorspaceAtom) return true;
|
|
287
|
+
const value = colorSpace === 'bt709' ? 1 : 0;
|
|
288
|
+
if (value !== this._colorspace) {
|
|
289
|
+
this.Xv.SetPortAttribute(this.id, this._colorspaceAtom, value);
|
|
290
|
+
this._colorspace = value;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* One frame into `drawable`, a window at the port's depth: converted, and
|
|
297
|
+
* scaled from `src` (frame pixels, the whole frame when left out) to `dst`
|
|
298
|
+
* (drawable pixels, the frame's size at 0,0 when left out).
|
|
299
|
+
*
|
|
300
|
+
* `planes` are the frame's, in the format's own order (`[y, u, v]` for
|
|
301
|
+
* I420, `[y, v, u]` for YV12, `[y, uv]` for NV12, one for the packed ones),
|
|
302
|
+
* with `strides` where a decoder pads its rows; they are copied before
|
|
303
|
+
* this returns, so the caller may reuse them at once.
|
|
304
|
+
*
|
|
305
|
+
* Answers whether the frame was sent. False — and nothing on the wire —
|
|
306
|
+
* for a format the port does not take, a colour space it cannot be told,
|
|
307
|
+
* a size whose layout is not known yet (the first ask starts the query),
|
|
308
|
+
* or a frame too large for one request on a connection with no shared
|
|
309
|
+
* memory. A caller that gets false draws the frame its own way.
|
|
310
|
+
*
|
|
311
|
+
* @param {number} drawable
|
|
312
|
+
* @param {object} frame
|
|
313
|
+
* @param {string} frame.format
|
|
314
|
+
* @param {number} frame.width
|
|
315
|
+
* @param {number} frame.height
|
|
316
|
+
* @param {ArrayLike<Uint8Array>} frame.planes
|
|
317
|
+
* @param {number[]} [frame.strides]
|
|
318
|
+
* @param {{x:number,y:number,width:number,height:number}} [frame.src]
|
|
319
|
+
* @param {{x:number,y:number,width:number,height:number}} [frame.dst]
|
|
320
|
+
* @param {'bt601'|'bt709'} [frame.colorSpace]
|
|
321
|
+
* @returns {boolean}
|
|
322
|
+
*/
|
|
323
|
+
putImage(drawable, { format, width, height, planes, strides, src, dst, colorSpace = 'bt601' }) {
|
|
324
|
+
if (this.closed) return false;
|
|
325
|
+
const fourcc = VIDEO_FOURCC[format];
|
|
326
|
+
if (!fourcc || !this.formats.includes(format)) return false;
|
|
327
|
+
const layout = this.layout(format, width, height);
|
|
328
|
+
if (!layout) return false;
|
|
329
|
+
// AR24 is RGB: no matrix to read it with, and no attribute to send
|
|
330
|
+
if (format !== 'AR24' && !this.setColorSpace(colorSpace)) return false;
|
|
331
|
+
const s = src ?? { x: 0, y: 0, width, height };
|
|
332
|
+
const d = dst ?? { x: 0, y: 0, width, height };
|
|
333
|
+
const image = {
|
|
334
|
+
srcX: s.x,
|
|
335
|
+
srcY: s.y,
|
|
336
|
+
srcWidth: s.width,
|
|
337
|
+
srcHeight: s.height,
|
|
338
|
+
drwX: d.x,
|
|
339
|
+
drwY: d.y,
|
|
340
|
+
drwWidth: d.width,
|
|
341
|
+
drwHeight: d.height,
|
|
342
|
+
width: layout.width,
|
|
343
|
+
height: layout.height
|
|
344
|
+
};
|
|
345
|
+
const gc = this._gcFor(drawable);
|
|
346
|
+
const segment = this._acquire(layout.dataSize);
|
|
347
|
+
if (segment) {
|
|
348
|
+
packVideoFrame(format, width, height, planes, strides, layout, segment.buffer);
|
|
349
|
+
segment.commit(0, layout.dataSize);
|
|
350
|
+
this.Xv.ShmPutImage(this.id, drawable, gc, segment.shmseg, fourcc, {
|
|
351
|
+
...image,
|
|
352
|
+
offset: 0,
|
|
353
|
+
sendEvent: true
|
|
354
|
+
});
|
|
355
|
+
// back in the pool only once the server has read it
|
|
356
|
+
segment.once('complete', () => this._release(segment));
|
|
357
|
+
} else {
|
|
358
|
+
// Down the socket, and node-x11 queues the payload by reference: a
|
|
359
|
+
// buffer of the frame's own, never the segment's.
|
|
360
|
+
const data = Buffer.alloc(layout.dataSize);
|
|
361
|
+
packVideoFrame(format, width, height, planes, strides, layout, data);
|
|
362
|
+
try {
|
|
363
|
+
this.Xv.PutImage(this.id, drawable, gc, fourcc, { ...image, data });
|
|
364
|
+
} catch {
|
|
365
|
+
// larger than one request can carry here: refused before sending
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
this._drawables.add(drawable);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Stop whatever this port is putting into `drawable` — before the
|
|
374
|
+
* drawable goes, so the server drops what it keeps for the pair. */
|
|
375
|
+
stop(drawable) {
|
|
376
|
+
if (!this._drawables.delete(drawable)) return;
|
|
377
|
+
safeRelease(this.X, () => this.Xv.StopVideo(this.id, drawable));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** `drawable` went with its window, and the server dropped what it kept
|
|
381
|
+
* for the pair itself: nothing to send. */
|
|
382
|
+
forget(drawable) {
|
|
383
|
+
this._drawables.delete(drawable);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A `VideoTarget` under `parent` (an ntk Window or a window id) at
|
|
388
|
+
* `width` x `height`, or null where this server has no Composite.
|
|
389
|
+
*/
|
|
390
|
+
createTarget(parent, { width, height }) {
|
|
391
|
+
if (!this.targets) return null;
|
|
392
|
+
return new VideoTarget(this, parent, { width, height });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Ungrab the port and let go of its segments. Idempotent. */
|
|
396
|
+
close() {
|
|
397
|
+
if (this.closed) return;
|
|
398
|
+
this.closed = true;
|
|
399
|
+
this.app._videoPorts?.delete(this);
|
|
400
|
+
const X = this.X;
|
|
401
|
+
safeRelease(X, () => {
|
|
402
|
+
for (const drawable of this._drawables) this.Xv.StopVideo(this.id, drawable);
|
|
403
|
+
this.Xv.UngrabPort(this.id, 0);
|
|
404
|
+
if (this._gc) X.FreeGC(this._gc);
|
|
405
|
+
if (this._colormap) X.FreeColormap(this._colormap);
|
|
406
|
+
});
|
|
407
|
+
this._drawables.clear();
|
|
408
|
+
const pool = this._pool;
|
|
409
|
+
this._pool = [];
|
|
410
|
+
for (const segment of pool) detach(segment);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The port's GC: made on the first drawable it draws into, valid for
|
|
414
|
+
* every drawable at its depth on the screen. */
|
|
415
|
+
_gcFor(drawable) {
|
|
416
|
+
if (!this._gc) {
|
|
417
|
+
this._gc = this.X.AllocID();
|
|
418
|
+
this.X.CreateGC(this._gc, drawable, { graphicsExposures: 0 });
|
|
419
|
+
}
|
|
420
|
+
return this._gc;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The colormap a window on the port's visual is created with. */
|
|
424
|
+
_colormapFor() {
|
|
425
|
+
const screen = this.app.display.screen[0];
|
|
426
|
+
if (this.visual === screen.root_visual) return screen.default_colormap;
|
|
427
|
+
if (!this._colormap) this._colormap = this.app.createColormap(this.visual);
|
|
428
|
+
return this._colormap;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A free attached segment of at least `bytes`, or null — warming one for
|
|
433
|
+
* next time when there is room. A segment too small for the frames now
|
|
434
|
+
* being put is let go on the way past.
|
|
435
|
+
*/
|
|
436
|
+
_acquire(bytes) {
|
|
437
|
+
const shm = this.app.shm;
|
|
438
|
+
if (!shm.ready) return null;
|
|
439
|
+
for (let i = 0; i < this._pool.length; i++) {
|
|
440
|
+
const segment = this._pool[i];
|
|
441
|
+
if (segment.size >= bytes) return this._pool.splice(i, 1)[0];
|
|
442
|
+
}
|
|
443
|
+
for (let i = this._pool.length - 1; i >= 0; i--) {
|
|
444
|
+
detach(this._pool[i]);
|
|
445
|
+
this._segments--;
|
|
446
|
+
}
|
|
447
|
+
this._pool.length = 0;
|
|
448
|
+
if (!this._warming && this._segments < MAX_SEGMENTS) {
|
|
449
|
+
this._warming = true;
|
|
450
|
+
this._segments++;
|
|
451
|
+
shm.ext.createSegment(roundUp(bytes), (err, segment) => {
|
|
452
|
+
this._warming = false;
|
|
453
|
+
if (err || !segment) {
|
|
454
|
+
this._segments--;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (this.closed) detach(segment);
|
|
458
|
+
else this._pool.push(segment);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_release(segment) {
|
|
465
|
+
if (this.closed) detach(segment);
|
|
466
|
+
else this._pool.push(segment);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function detach(segment) {
|
|
471
|
+
try {
|
|
472
|
+
segment.detach();
|
|
473
|
+
} catch {
|
|
474
|
+
// the connection went first
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Where a port draws a frame that is not to be seen yet: a child window,
|
|
480
|
+
* manually redirected and placed outside its parent, so the server keeps
|
|
481
|
+
* its pixels in a pixmap nothing shows and the pointer never finds it.
|
|
482
|
+
* Shaped like an `Image` — `width`, `height`, `picture(app)` — so
|
|
483
|
+
* `ctx.drawImage(target, …)` composites the last frame put into it, and
|
|
484
|
+
* `copyTo(surface)` keeps one.
|
|
485
|
+
*
|
|
486
|
+
* Its pixels last as long as its parent is mapped: a window that is not
|
|
487
|
+
* viewable has no redirected pixmap, so put a frame and copy it out in the
|
|
488
|
+
* same breath, while the parent is on screen.
|
|
489
|
+
*/
|
|
490
|
+
export class VideoTarget {
|
|
491
|
+
constructor(port, parent, { width, height }) {
|
|
492
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
493
|
+
throw new Error('VideoTarget: width and height must be positive integers');
|
|
494
|
+
}
|
|
495
|
+
const app = port.app;
|
|
496
|
+
const X = app.X;
|
|
497
|
+
this.port = port;
|
|
498
|
+
this.app = app;
|
|
499
|
+
this.width = width;
|
|
500
|
+
this.height = height;
|
|
501
|
+
this.id = X.AllocID();
|
|
502
|
+
const parentId = typeof parent === 'number' ? parent : parent.id;
|
|
503
|
+
// Outside the parent, up and to the left: a redirected window still
|
|
504
|
+
// takes the pointer where it is, and a child entirely outside its
|
|
505
|
+
// parent is where the pointer can never be. Its pixels are its own
|
|
506
|
+
// pixmap's either way (measured at -5000,-5000 on Xwayland).
|
|
507
|
+
X.CreateWindow(this.id, parentId, -width - 1, -height - 1, width, height, 0, port.depth, 1, port.visual, {
|
|
508
|
+
// a depth or visual other than the parent's needs both named
|
|
509
|
+
borderPixel: 0,
|
|
510
|
+
colormap: port._colormapFor(),
|
|
511
|
+
// none: the adaptor writes every pixel, and a cleared one is wasted work
|
|
512
|
+
backgroundPixmap: 0
|
|
513
|
+
});
|
|
514
|
+
const composite = port._composite;
|
|
515
|
+
composite.RedirectWindow(this.id, composite.Redirect.Manual);
|
|
516
|
+
X.MapWindow(this.id);
|
|
517
|
+
this._picture = new Picture(app, {
|
|
518
|
+
drawable: { id: this.id },
|
|
519
|
+
format: port.pictFormat ?? app.display.Render.rgb24
|
|
520
|
+
});
|
|
521
|
+
this._destroyed = false;
|
|
522
|
+
// An ntk Window going takes this one with it, and the server frees the
|
|
523
|
+
// window, its picture and the port's hold on it in the same breath:
|
|
524
|
+
// asking for any of them again afterwards would be three X errors. A
|
|
525
|
+
// parent given by id is the caller's to outlive.
|
|
526
|
+
this._parent = typeof parent === 'object' && typeof parent.once === 'function' ? parent : null;
|
|
527
|
+
this._onParentGone = () => this._gone();
|
|
528
|
+
this._parent?.once('_destroyed', this._onParentGone);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The window went with its parent: forget it all, send nothing. */
|
|
532
|
+
_gone() {
|
|
533
|
+
if (this._destroyed) return;
|
|
534
|
+
this._destroyed = true;
|
|
535
|
+
this.port.forget(this.id);
|
|
536
|
+
this._picture.forget();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Gone: destroyed, or taken with its parent window. */
|
|
540
|
+
get destroyed() {
|
|
541
|
+
return this._destroyed;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Whether a frame put now lands anywhere: false once the target is gone,
|
|
546
|
+
* and while an ntk parent is unmapped — a minimized window, one not yet
|
|
547
|
+
* mapped — when the child has no pixmap to hold it and a copy out of it
|
|
548
|
+
* would read whatever the server has there. A parent given by id is
|
|
549
|
+
* taken to be on screen.
|
|
550
|
+
*/
|
|
551
|
+
get viewable() {
|
|
552
|
+
return !this._destroyed && (this._parent === null || this._parent._mapped === true);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* A frame into the target, scaled to fill it: `port.putImage` with the
|
|
557
|
+
* destination named. False where the target is not `viewable`, or the
|
|
558
|
+
* port declined (see there).
|
|
559
|
+
*/
|
|
560
|
+
put(frame) {
|
|
561
|
+
if (!this.viewable) return false;
|
|
562
|
+
return this.port.putImage(this.id, {
|
|
563
|
+
...frame,
|
|
564
|
+
dst: { x: 0, y: 0, width: this.width, height: this.height }
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The picture, read as stored, as `Surface.picture(app)` is. */
|
|
569
|
+
picture(app) {
|
|
570
|
+
return this[OWN_PICTURE](app)._settle();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
[OWN_PICTURE](app) {
|
|
574
|
+
if (app && app !== this.app) {
|
|
575
|
+
throw new Error('VideoTarget: belongs to a different X connection');
|
|
576
|
+
}
|
|
577
|
+
return this._picture;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The last frame into `destination` — an ntk `Surface`, or anything with
|
|
582
|
+
* its picture — at `dx`, `dy`: one server-side copy, alpha opaque, in
|
|
583
|
+
* line with the put before it.
|
|
584
|
+
*/
|
|
585
|
+
copyTo(destination, dx = 0, dy = 0) {
|
|
586
|
+
if (this._destroyed) return;
|
|
587
|
+
const R = this.app.display.Render;
|
|
588
|
+
const dst = destination[OWN_PICTURE]?.(this.app) ?? destination.picture(this.app);
|
|
589
|
+
R.Composite(R.PictOp.Src, this._picture.id, 0, dst.id, 0, 0, 0, 0, dx, dy, this.width, this.height);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
destroy() {
|
|
593
|
+
if (this._destroyed) return;
|
|
594
|
+
this._destroyed = true;
|
|
595
|
+
this._parent?.off('_destroyed', this._onParentGone);
|
|
596
|
+
this.port.stop(this.id);
|
|
597
|
+
this._picture.destroy();
|
|
598
|
+
safeRelease(this.app.X, () => this.app.X.DestroyWindow(this.id));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
[Symbol.dispose]() {
|
|
602
|
+
this.destroy();
|
|
603
|
+
}
|
|
604
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ntk",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.25.0",
|
|
4
4
|
"description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
|
|
5
5
|
"author": "Andrey Sidorov <sidorares@yandex.ru>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./xembed": "./lib/xembed.js",
|
|
29
29
|
"./color": "./lib/color.js",
|
|
30
30
|
"./imagedata": "./lib/imagedata.js",
|
|
31
|
+
"./xv": "./lib/xv.js",
|
|
31
32
|
"./path": "./lib/path.js",
|
|
32
33
|
"./gl": "./lib/gl.js",
|
|
33
34
|
"./shadow-math": "./lib/shadowmath.js",
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
"x11-dri": ">=0.5.0 <1"
|
|
68
69
|
},
|
|
69
70
|
"scripts": {
|
|
70
|
-
"test": "NTK_STRICT_COLORS=1 node --test",
|
|
71
|
+
"test": "NTK_STRICT_COLORS=1 node --import ./test/helpers/exit-watchdog.js --test",
|
|
71
72
|
"check-release-message": "node scripts/check-release-message.mjs"
|
|
72
73
|
},
|
|
73
74
|
"devDependencies": {
|