react-x11 2.31.0 → 2.33.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.
@@ -17,7 +17,12 @@
17
17
  // A part's content is a raster the presenter paints by calling the part's
18
18
  // own `paint`, at opacity 1 and untransformed, and paints again when its
19
19
  // `version` changes; its opacity and its transform are the layer's, and so
20
- // are its animations, keyframes the bridge hands to Core Animation. A
20
+ // are its animations, keyframes the bridge hands to Core Animation. Or it is
21
+ // a **source**, the part's `contents` — a `<video>`'s frames, or its player —
22
+ // which the presenter does not paint at all: the source shows itself on the
23
+ // layer (src/cocoa/video.js, src/cocoa/player.js), between frames and on its
24
+ // own clock, and the layer is exactly the part's rect. A source this bridge
25
+ // cannot show is a part declined, drawn by its element like any other. A
21
26
  // transform is CSS's matrix, which the bridge takes from
22
27
  // `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
23
28
  // delay a part joined half way through needs, so that is the bridge sprites
@@ -25,8 +30,11 @@
25
30
  // element.
26
31
 
27
32
  import { RASTER_PAD, RasterState } from './presenter.js';
33
+ import { CocoaPlayer, PlayerLift } from './player.js';
34
+ import { VideoLift, canLiftVideo } from './video.js';
28
35
  import { intersectRects } from '../nodes/rects.js';
29
36
  import { DEV } from '../nodes/util.js';
37
+ import { isVideoFrames } from '../videoframes.js';
30
38
 
31
39
  // Past this many device pixels on a side a part is drawn by its element: a
32
40
  // layer's contents are a bitmap, and a page-sized one is a page-sized
@@ -105,12 +113,26 @@ function isAnimation(a) {
105
113
  return true;
106
114
  }
107
115
 
108
- /** Is `sprite` a part as `Node.sprites()` documents one? */
116
+ /** Is `sprite` a part as `Node.sprites()` documents one? Either it paints
117
+ * or it has a source to show, never both. */
109
118
  function isSprite(sprite) {
110
119
  if (sprite == null || typeof sprite.key !== 'string') return false;
111
- if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
120
+ if (!isRect(sprite.rect)) return false;
121
+ if (
122
+ sprite.contents != null
123
+ ? sprite.paint !== undefined
124
+ : typeof sprite.paint !== 'function'
125
+ ) {
126
+ return false;
127
+ }
112
128
  if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
113
129
  if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
130
+ if (
131
+ sprite.clipRadius !== undefined &&
132
+ !(finite(sprite.clipRadius) && sprite.clipRadius >= 0)
133
+ ) {
134
+ return false;
135
+ }
114
136
  if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
115
137
  if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
116
138
  return false;
@@ -204,8 +226,11 @@ function extentOf(sprite) {
204
226
  }
205
227
 
206
228
  /** The raster a part's content is painted into: its reach, out to whole
207
- * pixels and padded for antialiasing, untransformed. */
229
+ * pixels and padded for antialiasing, untransformed. A source's layer is
230
+ * the part's rect, which the element has already put on whole pixels so
231
+ * that the hole it leaves and the layer meet. */
208
232
  function rasterRectOf(sprite) {
233
+ if (sprite.contents != null) return sprite.rect;
209
234
  const reach = sprite.reach ?? sprite.rect;
210
235
  const x = Math.floor(reach.x) - RASTER_PAD;
211
236
  const y = Math.floor(reach.y) - RASTER_PAD;
@@ -236,6 +261,8 @@ export class SpriteLayers {
236
261
  this.supported = Boolean(
237
262
  this.native.transformForms?.()?.includes?.('matrix'),
238
263
  );
264
+ // and a video's frames on a layer need its video surfaces
265
+ this.videos = canLiftVideo(this.native);
239
266
  this.hosts = new Map(); // element -> Map(key -> state)
240
267
  this._warned = new WeakSet();
241
268
  }
@@ -304,26 +331,48 @@ export class SpriteLayers {
304
331
  continue;
305
332
  }
306
333
  if (now.has(sprite.key)) continue;
334
+ const contents = sprite.contents ?? null;
335
+ // a source this bridge cannot show: the element draws it
336
+ if (contents !== null && !this._canShow(contents)) continue;
307
337
  const raster = rasterRectOf(sprite);
308
- if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
338
+ // a raster's limit; a source's surface is its own size, whatever
339
+ // the layer is scaled to
340
+ if (
341
+ contents === null &&
342
+ (raster.width > MAX_SIDE || raster.height > MAX_SIDE)
343
+ ) {
344
+ continue;
345
+ }
309
346
  // what shows of everywhere it can be: inside its own clip, and the
310
347
  // clips of its element's ancestors, which cut its layer (`_clear`)
311
- const cut = { clip: sprite.clip ?? null, bounds: null };
348
+ const cut = {
349
+ clip: sprite.clip ?? null,
350
+ radius: sprite.clip ? (sprite.clipRadius ?? 0) : 0,
351
+ bounds: null,
352
+ };
312
353
  const extent = extentOf(sprite);
313
354
  const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
314
355
  if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
315
356
  const shown = cut.bounds;
316
357
  let state = was?.get(sprite.key);
317
358
  // a clip that comes or goes is a layer in a box or out of one: lifted
318
- // again, rather than a running layer moved between parents
319
- if (state && Boolean(state.clip) !== Boolean(cut.clip)) {
359
+ // again, rather than a running layer moved between parents — and so
360
+ // is a part that shows another source, or stops painting to show one
361
+ if (
362
+ state &&
363
+ (Boolean(state.clip) !== Boolean(cut.clip) ||
364
+ state.contents !== contents)
365
+ ) {
320
366
  this._drop(state, root);
321
367
  state = null;
322
368
  }
323
369
  const lifting = !state;
324
- if (lifting) state = this._lift(host, sprite.key, cut.clip !== null);
370
+ if (lifting) {
371
+ state = this._lift(host, sprite.key, cut.clip !== null, contents);
372
+ }
325
373
  state.sprite = sprite;
326
374
  state.clip = cut.clip;
375
+ state.radius = cut.radius;
327
376
  state.order = [...place, AFTER_CHILDREN, index];
328
377
  // the bitmap under where it shows repaints without it, from this
329
378
  // frame on; where it went since, it is the element's own claims
@@ -370,7 +419,14 @@ export class SpriteLayers {
370
419
  }
371
420
  }
372
421
 
373
- _lift(host, key, clipped) {
422
+ /** Can this bridge show `contents` on a layer by itself? A player always
423
+ * can: it was made by this bridge, with the layer it plays into. */
424
+ _canShow(contents) {
425
+ if (contents instanceof CocoaPlayer) return !contents.released;
426
+ return isVideoFrames(contents) && this.videos;
427
+ }
428
+
429
+ _lift(host, key, clipped, contents = null) {
374
430
  const layer = this.native.createLayer();
375
431
  // a part cut to a clip is in a box of the clip's size that masks to it,
376
432
  // and the box is what stands among the layers above the bitmap
@@ -389,6 +445,7 @@ export class SpriteLayers {
389
445
  box,
390
446
  boxProps: {},
391
447
  clip: null, // device pixels, window coordinates
448
+ radius: 0, // the clip's corners, device pixels
392
449
  raster: new RasterState(),
393
450
  painted: UNPAINTED,
394
451
  rect: null, // the raster's rect, device pixels, window coordinates
@@ -402,6 +459,14 @@ export class SpriteLayers {
402
459
  sprite: null,
403
460
  extent: null,
404
461
  order: null,
462
+ // what shows itself on the layer, for a part with a source
463
+ contents,
464
+ lift:
465
+ contents === null
466
+ ? null
467
+ : contents instanceof CocoaPlayer
468
+ ? new PlayerLift(this.native, contents, layer)
469
+ : new VideoLift(this.native, contents, layer),
405
470
  };
406
471
  }
407
472
 
@@ -409,6 +474,7 @@ export class SpriteLayers {
409
474
  * forgotten, and the bitmap under where it could be repaints with the
410
475
  * element drawing it again. */
411
476
  _drop(state, root) {
477
+ state.lift?.release();
412
478
  this.native.removeFromSuperlayer(state.box ?? state.layer);
413
479
  state.raster.release(this.native);
414
480
  for (const run of state.runs.values()) this._ends().delete(run.caId);
@@ -427,13 +493,14 @@ export class SpriteLayers {
427
493
  const phase = `${reach.x - Math.floor(reach.x)},${reach.y - Math.floor(reach.y)}`;
428
494
  // painted again for a new version, and for a raster of another size or
429
495
  // with the part at another fraction of a pixel; a move by whole pixels
430
- // is the layer's alone
496
+ // is the layer's alone. A source paints nothing: it shows itself.
431
497
  if (
432
- state.painted === UNPAINTED ||
433
- sprite.version !== state.painted ||
434
- state.rect?.width !== rect.width ||
435
- state.rect?.height !== rect.height ||
436
- state.phase !== phase
498
+ !state.lift &&
499
+ (state.painted === UNPAINTED ||
500
+ sprite.version !== state.painted ||
501
+ state.rect?.width !== rect.width ||
502
+ state.rect?.height !== rect.height ||
503
+ state.phase !== phase)
437
504
  ) {
438
505
  this._paint(state, sprite, rect);
439
506
  state.phase = phase;
@@ -446,14 +513,23 @@ export class SpriteLayers {
446
513
  const clip = state.clip;
447
514
  if (state.box) {
448
515
  const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
516
+ // its corners rounded where the clip's are: a circle's arc, as wide
517
+ // as the box allows
518
+ const cornerRadius =
519
+ Math.min(state.radius, clip.width / 2, clip.height / 2) / s;
449
520
  const was = state.boxProps;
450
- if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
521
+ if (
522
+ !sameMatrix(was.frame, frame) ||
523
+ was.zPosition !== z ||
524
+ was.cornerRadius !== cornerRadius
525
+ ) {
451
526
  this.native.setLayerProps(state.box, {
452
527
  frame,
453
528
  masksToBounds: true,
529
+ cornerRadius,
454
530
  zPosition: z,
455
531
  });
456
- state.boxProps = { frame, zPosition: z };
532
+ state.boxProps = { frame, zPosition: z, cornerRadius };
457
533
  }
458
534
  }
459
535
  const ox = state.box ? clip.x : 0;
@@ -490,6 +566,8 @@ export class SpriteLayers {
490
566
  }
491
567
  if (any) this.native.setLayerProps(state.layer, out);
492
568
  state.props = next;
569
+ // a source that fills the layer with a layer of its own
570
+ state.lift?.layout?.(next.bounds);
493
571
  // after the model went out, in the same transaction
494
572
  this._syncAnimations(state, sprite);
495
573
  }
@@ -595,6 +673,7 @@ export class SpriteLayers {
595
673
  destroy() {
596
674
  for (const lifted of this.hosts.values()) {
597
675
  for (const state of lifted.values()) {
676
+ state.lift?.release();
598
677
  this.native.removeFromSuperlayer(state.box ?? state.layer);
599
678
  state.raster.release(this.native);
600
679
  for (const run of state.runs.values()) this._ends().delete(run.caId);
@@ -0,0 +1,122 @@
1
+ // A video's frames on a layer of their own (docs/architecture/video.md §7):
2
+ // what the surface presenter's sprites (src/cocoa/sprites.js) do with a part
3
+ // whose `contents` is a `VideoFrames` sink instead of a `paint`.
4
+ //
5
+ // Every frame goes into a video surface — an IOSurface the render server
6
+ // scans out as it is, NV12 for a YCbCr sink and BGRA for a BGRA one
7
+ // (@windowkit/appkit's createVideoSurface) — and the layer is repointed at
8
+ // it, inside the `push` that delivered it: a layer change of its own, with
9
+ // no react-x11 frame, no paint and nothing for the frame pacer to price. At
10
+ // 1080p that is the copy in (0.07ms for NV12, 0.27ms for an I420 frame
11
+ // interleaved on the way) and a transaction.
12
+ //
13
+ // The surfaces are a ring, because a surface the render server is still
14
+ // reading — the one on screen, and in threaded mode the one a queued frame
15
+ // has not taken off the layer yet — is torn by a write. A frame goes into
16
+ // one `videoSurfaceIsInUse` says nothing reads, a new one is made while the
17
+ // ring is short, and with every one of them busy the frame waits for the
18
+ // next push or a short retry: the newest frame is the frame, and a dropped
19
+ // one is the cheapest thing to lose.
20
+ //
21
+ // The verbs are asked for by name (`canLiftVideo`), and a bridge without
22
+ // them lifts nothing: the part is declined and the element draws its frames,
23
+ // which is what it does on every other backend.
24
+
25
+ // Two would do in pump mode — one on screen, one being written — and a
26
+ // worker's queued flip holds a third.
27
+ const RING_MAX = 4;
28
+ // With the whole ring busy, how soon to try the newest frame again: about a
29
+ // display frame, since that is how often the render server lets one go.
30
+ const RETRY_MS = 8;
31
+
32
+ /** Has `native` the verbs a lifted video needs? */
33
+ export function canLiftVideo(native) {
34
+ return (
35
+ typeof native?.createVideoSurface === 'function' &&
36
+ typeof native.writeVideoSurface === 'function' &&
37
+ typeof native.videoSurfaceIsInUse === 'function' &&
38
+ typeof native.setLayerContentsIOSurface === 'function'
39
+ );
40
+ }
41
+
42
+ /**
43
+ * A sink's frames on `layer`, from the frame it holds now until `release()`.
44
+ * Placing the layer — its bounds, its place among the others, the box that
45
+ * cuts it to a clip — is the presenter's; this only ever sets what it shows.
46
+ */
47
+ export class VideoLift {
48
+ constructor(native, sink, layer) {
49
+ this.native = native;
50
+ this.sink = sink;
51
+ this.layer = layer;
52
+ this.ring = []; // { handle, id }
53
+ this.current = null;
54
+ this.shown = 0; // the version on the layer
55
+ this.released = false;
56
+ this._retry = null;
57
+ this._unsubscribe = sink._subscribe((frame) => this._show(frame));
58
+ if (sink.frame) this._show(sink.frame);
59
+ }
60
+
61
+ /** A surface nothing reads, or a new one while the ring is short. */
62
+ _free() {
63
+ const native = this.native;
64
+ for (const s of this.ring) {
65
+ if (s !== this.current && !native.videoSurfaceIsInUse(s.handle)) {
66
+ return s;
67
+ }
68
+ }
69
+ if (this.ring.length >= RING_MAX) return null;
70
+ const sink = this.sink;
71
+ const made = native.createVideoSurface(sink.width, sink.height, {
72
+ // a YCbCr frame stays YCbCr, I420 interleaved by the write; the
73
+ // render server converts it, in the colour the sink says it is
74
+ format: sink.format === 'BGRA' ? 'BGRA' : 'NV12',
75
+ colorSpace: sink.colorSpace,
76
+ range: sink.range,
77
+ });
78
+ const s = { handle: made.handle, id: made.iosurfaceId };
79
+ this.ring.push(s);
80
+ return s;
81
+ }
82
+
83
+ _show(frame) {
84
+ if (this.released || frame === null) return;
85
+ const s = this._free();
86
+ if (!s) {
87
+ this._again();
88
+ return;
89
+ }
90
+ this.native.writeVideoSurface(s.handle, this.sink.format, frame.planes, {
91
+ strides: frame.strides,
92
+ });
93
+ // its own transaction, actions off, in pump mode; a command of its own
94
+ // from a worker
95
+ this.native.setLayerContentsIOSurface(this.layer, s.id);
96
+ this.current = s;
97
+ this.shown = frame.version;
98
+ }
99
+
100
+ _again() {
101
+ if (this._retry) return;
102
+ this._retry = setTimeout(() => {
103
+ this._retry = null;
104
+ const frame = this.sink.frame;
105
+ if (frame && frame.version !== this.shown) this._show(frame);
106
+ }, RETRY_MS);
107
+ this._retry.unref?.();
108
+ }
109
+
110
+ /** Off the layer: no more frames, and the surfaces freed — the layer
111
+ * holds the one it shows until the presenter takes it away. */
112
+ release() {
113
+ if (this.released) return;
114
+ this.released = true;
115
+ this._unsubscribe();
116
+ if (this._retry) clearTimeout(this._retry);
117
+ this._retry = null;
118
+ for (const s of this.ring) this.native.releaseVideoSurface?.(s.handle);
119
+ this.ring = [];
120
+ this.current = null;
121
+ }
122
+ }
package/src/host.js CHANGED
@@ -24,6 +24,7 @@ const BUILT_IN = Object.freeze([
24
24
  'box',
25
25
  'text',
26
26
  'image',
27
+ 'video',
27
28
  'canvas',
28
29
  'textinput',
29
30
  'textarea',
package/src/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export * from './types/tray.js';
32
32
  export * from './types/permissions.js';
33
33
  export * from './types/notifications.js';
34
34
  export * from './types/desktopcalendar.js';
35
+ export * from './types/video.js';
35
36
 
36
37
  /**
37
38
  * The XID of the X11 window a ref points at, or `null` if there is not one
@@ -220,9 +221,21 @@ export function useClipboard(): Clipboard;
220
221
  * above everything the X server draws: nothing can be drawn over one there.
221
222
  * Ask it before handing a surface its HUD. A property of the backend and the
222
223
  * display, settled before the first render.
224
+ *
225
+ * `'mediaPlayback'` is whether this display plays a file or URL itself — a
226
+ * platform player, AVFoundation on macOS over a bridge with its player verbs
227
+ * — which `<video src>` needs. False on X11 and Wayland for good; ask it
228
+ * before rendering a `<video src>`, which refuses with one `onError` and
229
+ * shows its poster where it is false. `<video frames>` needs no capability.
230
+ * A property of the backend, and it never changes.
223
231
  */
224
232
  export type SupportsFeature =
225
- 'transparency' | 'shaders' | 'nativeControls' | 'embedding' | 'glOverlay';
233
+ | 'transparency'
234
+ | 'shaders'
235
+ | 'nativeControls'
236
+ | 'embedding'
237
+ | 'glOverlay'
238
+ | 'mediaPlayback';
226
239
 
227
240
  /**
228
241
  * Can this **display** do something? `'transparency'` is true when the
package/src/index.js CHANGED
@@ -75,6 +75,13 @@ export {
75
75
  export { useDesktopCalendarEvents } from './desktopcalendarhooks.js';
76
76
  export { parseUriList } from './transfer.js';
77
77
  export { useApp, useClipboard, useSupports } from './appcontext.js';
78
+ export {
79
+ createVideoFrames,
80
+ VIDEO_FORMATS,
81
+ VideoFrames,
82
+ } from './videoframes.js';
83
+ export { useVideoFrames } from './videohooks.js';
84
+ export { NoMediaPlaybackError } from './mediaplayback.js';
78
85
  export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
79
86
  export { announce } from './a11y.js';
80
87
  // the standard Undo/Cut/Copy/Paste menu, for an element that edits or
@@ -0,0 +1,37 @@
1
+ // Whether this display plays a file or URL itself — `<video src>` — and the
2
+ // error a `<video src>` reports where it does not.
3
+ //
4
+ // `<video>` asks before it opens anything, and `useSupports('mediaPlayback')`
5
+ // asks for a component deciding what to render. One function for both, so
6
+ // the two cannot disagree (#531's rule, src/embedding.js's shape). A feature
7
+ // test on the app, not a question about which backend this is: the answer is
8
+ // yes where the app can make a player — the Cocoa app over a bridge with
9
+ // AVFoundation's player verbs — and no on X11 and Wayland for good, where no
10
+ // display-server player exists and the application decodes into `frames`.
11
+ //
12
+ // A leaf module on purpose: it imports nothing of ours, so both
13
+ // `appcontext.js` and the node layer can use it.
14
+
15
+ /** Can `app` play a `<video src>` with a platform player? */
16
+ export function canPlayMedia(app) {
17
+ return typeof app?.createPlayer === 'function';
18
+ }
19
+
20
+ /**
21
+ * `<video src>` where this backend has no platform player — the error
22
+ * `onError` is handed, so an application can tell it from a file that would
23
+ * not play.
24
+ */
25
+ export class NoMediaPlaybackError extends Error {
26
+ constructor() {
27
+ super(
28
+ 'react-x11: <video src> needs a platform media player, and this ' +
29
+ 'backend has none — so the poster is shown instead. Ask ' +
30
+ "useSupports('mediaPlayback') before rendering one, and give a " +
31
+ 'backend without a player frames you decode yourself: <video ' +
32
+ 'frames={useVideoFrames(…)}> works everywhere (docs/elements.md#video).',
33
+ );
34
+ this.name = 'NoMediaPlaybackError';
35
+ this.code = 'ENOMEDIAPLAYBACK';
36
+ }
37
+ }
package/src/node.d.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  MouseEvent,
25
25
  WheelEvent,
26
26
  } from './types/events.js';
27
+ import type { VideoFrames } from './types/video.js';
27
28
 
28
29
  /** A gradient, as `createLinearGradient` answers with. */
29
30
  export interface CanvasGradientLike {
@@ -289,9 +290,23 @@ export interface Sprite {
289
290
  * every ancestor with square corners (a rounded one has to hold all of
290
291
  * it). */
291
292
  clip?: Rect;
293
+ /** The radius of `clip`'s corners, in device pixels: a circular arc at
294
+ * each of the four, as a card with a `borderRadius` cuts what it holds.
295
+ * 0 when left out. A clipping ancestor with square corners that would
296
+ * cut the clip again keeps the part with its element, since one box
297
+ * cannot take both. */
298
+ clipRadius?: number;
292
299
  /** Draws the part where it is in the window, as `paint` would, but at
293
- * opacity 1 and untransformed. */
294
- paint(ctx: Context2D): void;
300
+ * opacity 1 and untransformed. A part with `contents` has none. */
301
+ paint?(ctx: Context2D): void;
302
+ /**
303
+ * A source that shows itself on the part's layer instead of a paint — a
304
+ * `VideoFrames` sink, whose every push reaches the layer with no frame of
305
+ * the window's own. `rect` is the picture, on whole pixels; a presenter
306
+ * that cannot show the source declines the part, and the element draws
307
+ * the current frame itself (docs/extending.md).
308
+ */
309
+ contents?: VideoFrames;
295
310
  /** Compared with `===` from frame to frame; a change paints the part
296
311
  * again. Left out, it is painted once. */
297
312
  version?: unknown;
@@ -0,0 +1,46 @@
1
+ // `objectFit`: where a picture with a size of its own goes in a box that has
2
+ // another — CSS's `object-fit`, for `<image>` and `<video>`, centred the way
3
+ // CSS's default `object-position: 50% 50%` centres it. A leaf: no node, no
4
+ // backend, rects in and a rect out.
5
+
6
+ export const OBJECT_FITS = Object.freeze([
7
+ 'fill',
8
+ 'contain',
9
+ 'cover',
10
+ 'none',
11
+ 'scale-down',
12
+ ]);
13
+
14
+ /** The element's `objectFit`, or its default where the style names none. */
15
+ export function objectFitOf(style, fallback) {
16
+ const fit = style?.objectFit;
17
+ return OBJECT_FITS.includes(fit) ? fit : fallback;
18
+ }
19
+
20
+ /**
21
+ * Where a `width` x `height` picture goes in `box` under `fit`: `fill`
22
+ * stretches it to the box, `contain` scales it to fit inside with bars,
23
+ * `cover` scales it to fill with the overflow cut (by the caller), `none`
24
+ * leaves it its own size, and `scale-down` is `none` or `contain`, whichever
25
+ * is smaller. Its edges are snapped to whole pixels, so a picture drawn
26
+ * there and a layer placed there cover exactly the same ones — except under
27
+ * `fill`, which is the box itself, as an `<image>` has always been drawn.
28
+ */
29
+ export function fitRect(box, width, height, fit) {
30
+ if (fit === 'fill' || !(width > 0 && height > 0)) return { ...box };
31
+ let k;
32
+ if (fit === 'none') k = 1;
33
+ else {
34
+ const sx = box.width / width;
35
+ const sy = box.height / height;
36
+ k = fit === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
37
+ if (fit === 'scale-down') k = Math.min(k, 1);
38
+ }
39
+ const w = width * k;
40
+ const h = height * k;
41
+ const x0 = Math.round(box.x + (box.width - w) / 2);
42
+ const y0 = Math.round(box.y + (box.height - h) / 2);
43
+ const x1 = Math.round(box.x + (box.width + w) / 2);
44
+ const y1 = Math.round(box.y + (box.height + h) / 2);
45
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
46
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from '../imagesource.js';
19
19
  import { loadImageFile } from '../imagedecode.js';
20
20
  import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
21
+ import { fitRect, objectFitOf } from './fit.js';
21
22
  import { intrinsicSize } from './layout.js';
22
23
  import { Node } from './node.js';
23
24
  import { DEV } from './util.js';
@@ -317,12 +318,42 @@ export class ImageNode extends Node {
317
318
  }
318
319
  if (!this.image) return;
319
320
  const content = this.contentBox();
320
- ctx.drawImage(
321
- this.image,
322
- content.x,
323
- content.y,
324
- content.width,
325
- content.height,
321
+ const fit = objectFitOf(this.style, 'fill');
322
+ if (fit === 'fill') {
323
+ ctx.drawImage(
324
+ this.image,
325
+ content.x,
326
+ content.y,
327
+ content.width,
328
+ content.height,
329
+ );
330
+ return;
331
+ }
332
+ // the source's size in device pixels, as `measureContent` takes it
333
+ const s = this.scale;
334
+ const rect = fitRect(
335
+ content,
336
+ (this.image.width ?? 0) * s,
337
+ (this.image.height ?? 0) * s,
338
+ fit,
326
339
  );
340
+ if (!(rect.width > 0 && rect.height > 0)) return;
341
+ const inside =
342
+ rect.x >= content.x &&
343
+ rect.y >= content.y &&
344
+ rect.x + rect.width <= content.x + content.width &&
345
+ rect.y + rect.height <= content.y + content.height;
346
+ if (!inside) {
347
+ // `cover`, and `none` larger than the box: cut to the content box
348
+ ctx.save();
349
+ ctx.beginPath();
350
+ ctx.rect(content.x, content.y, content.width, content.height);
351
+ ctx.clip();
352
+ }
353
+ try {
354
+ ctx.drawImage(this.image, rect.x, rect.y, rect.width, rect.height);
355
+ } finally {
356
+ if (!inside) ctx.restore();
357
+ }
327
358
  }
328
359
  }
@@ -13,6 +13,7 @@ export const DRAWN_KINDS = new Set([
13
13
  'box',
14
14
  'text',
15
15
  'image',
16
+ 'video',
16
17
  'canvas',
17
18
  'textinput',
18
19
  'textarea',
@@ -147,6 +147,7 @@ const NO_LAYOUT_KINDS = new Set([
147
147
  'text',
148
148
  'textchunk',
149
149
  'image',
150
+ 'video',
150
151
  'svg',
151
152
  'canvas',
152
153
  'textinput',