buninu-browser 0.6.0 → 0.9.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/AGENTS.md +5 -0
- package/README.md +94 -4
- package/buninu-browser.js +1 -1
- package/frontends/wayland/README.md +35 -2
- package/frontends/wayland/client.js +8 -6
- package/frontends/wayland/clipboard.js +1 -1
- package/frontends/wayland/index.js +1 -1
- package/frontends/wayland/input.js +1 -1
- package/frontends/wayland/resource-io.js +6 -0
- package/frontends/wayland/transport-posix.js +120 -0
- package/frontends/wayland/transport-win32.js +9 -0
- package/frontends/wayland/transport.js +3 -120
- package/frontends/wayland/win32-resources.js +80 -0
- package/frontends/wayland/win32-wire.js +62 -0
- package/index.js +10 -6
- package/lib/audio/biquad.js +25 -0
- package/lib/audio/piano.js +6 -3
- package/lib/audio/playback-buffer.js +5 -5
- package/lib/audio/pulse-client.js +24 -5
- package/lib/audio/web-audio.js +570 -0
- package/lib/browser-dialog.js +116 -0
- package/lib/cdp-server.js +128 -72
- package/lib/file-picker.js +48 -0
- package/lib/headless-shell.js +34 -12
- package/lib/layout/text-layout.js +112 -19
- package/lib/media/mp4-frame-times.js +93 -0
- package/lib/paint/direct-canvas.ts +39 -0
- package/lib/paint/display-list.js +37 -12
- package/lib/paint/raster-readback.js +16 -7
- package/lib/renderer/animation-frames.js +10 -5
- package/lib/renderer/bindings.js +69 -16
- package/lib/renderer/canvas-2d.js +265 -15
- package/lib/renderer/canvas-round-rect.js +36 -0
- package/lib/renderer/css-animations.js +173 -0
- package/lib/renderer/dialog-client.js +30 -0
- package/lib/renderer/dialog-worker.js +35 -0
- package/lib/renderer/eval-environment.js +28 -0
- package/lib/renderer/file-input.js +49 -0
- package/lib/renderer/host.js +33 -4
- package/lib/renderer/html-drag.js +77 -0
- package/lib/renderer/image-loading.js +45 -2
- package/lib/renderer/main.js +4 -0
- package/lib/renderer/module-transform.js +5 -3
- package/lib/renderer/page-builtins.js +59 -0
- package/lib/renderer/page-realm.js +177 -40
- package/lib/renderer/page-renderer.js +397 -49
- package/lib/renderer/piano-input.js +3 -1
- package/lib/renderer/raster-surface.js +18 -0
- package/lib/renderer/script-rewrite.js +165 -9
- package/lib/renderer/video-presentation.js +5 -4
- package/lib/renderer/webgl/clip.js +24 -0
- package/lib/renderer/webgl/context.js +158 -0
- package/lib/renderer/webgl/cpu-fragment.js +211 -0
- package/lib/renderer/webgl/draw-cache.js +22 -0
- package/lib/renderer/webgl/pipeline.js +70 -0
- package/lib/renderer/webgl/shaders.js +58 -0
- package/lib/renderer/webgl/textures.js +43 -0
- package/lib/renderer/window-named-properties.js +46 -0
- package/lib/screenshot-cli.js +4 -7
- package/lib/style/animation-values.js +29 -0
- package/lib/style/computed-style.js +109 -13
- package/lib/style/transform.js +44 -0
- package/lib/window-size.js +10 -0
- package/package.json +1 -1
- package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
- package/patches/happy-dom/README.md +16 -0
- package/test/animation-frames.test.js +15 -0
- package/test/canvas-2d.test.js +252 -4
- package/test/canvas-frame-pacing.test.js +7 -0
- package/test/cdp-viewers.test.js +79 -0
- package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
- package/test/classic-games-patches/README.md +156 -0
- package/test/classic-games-scenarios.js +133 -0
- package/test/clipboard.test.js +16 -0
- package/test/computed-style.test.js +18 -0
- package/test/css-animation-lifecycle.test.js +28 -0
- package/test/css-animations.test.js +83 -0
- package/test/direct-canvas.test.js +39 -0
- package/test/dynamic-inline-handlers.test.js +25 -0
- package/test/eval-declarations.test.js +22 -0
- package/test/file-picker.test.js +26 -0
- package/test/fixtures/canvas-damage.js +23 -0
- package/test/fixtures/canvas-frame-pacing.js +35 -0
- package/test/fixtures/direct-canvas.js +96 -0
- package/test/fixtures/javascript-dialogs.html +8 -0
- package/test/fixtures/media-audio-lifetime.js +65 -0
- package/test/fixtures/renderer-memory.js +7 -5
- package/test/fixtures/video-black-replay.js +8 -6
- package/test/fixtures/video-slow-end.js +39 -0
- package/test/fixtures/web-audio-renderer.js +45 -0
- package/test/flex.test.js +26 -0
- package/test/grid.test.js +13 -0
- package/test/headless-shell.test.js +9 -0
- package/test/headless-window-size.test.js +28 -0
- package/test/html-rcdata.test.js +35 -0
- package/test/image-decode.test.js +18 -0
- package/test/index.test.js +1 -0
- package/test/javascript-dialog.test.js +83 -0
- package/test/manual-breakout-paddle.js +42 -0
- package/test/manual-breakout-static.js +35 -0
- package/test/manual-classic-games.js +41 -0
- package/test/manual-clumsy-bird-three-pipes.js +81 -0
- package/test/manual-clumsy-bird.js +52 -0
- package/test/manual-hextris.js +30 -0
- package/test/manual-numbered-music.js +24 -0
- package/test/manual-t-rex-runner.js +49 -0
- package/test/manual-threejs-scene.js +64 -0
- package/test/manual-webgl-benchmark.js +27 -0
- package/test/manual-webgl-cpu.html +26 -0
- package/test/media-audio-lifetime.test.js +8 -0
- package/test/mp4-frame-times.test.js +33 -0
- package/test/page-scripts.test.js +96 -1
- package/test/piano-input.test.js +15 -2
- package/test/piano.test.js +11 -2
- package/test/playback-buffer.test.js +11 -0
- package/test/pointer-drag.test.js +33 -0
- package/test/positioning.test.js +82 -0
- package/test/pulse-client.test.js +51 -2
- package/test/raster-readback.test.js +14 -0
- package/test/script-rewrite.test.js +61 -3
- package/test/video-slow-end.test.js +6 -0
- package/test/viewport-keyboard-overflow.test.js +25 -0
- package/test/wayland-win32-resources.test.js +47 -0
- package/test/wayland-win32-wire.test.js +59 -0
- package/test/web-audio-renderer.test.js +8 -0
- package/test/web-audio.test.js +264 -0
- package/test/webgl-clip.test.js +9 -0
- package/test/webgl-cpu-fragment.test.js +43 -0
- package/test/window-named-properties.test.js +41 -0
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
- package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
- package/vendor/land-renderer/LICENSE +21 -0
- package/vendor/land-renderer/README.md +14 -0
- package/vendor/land-renderer/direct-compositor.ts +443 -0
- package/vendor/land-renderer/frame.js +88 -0
- package/vendor/land-renderer/png-fast.js +85 -0
- package/vendor/land-renderer/raw-frames.js +66 -0
- package/vendor/land-renderer/render-types.ts +7 -0
- package/vendor/land-renderer/renderer.ts +134 -0
- package/vendor/land-renderer/snapshot.js +37 -0
- package/vendor/land-renderer/viewport.js +18 -0
- package/vendor/ses/dist/ses.mjs +1 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { surfaceViewport } from "./viewport.js";
|
|
2
|
+
import { snapshotOpaque } from "./snapshot.js";
|
|
3
|
+
import type { ViewportSurface } from "./render-types.ts";
|
|
4
|
+
import type { Pixels } from "./render-types.ts";
|
|
5
|
+
|
|
6
|
+
/** A surface as the renderer sees it: the compositor's own, or the renderer worker's copy. */
|
|
7
|
+
export interface DirectSurface extends ViewportSurface { fractionalScale?: unknown }
|
|
8
|
+
interface Point { x: number; y: number }
|
|
9
|
+
/** The parts of a compositor (or the renderer worker's stand-in) that composition reads. */
|
|
10
|
+
export interface DirectScene {
|
|
11
|
+
input: { x: number; y: number; pointer?: object | boolean | null; cursor?: { surface: DirectSurface | null; x: number; y: number } | null };
|
|
12
|
+
wm: { renderSurfaces(): Iterable<DirectSurface>; origin(surface: DirectSurface): Point };
|
|
13
|
+
}
|
|
14
|
+
export interface DirectComposer {
|
|
15
|
+
close(): void;
|
|
16
|
+
/** Opaque BGRA pixels (little-endian words) of the output, or of the clip at `factor`. */
|
|
17
|
+
compose(width: number, height: number, factor?: number, clipX?: number, clipY?: number, smooth?: boolean): Uint32Array;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BACKGROUND = 0xff181b22; // opaque #181b22, BGRA bytes (little endian)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Nearest-neighbour downscale of a composed frame, as Skia draws a preview
|
|
24
|
+
* from the full-size image: `scale` (preview factor / full factor) is inverted
|
|
25
|
+
* in float32, and a sample on a pixel edge takes the pixel before it.
|
|
26
|
+
*/
|
|
27
|
+
export function downsample(src: Uint32Array, srcWidthIn: number, srcHeightIn: number, widthIn: number, heightIn: number, scale: number): Uint32Array {
|
|
28
|
+
// Integer dimensions and indices (`| 0`, Math.imul) for compiled code.
|
|
29
|
+
const srcWidth = srcWidthIn | 0, srcHeight = srcHeightIn | 0, width = widthIn | 0, height = heightIn | 0;
|
|
30
|
+
const inverse = Math.fround(1 / Math.fround(scale));
|
|
31
|
+
const sample = (i: number, limit: number): number => Math.min(limit - 1, Math.ceil(Math.fround(Math.fround(i + 0.5) * inverse)) - 1) | 0;
|
|
32
|
+
const out = new Uint32Array(Math.imul(width, height)), columns = new Int32Array(width);
|
|
33
|
+
for (let x = 0; x < width; x++) columns[x] = sample(x, srcWidth);
|
|
34
|
+
for (let y = 0, di = 0; y < height; y++) {
|
|
35
|
+
const row = Math.imul(sample(y, srcHeight), srcWidth);
|
|
36
|
+
for (let x = 0; x < width; x++, di = (di + 1) | 0) out[di] = src[(row + columns[x]!) | 0]!;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bilinear downscale of a composed, opaque frame, as Skia draws a filtered
|
|
43
|
+
* preview from the full-size image: each centre is read through the float32
|
|
44
|
+
* inverse scale, and the four pixels around it are blended, clamped at the edges.
|
|
45
|
+
*/
|
|
46
|
+
export function downsampleLinear(src: Uint32Array, srcWidthIn: number, srcHeightIn: number, widthIn: number, heightIn: number, scale: number): Uint32Array {
|
|
47
|
+
const srcWidth = srcWidthIn | 0, srcHeight = srcHeightIn | 0, width = widthIn | 0, height = heightIn | 0;
|
|
48
|
+
const horizontal = Axis.of(Math.fround(scale), 0, srcWidth, 0, width, true, false);
|
|
49
|
+
const vertical = Axis.of(Math.fround(scale), 0, srcHeight, 0, height, true, false);
|
|
50
|
+
const out = new Uint32Array(Math.imul(width, height));
|
|
51
|
+
for (let y = 0, di = 0; y < height; y++) {
|
|
52
|
+
const r0 = Math.imul(vertical.first[y]!, srcWidth), r1 = Math.imul(vertical.second![y]!, srcWidth), wy = vertical.weight![y]!;
|
|
53
|
+
for (let x = 0; x < width; x++, di = (di + 1) | 0) {
|
|
54
|
+
const c0 = horizontal.first[x]!, c1 = horizontal.second![x]!, wx = horizontal.weight![x]!;
|
|
55
|
+
out[di] = bilinear(src[r0 + c0]! | 0, src[r0 + c1]! | 0, src[r1 + c0]! | 0, src[r1 + c1]! | 0, wx, wy, 0xff000000, false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const f32 = Math.fround;
|
|
62
|
+
/**
|
|
63
|
+
* A 2D affine matrix in float32, built as Skia's canvas builds its matrix:
|
|
64
|
+
* every operation pre-concatenates. device = (m00 u + m01 v + m03, m10 u + m11 v + m13).
|
|
65
|
+
*/
|
|
66
|
+
class Matrix {
|
|
67
|
+
m00 = 1; m01 = 0; m03 = 0; m10 = 0; m11 = 1; m13 = 0;
|
|
68
|
+
copy(): Matrix {
|
|
69
|
+
const m = new Matrix();
|
|
70
|
+
m.m00 = this.m00; m.m01 = this.m01; m.m03 = this.m03; m.m10 = this.m10; m.m11 = this.m11; m.m13 = this.m13;
|
|
71
|
+
return m;
|
|
72
|
+
}
|
|
73
|
+
translate(xIn: number, yIn: number): void {
|
|
74
|
+
const x = f32(xIn), y = f32(yIn);
|
|
75
|
+
this.m03 = f32(f32(f32(this.m00 * x) + f32(this.m01 * y)) + this.m03);
|
|
76
|
+
this.m13 = f32(f32(f32(this.m10 * x) + f32(this.m11 * y)) + this.m13);
|
|
77
|
+
}
|
|
78
|
+
scale(xIn: number, yIn: number): void {
|
|
79
|
+
const x = f32(xIn), y = f32(yIn);
|
|
80
|
+
this.m00 = f32(this.m00 * x); this.m10 = f32(this.m10 * x);
|
|
81
|
+
this.m01 = f32(this.m01 * y); this.m11 = f32(this.m11 * y);
|
|
82
|
+
}
|
|
83
|
+
/** A quarter turn by `turns` (counter-clockwise for -1), as Skia's rotate snaps sin/cos to 0 and ±1. */
|
|
84
|
+
rotate(turns: number): void {
|
|
85
|
+
const t = ((turns % 4) + 4) % 4, cos = t === 0 ? 1 : t === 2 ? -1 : 0, sin = t === 1 ? 1 : t === 3 ? -1 : 0;
|
|
86
|
+
const m00 = this.m00, m01 = this.m01, m10 = this.m10, m11 = this.m11;
|
|
87
|
+
this.m00 = f32(m00 * cos + m01 * sin); this.m01 = f32(m00 * -sin + m01 * cos);
|
|
88
|
+
this.m10 = f32(m10 * cos + m11 * sin); this.m11 = f32(m10 * -sin + m11 * cos);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Device pixels [start, end) covered by an edge pair, rounded as Skia rounds non-antialiased edges. */
|
|
93
|
+
function span(a: number, b: number, limit: number): [number, number] {
|
|
94
|
+
return [Math.max(0, Math.round(Math.min(a, b))), Math.min(limit, Math.round(Math.max(a, b)))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One device axis of a placed surface: which pixels it covers, and for each the
|
|
99
|
+
* buffer coordinate it samples, read through the inverse float32 matrix at the
|
|
100
|
+
* pixel centre. Nearest samples take the pixel before an exact edge; linear
|
|
101
|
+
* samples blend the two pixels around the centre, clamped at the buffer edges.
|
|
102
|
+
*/
|
|
103
|
+
class Axis {
|
|
104
|
+
constructor(
|
|
105
|
+
readonly start: number, readonly count: number,
|
|
106
|
+
/** Nearest: one buffer index per pixel. Linear: the lower index. */
|
|
107
|
+
readonly first: Int32Array,
|
|
108
|
+
/** Linear only: the upper index and its weight. */
|
|
109
|
+
readonly second: Int32Array | null, readonly weight: Float32Array | null,
|
|
110
|
+
) {}
|
|
111
|
+
static of(scale: number, translate: number, size: number, start: number, end: number, linear: boolean, exactInverse: boolean): Axis {
|
|
112
|
+
const count = Math.max(0, end - start) | 0, last = (size - 1) | 0;
|
|
113
|
+
// Skia inverts a scale-translate matrix in float32 and a rotated one through its determinant.
|
|
114
|
+
const inverse = f32(1 / scale), shift = exactInverse ? f32(-translate / scale) : f32(-translate * inverse);
|
|
115
|
+
const first = new Int32Array(count);
|
|
116
|
+
const second = linear ? new Int32Array(count) : null, weight = linear ? new Float32Array(count) : null;
|
|
117
|
+
for (let i = 0; i < count; i++) {
|
|
118
|
+
const u = f32(f32(f32(start + i + 0.5) * inverse) + shift);
|
|
119
|
+
if (!linear) { first[i] = Math.min(last, Math.max(0, Math.ceil(u) - 1)); continue; }
|
|
120
|
+
const t = u - 0.5, low = Math.floor(t);
|
|
121
|
+
first[i] = Math.min(last, Math.max(0, low)); second![i] = Math.min(last, Math.max(0, low + 1)); weight![i] = t - low;
|
|
122
|
+
}
|
|
123
|
+
return new Axis(start, count, first, second, weight);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** How a row's pixels reach the output: copied, copied with alpha forced (XRGB), or blended. */
|
|
128
|
+
const OPAQUE = 0, XRGB = 1, BLEND = 2;
|
|
129
|
+
/** One surface's validated placement; also part of the scene's cache key. */
|
|
130
|
+
class Row {
|
|
131
|
+
constructor(
|
|
132
|
+
readonly buffer: Pixels, readonly words: Uint32Array, readonly stride: number, readonly kind: number,
|
|
133
|
+
readonly m: Matrix, readonly clip: number[] | null, readonly linear: boolean,
|
|
134
|
+
readonly left: number, readonly top: number, readonly right: number, readonly bottom: number,
|
|
135
|
+
) {}
|
|
136
|
+
equals(o: Row): boolean {
|
|
137
|
+
const a = this.m, b = o.m, c = this.clip, d = o.clip;
|
|
138
|
+
return this.buffer === o.buffer && this.words === o.words && this.kind === o.kind && this.linear === o.linear &&
|
|
139
|
+
a.m00 === b.m00 && a.m01 === b.m01 && a.m03 === b.m03 && a.m10 === b.m10 && a.m11 === b.m11 && a.m13 === b.m13 &&
|
|
140
|
+
(c === null ? d === null : d !== null && c[0] === d[0] && c[1] === d[1] && c[2] === d[2] && c[3] === d[3]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
class Scene {
|
|
144
|
+
constructor(readonly width: number, readonly height: number, readonly rows: Row[], readonly pointer: Point | null,
|
|
145
|
+
readonly factor: number, readonly clipX: number, readonly clipY: number) {}
|
|
146
|
+
equals(o: Scene): boolean {
|
|
147
|
+
if (this.width !== o.width || this.height !== o.height || this.rows.length !== o.rows.length ||
|
|
148
|
+
this.factor !== o.factor || this.clipX !== o.clipX || this.clipY !== o.clipY) return false;
|
|
149
|
+
for (let i = 0; i < this.rows.length; i++) if (!this.rows[i]!.equals(o.rows[i]!)) return false;
|
|
150
|
+
const a = this.pointer, b = o.pointer;
|
|
151
|
+
return a === null ? b === null : b !== null && a.x === b.x && a.y === b.y;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Buffer words, and the stride in words; a misaligned or odd-strided buffer is packed once. */
|
|
155
|
+
class Words { constructor(readonly words: Uint32Array, readonly stride: number) {} }
|
|
156
|
+
|
|
157
|
+
// The software compositor: every surface (any buffer transform, viewport crop
|
|
158
|
+
// and scale, translucency, cursor) and the built-in pointer, drawn as Skia draws
|
|
159
|
+
// them on a canvas scaled by `factor` and translated by the clip origin: nearest
|
|
160
|
+
// sampling, or linear sampling of downscaled surfaces when `smooth`.
|
|
161
|
+
export function createDirectComposer(compositor: DirectScene): DirectComposer {
|
|
162
|
+
let cachedScene: Scene | null = null, cachedPixels: Uint32Array | null = null;
|
|
163
|
+
const axes = new Map<string, Axis>();
|
|
164
|
+
const axis: typeof Axis.of = (...args) => {
|
|
165
|
+
const key = args.join(':');
|
|
166
|
+
let value = axes.get(key);
|
|
167
|
+
if (!value) {
|
|
168
|
+
value = Axis.of(...args);
|
|
169
|
+
// Geometry only, never client pixels. Bound memory during resize/drag.
|
|
170
|
+
if (axes.size >= 128) axes.delete(axes.keys().next().value!);
|
|
171
|
+
axes.set(key, value);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
};
|
|
175
|
+
let cursorTile: {row: Row; width: number; height: number; samples: Float64Array} | null = null;
|
|
176
|
+
// Buffer snapshots are immutable; replacement commits use a new key.
|
|
177
|
+
// Word views (or packed copies of misaligned buffers) are kept per snapshot.
|
|
178
|
+
let opacity = new WeakMap<Pixels, boolean>(), packed = new WeakMap<Pixels, Words>();
|
|
179
|
+
function words(b: Pixels, bytes: Uint8Array): Words {
|
|
180
|
+
let view = packed.get(b);
|
|
181
|
+
if (!view) {
|
|
182
|
+
if (b.stride % 4 === 0 && bytes.byteOffset % 4 === 0)
|
|
183
|
+
view = new Words(new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4)), b.stride >> 2);
|
|
184
|
+
else {
|
|
185
|
+
const out = new Uint8Array(b.width * b.height * 4);
|
|
186
|
+
for (let y = 0; y < b.height; y++) out.set(bytes.subarray(y * b.stride, y * b.stride + b.width * 4), y * b.width * 4);
|
|
187
|
+
view = new Words(new Uint32Array(out.buffer), b.width);
|
|
188
|
+
}
|
|
189
|
+
packed.set(b, view);
|
|
190
|
+
}
|
|
191
|
+
return view;
|
|
192
|
+
}
|
|
193
|
+
function opaque(b: Pixels, bytes: Uint8Array): boolean {
|
|
194
|
+
// Share full-frame validation with cursor-tile composition: do not scan the
|
|
195
|
+
// complete client image again just to draw a small cursor rectangle.
|
|
196
|
+
// The generic compositor also accepts buffers without final-row padding.
|
|
197
|
+
if (bytes.byteLength >= b.stride * b.height) return snapshotOpaque(b, bytes);
|
|
198
|
+
let known = opacity.get(b);
|
|
199
|
+
if (known === undefined) {
|
|
200
|
+
known = true;
|
|
201
|
+
// Locals: the buffer may come from JavaScript, whose objects have no layout here.
|
|
202
|
+
const rowsOf = b.height | 0, columnsOf = b.width | 0, strideOf = b.stride | 0;
|
|
203
|
+
for (let row = 0; row < rowsOf && known; row++)
|
|
204
|
+
for (let column = 0, at = (Math.imul(row, strideOf) + 3) | 0; column < columnsOf; column++, at = (at + 4) | 0)
|
|
205
|
+
if (bytes[at] !== 255) { known = false; break; }
|
|
206
|
+
opacity.set(b, known);
|
|
207
|
+
}
|
|
208
|
+
return known;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
close() {
|
|
212
|
+
cachedScene = cachedPixels = null;
|
|
213
|
+
axes.clear(); cursorTile = null;
|
|
214
|
+
opacity = new WeakMap(); packed = new WeakMap();
|
|
215
|
+
},
|
|
216
|
+
compose(width: number, height: number, factor = 1, clipX = 0, clipY = 0, smooth = false): Uint32Array {
|
|
217
|
+
const input = compositor.input, cursor = input.cursor, rows: Row[] = [];
|
|
218
|
+
const outWidth = width | 0, outHeight = height | 0;
|
|
219
|
+
let cursorRow: Row | null = null;
|
|
220
|
+
const surfaces = [...compositor.wm.renderSurfaces()];
|
|
221
|
+
if (cursor?.surface?.pixels) surfaces.push(cursor.surface);
|
|
222
|
+
for (const surface of surfaces) {
|
|
223
|
+
const b = surface.pixels, bytes: Uint8Array | undefined = b?.bytes;
|
|
224
|
+
if (!b || !bytes || !(b.width > 0) || !(b.height > 0) || bytes.byteLength < b.stride * (b.height - 1) + b.width * 4) continue;
|
|
225
|
+
let { x, y } = surface === cursor?.surface ? { x: input.x - cursor.x, y: input.y - cursor.y } : compositor.wm.origin(surface);
|
|
226
|
+
const viewport = surfaceViewport(surface)!, source = viewport.source;
|
|
227
|
+
const sx = source[0]!, sy = source[1]!, sw = source[2]!, sh = source[3]!;
|
|
228
|
+
let dw = viewport.width, dh = viewport.height;
|
|
229
|
+
const transform = surface.transform | 0, rotation = transform & 3, scale = surface.scale;
|
|
230
|
+
const w = rotation % 2 ? b.height : b.width, h = rotation % 2 ? b.width : b.height;
|
|
231
|
+
if (![x, y, dw, dh, sw, sh].every(Number.isFinite) || dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) continue;
|
|
232
|
+
// Fractional clients round their buffers to physical pixels. Match that
|
|
233
|
+
// rounding when it gives a 1:1 mapping, avoiding a second filter.
|
|
234
|
+
if (surface.fractionalScale && scale === 1 && sx === 0 && sy === 0 && sw === w && sh === h &&
|
|
235
|
+
Math.round(dw * factor) === w && Math.round(dh * factor) === h) {
|
|
236
|
+
x = Math.round(x * factor) / factor; y = Math.round(y * factor) / factor;
|
|
237
|
+
dw = w / factor; dh = h / factor;
|
|
238
|
+
}
|
|
239
|
+
const m = new Matrix();
|
|
240
|
+
m.scale(factor, factor); m.translate(-clipX, -clipY); m.translate(x, y);
|
|
241
|
+
let clip: number[] | null = null;
|
|
242
|
+
if (sx !== 0 || sy !== 0 || sw !== w / scale || sh !== h / scale) {
|
|
243
|
+
const [left, right] = span(m.m03, f32(f32(m.m00 * f32(dw)) + m.m03), outWidth);
|
|
244
|
+
const [top, bottom] = span(m.m13, f32(f32(m.m11 * f32(dh)) + m.m13), outHeight);
|
|
245
|
+
clip = [left, top, right, bottom];
|
|
246
|
+
}
|
|
247
|
+
if (dw !== sw || dh !== sh) m.scale(dw / sw, dh / sh);
|
|
248
|
+
if (sx !== 0 || sy !== 0) m.translate(-sx, -sy);
|
|
249
|
+
m.scale(1 / scale, 1 / scale);
|
|
250
|
+
if (transform & 4) { m.translate(w, 0); m.scale(-1, 1); }
|
|
251
|
+
if (rotation === 1) { m.translate(0, b.width); m.rotate(-1); }
|
|
252
|
+
if (rotation === 2) { m.translate(b.width, b.height); m.rotate(2); }
|
|
253
|
+
if (rotation === 3) { m.translate(b.height, 0); m.rotate(1); }
|
|
254
|
+
// Device bounds of the buffer, as Skia maps and rounds a non-antialiased image rectangle.
|
|
255
|
+
const swap = m.m00 === 0;
|
|
256
|
+
const [left0, right0] = span(m.m03, f32(f32((swap ? m.m01 * b.height : m.m00 * b.width)) + m.m03), outWidth);
|
|
257
|
+
const [top0, bottom0] = span(m.m13, f32(f32((swap ? m.m10 * b.width : m.m11 * b.height)) + m.m13), outHeight);
|
|
258
|
+
const left = Math.max(left0, clip?.[0] ?? 0), top = Math.max(top0, clip?.[1] ?? 0);
|
|
259
|
+
const right = Math.min(right0, clip?.[2] ?? outWidth), bottom = Math.min(bottom0, clip?.[3] ?? outHeight);
|
|
260
|
+
if (left >= right || top >= bottom) continue;
|
|
261
|
+
const linear = smooth && (factor * dw / sw / scale < 1 - 1e-6 || factor * dh / sh / scale < 1 - 1e-6);
|
|
262
|
+
const kind = opaque(b, bytes) ? OPAQUE : b.format === 1 ? XRGB : BLEND;
|
|
263
|
+
const buffer = words(b, bytes);
|
|
264
|
+
const row = new Row(b, buffer.words, buffer.stride, kind, m, clip, linear, left, top, right, bottom);
|
|
265
|
+
rows.push(row);
|
|
266
|
+
if (surface === cursor?.surface) cursorRow = row;
|
|
267
|
+
}
|
|
268
|
+
if (!cursorRow) cursorTile = null;
|
|
269
|
+
const pointer = !cursor && !!input.pointer && Number.isFinite(input.x) && Number.isFinite(input.y);
|
|
270
|
+
const scene = new Scene(width, height, rows, pointer ? { x: input.x, y: input.y } : null, factor, clipX, clipY);
|
|
271
|
+
if (cachedScene?.equals(scene)) return cachedPixels!;
|
|
272
|
+
// For compiled code: integer coordinates (`| 0`, Math.imul) stay 32-bit
|
|
273
|
+
// integers. Outputs are at most 8192 x 8192, so each index fits.
|
|
274
|
+
const out = new Uint32Array(outWidth * outHeight);
|
|
275
|
+
// An opaque surface covering the output 1:1 is already the desktop. Copy it
|
|
276
|
+
// once, then draw only what lies above it. Never modify client snapshots:
|
|
277
|
+
// moving or removing a cursor must reveal them again.
|
|
278
|
+
let base = -1;
|
|
279
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
280
|
+
const row = rows[i]!, m = row.m;
|
|
281
|
+
if (row.kind === OPAQUE && !row.linear && m.m00 === 1 && m.m11 === 1 && m.m01 === 0 && m.m10 === 0 && m.m03 === 0 && m.m13 === 0 &&
|
|
282
|
+
row.left === 0 && row.top === 0 && row.right === outWidth && row.bottom === outHeight && row.stride === outWidth &&
|
|
283
|
+
row.buffer.width === outWidth && row.buffer.height >= outHeight) { base = i; break; }
|
|
284
|
+
}
|
|
285
|
+
if (base >= 0) out.set(rows[base]!.words.subarray(0, outWidth * outHeight));
|
|
286
|
+
else out.fill(BACKGROUND);
|
|
287
|
+
for (let index = base + 1; index < rows.length; index++) {
|
|
288
|
+
const row = rows[index]!;
|
|
289
|
+
// Later opaque surfaces spanning these columns hide whole output rows.
|
|
290
|
+
let hidden: Uint8Array | null = null;
|
|
291
|
+
const lines = (row.bottom - row.top) | 0;
|
|
292
|
+
for (let later = index + 1; later < rows.length; later++) {
|
|
293
|
+
const o = rows[later]!;
|
|
294
|
+
if (o.kind === BLEND || o.left > row.left || o.right < row.right) continue;
|
|
295
|
+
const top = Math.max(row.top, o.top), bottom = Math.min(row.bottom, o.bottom);
|
|
296
|
+
if (top < bottom) (hidden ??= new Uint8Array(lines)).fill(1, top - row.top, bottom - row.top);
|
|
297
|
+
}
|
|
298
|
+
if (row === cursorRow && row.right - row.left <= 512 && row.bottom - row.top <= 512) {
|
|
299
|
+
if (!cursorTile || cursorTile.width !== outWidth || cursorTile.height !== outHeight || !cursorTile.row.equals(row))
|
|
300
|
+
cursorTile = {row, width: outWidth, height: outHeight, samples: cursorSamples(row, axis)};
|
|
301
|
+
drawCursor(out, outWidth, row, cursorTile.samples);
|
|
302
|
+
} else {
|
|
303
|
+
if (row === cursorRow) cursorTile = null;
|
|
304
|
+
draw(out, outWidth, row, hidden, axis);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (pointer) {
|
|
308
|
+
// Skia draws two non-antialiased rectangles in logical coordinates.
|
|
309
|
+
const m = new Matrix();
|
|
310
|
+
m.scale(factor, factor); m.translate(-clipX, -clipY);
|
|
311
|
+
for (const [w, h] of [[3, 15], [10, 3]] as const) {
|
|
312
|
+
const [x0, x1] = span(f32(f32(m.m00 * f32(input.x)) + m.m03), f32(f32(m.m00 * f32(input.x + w)) + m.m03), outWidth);
|
|
313
|
+
const [y0, y1] = span(f32(f32(m.m11 * f32(input.y)) + m.m13), f32(f32(m.m11 * f32(input.y + h)) + m.m13), outHeight);
|
|
314
|
+
for (let y = y0; y < y1; y++) out.fill(0xffffffff, Math.imul(y, outWidth) + x0, Math.imul(y, outWidth) + x1);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
cachedScene = scene;
|
|
318
|
+
cachedPixels = out;
|
|
319
|
+
return out;
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Wayland ARGB is premultiplied and the destination stays opaque: src + dst * (1 - alpha). */
|
|
325
|
+
function blend(p: number, q: number, alpha: number): number {
|
|
326
|
+
const inverse = 255 - alpha;
|
|
327
|
+
const blue = Math.min(255, (p & 255) + Math.round(((q & 255) * inverse) / 255));
|
|
328
|
+
const green = Math.min(255, ((p >> 8) & 255) + Math.round((((q >> 8) & 255) * inverse) / 255));
|
|
329
|
+
const red = Math.min(255, ((p >> 16) & 255) + Math.round((((q >> 16) & 255) * inverse) / 255));
|
|
330
|
+
return (0xff000000 | (red << 16) | (green << 8) | blue) >>> 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function draw(out: Uint32Array, outWidth: number, row: Row, hidden: Uint8Array | null, axis: typeof Axis.of): void {
|
|
334
|
+
const m = row.m, b = row.buffer, swap = m.m00 === 0, stride = row.stride | 0;
|
|
335
|
+
// Each device axis reads one buffer axis; a rotated surface swaps them.
|
|
336
|
+
const horizontal = axis(swap ? m.m01 : m.m00, m.m03, swap ? b.height : b.width, row.left, row.right, row.linear, swap);
|
|
337
|
+
const vertical = axis(swap ? m.m10 : m.m11, m.m13, swap ? b.width : b.height, row.top, row.bottom, row.linear, swap);
|
|
338
|
+
// Word offsets in the buffer contributed by each axis.
|
|
339
|
+
const across = (axis: Axis, values: Int32Array, rows: boolean): Int32Array => {
|
|
340
|
+
if (!rows) return values;
|
|
341
|
+
const result = new Int32Array(axis.count);
|
|
342
|
+
for (let i = 0; i < axis.count; i++) result[i] = Math.imul(values[i]!, stride);
|
|
343
|
+
return result;
|
|
344
|
+
};
|
|
345
|
+
const h0 = across(horizontal, horizontal.first, swap), v0 = across(vertical, vertical.first, !swap);
|
|
346
|
+
const src = row.words, kind = row.kind, count = horizontal.count | 0, lines = vertical.count | 0, x0 = horizontal.start | 0;
|
|
347
|
+
if (!row.linear) {
|
|
348
|
+
// An unswapped 1:1 column mapping copies whole row spans.
|
|
349
|
+
const first = h0[0]! | 0, contiguous = !swap && h0[count - 1]! - first === count - 1;
|
|
350
|
+
let previousSource = -1, previousOutput = 0;
|
|
351
|
+
for (let line = 0; line < lines; line++) {
|
|
352
|
+
if (hidden !== null && hidden[line]) continue;
|
|
353
|
+
const sourceRow = v0[line]! | 0;
|
|
354
|
+
let di = (Math.imul(vertical.start + line, outWidth) + x0) | 0;
|
|
355
|
+
if (kind === OPAQUE) {
|
|
356
|
+
if (sourceRow === previousSource) out.copyWithin(di, previousOutput, previousOutput + count);
|
|
357
|
+
else if (contiguous) out.set(src.subarray(sourceRow + first, sourceRow + first + count), di);
|
|
358
|
+
else for (let i = 0; i < count; i++) out[di + i] = src[(sourceRow + h0[i]!) | 0]!;
|
|
359
|
+
previousSource = sourceRow; previousOutput = di;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
for (let i = 0; i < count; i++, di++) {
|
|
363
|
+
const p = src[(sourceRow + h0[i]!) | 0]! | 0;
|
|
364
|
+
if (kind === XRGB) { out[di] = (p | 0xff000000) >>> 0; continue; }
|
|
365
|
+
const alpha = (p >>> 24) & 255;
|
|
366
|
+
if (alpha === 255) out[di] = p >>> 0;
|
|
367
|
+
else if (alpha !== 0) out[di] = blend(p, out[di]! | 0, alpha);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Linear: blend the four buffer pixels around each centre in premultiplied
|
|
373
|
+
// floats, then composite and round as an 8-bit store does.
|
|
374
|
+
const h1 = across(horizontal, horizontal.second!, swap), v1 = across(vertical, vertical.second!, !swap);
|
|
375
|
+
// A swapped surface's horizontal weight runs along buffer rows, its vertical one along columns.
|
|
376
|
+
const hw = horizontal.weight!, vw = vertical.weight!;
|
|
377
|
+
for (let line = 0; line < lines; line++) {
|
|
378
|
+
if (hidden !== null && hidden[line]) continue;
|
|
379
|
+
const r0 = v0[line]! | 0, r1 = v1[line]! | 0, wy = vw[line]!;
|
|
380
|
+
let di = (Math.imul(vertical.start + line, outWidth) + x0) | 0;
|
|
381
|
+
for (let i = 0; i < count; i++, di++) {
|
|
382
|
+
const c0 = h0[i]! | 0, c1 = h1[i]! | 0, wx = hw[i]!;
|
|
383
|
+
out[di] = bilinear(src[(r0 + c0) | 0]! | 0, src[(r0 + c1) | 0]! | 0, src[(r1 + c0) | 0]! | 0, src[(r1 + c1) | 0]! | 0,
|
|
384
|
+
wx, wy, out[di]! | 0, kind === BLEND);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Four premultiplied pixels blended with weights `wx`, `wy` in floats, then
|
|
391
|
+
* composited over `q` (when `translucent`) and rounded as an 8-bit store does.
|
|
392
|
+
*/
|
|
393
|
+
function bilinear(a: number, b: number, c: number, d: number, wx: number, wy: number, q: number, translucent: boolean): number {
|
|
394
|
+
const ka = (1 - wx) * (1 - wy), kb = wx * (1 - wy), kc = (1 - wx) * wy, kd = wx * wy;
|
|
395
|
+
// Unrolled per channel: no closures in the per-pixel path.
|
|
396
|
+
const keep = translucent ? 1 - (((a >>> 24) & 255) * ka + ((b >>> 24) & 255) * kb + ((c >>> 24) & 255) * kc + ((d >>> 24) & 255) * kd) / 255 : 0;
|
|
397
|
+
const red = Math.min(255, Math.round(((a >>> 16) & 255) * ka + ((b >>> 16) & 255) * kb + ((c >>> 16) & 255) * kc + ((d >>> 16) & 255) * kd + ((q >>> 16) & 255) * keep));
|
|
398
|
+
const green = Math.min(255, Math.round(((a >>> 8) & 255) * ka + ((b >>> 8) & 255) * kb + ((c >>> 8) & 255) * kc + ((d >>> 8) & 255) * kd + ((q >>> 8) & 255) * keep));
|
|
399
|
+
const blue = Math.min(255, Math.round((a & 255) * ka + (b & 255) * kb + (c & 255) * kc + (d & 255) * kd + (q & 255) * keep));
|
|
400
|
+
return (0xff000000 | (red << 16) | (green << 8) | blue) >>> 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
// Cache cursor sampling, not its blend against the changing desktop. Keep
|
|
405
|
+
// unrounded doubles for linear filtering so final rounding matches bilinear().
|
|
406
|
+
function cursorSamples(row: Row, axis: typeof Axis.of): Float64Array {
|
|
407
|
+
const m = row.m, b = row.buffer, swap = m.m00 === 0;
|
|
408
|
+
const h = axis(swap ? m.m01 : m.m00, m.m03, swap ? b.height : b.width, row.left, row.right, row.linear, swap);
|
|
409
|
+
const v = axis(swap ? m.m10 : m.m11, m.m13, swap ? b.width : b.height, row.top, row.bottom, row.linear, swap);
|
|
410
|
+
const data = new Float64Array(h.count * v.count * 4), src = row.words;
|
|
411
|
+
const at = (x: number, y: number) => src[swap ? x * row.stride + y : y * row.stride + x]!;
|
|
412
|
+
for (let y = 0, i = 0; y < v.count; y++) for (let x = 0; x < h.count; x++, i += 4) {
|
|
413
|
+
const a = at(h.first[x]!, v.first[y]!);
|
|
414
|
+
if (!row.linear) {
|
|
415
|
+
data[i] = a; // Exact packed pixel; blending still uses the original formula.
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const b = at(h.second![x]!, v.first[y]!), c = at(h.first[x]!, v.second![y]!), d = at(h.second![x]!, v.second![y]!);
|
|
419
|
+
const wx = h.weight![x]!, wy = v.weight![y]!;
|
|
420
|
+
const ka = (1-wx)*(1-wy), kb = wx*(1-wy), kc = (1-wx)*wy, kd = wx*wy;
|
|
421
|
+
data[i] = ((a >>> 16)&255)*ka + ((b >>> 16)&255)*kb + ((c >>> 16)&255)*kc + ((d >>> 16)&255)*kd;
|
|
422
|
+
data[i+1] = ((a >>> 8)&255)*ka + ((b >>> 8)&255)*kb + ((c >>> 8)&255)*kc + ((d >>> 8)&255)*kd;
|
|
423
|
+
data[i+2] = (a&255)*ka + (b&255)*kb + (c&255)*kc + (d&255)*kd;
|
|
424
|
+
data[i+3] = row.kind === BLEND ? 1-(((a>>>24)&255)*ka+((b>>>24)&255)*kb+((c>>>24)&255)*kc+((d>>>24)&255)*kd)/255 : 0;
|
|
425
|
+
}
|
|
426
|
+
return data;
|
|
427
|
+
}
|
|
428
|
+
function drawCursor(out: Uint32Array, width: number, row: Row, samples: Float64Array): void {
|
|
429
|
+
for (let y = row.top, i = 0; y < row.bottom; y++) for (let x = row.left; x < row.right; x++, i += 4) {
|
|
430
|
+
const at = y * width + x, q = out[at]!;
|
|
431
|
+
if (!row.linear) {
|
|
432
|
+
const p = samples[i]! >>> 0, alpha = row.kind === BLEND ? p >>> 24 : 255;
|
|
433
|
+
if (alpha === 255) out[at] = (p | 0xff000000) >>> 0;
|
|
434
|
+
else if (alpha !== 0) out[at] = blend(p, q, alpha);
|
|
435
|
+
} else {
|
|
436
|
+
const keep = samples[i+3]!;
|
|
437
|
+
const r = Math.min(255, Math.round(samples[i]! + ((q>>>16)&255)*keep));
|
|
438
|
+
const g = Math.min(255, Math.round(samples[i+1]! + ((q>>>8)&255)*keep));
|
|
439
|
+
const b = Math.min(255, Math.round(samples[i+2]! + (q&255)*keep));
|
|
440
|
+
out[at] = (0xff000000 | r<<16 | g<<8 | b) >>> 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compositor frames: a raster tile of the page plus where it sits, and the
|
|
3
|
+
* two operations the compositor needs without CanvasKit: cutting the
|
|
4
|
+
* viewport out of a tile and encoding it (with Bun.Image).
|
|
5
|
+
*
|
|
6
|
+
* A tile covers a rectangle of the page in CSS pixels (x, y, width, height)
|
|
7
|
+
* rasterized at `scale` device pixels per CSS pixel; `pixels` is RGBA
|
|
8
|
+
* (unpremultiplied), `pixelWidth` x `pixelHeight`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The viewport's device pixels at a scroll position. Parts of the viewport
|
|
12
|
+
* the tile does not cover show the page background, as Chrome's compositor
|
|
13
|
+
* shows a checkerboard until the missing tiles are rasterized.
|
|
14
|
+
* @returns {{ pixels: Uint8Array, width: number, height: number }}
|
|
15
|
+
*/
|
|
16
|
+
export function cropViewport(tile, scrollX, scrollY, viewportWidth, viewportHeight) {
|
|
17
|
+
const scale = tile.scale;
|
|
18
|
+
const width = Math.max(1, Math.round(Math.floor(viewportWidth) * scale));
|
|
19
|
+
const height = Math.max(1, Math.round(Math.floor(viewportHeight) * scale));
|
|
20
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
21
|
+
const [red, green, blue, alpha] = tile.background ?? [255, 255, 255, 255];
|
|
22
|
+
const fill = new Uint8Array([red, green, blue, alpha]);
|
|
23
|
+
const offsetX = Math.round((scrollX - tile.x) * scale);
|
|
24
|
+
const offsetY = Math.round((scrollY - tile.y) * scale);
|
|
25
|
+
const fromX = Math.max(0, -offsetX);
|
|
26
|
+
const toX = Math.min(width, tile.pixelWidth - offsetX);
|
|
27
|
+
const rowFill = new Uint8Array(width * 4);
|
|
28
|
+
for (let index = 0; index < width; index++) rowFill.set(fill, index * 4);
|
|
29
|
+
for (let row = 0; row < height; row++) {
|
|
30
|
+
const sourceRow = row + offsetY;
|
|
31
|
+
const target = row * width * 4;
|
|
32
|
+
if (sourceRow < 0 || sourceRow >= tile.pixelHeight || toX <= fromX) {
|
|
33
|
+
pixels.set(rowFill, target);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (fromX > 0 || toX < width) pixels.set(rowFill, target);
|
|
37
|
+
const source = (sourceRow * tile.pixelWidth + fromX + offsetX) * 4;
|
|
38
|
+
pixels.set(tile.pixels.subarray(source, source + (toX - fromX) * 4), target + fromX * 4);
|
|
39
|
+
}
|
|
40
|
+
return { pixels, width, height };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const IMAGE_FORMATS = new Set(["png", "jpeg", "webp"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Encodes RGBA pixels with Bun.Image. Bun.Image does not read raw pixels, so
|
|
47
|
+
* they are wrapped in an uncompressed BMP first (header only, no copying of
|
|
48
|
+
* rows or channels).
|
|
49
|
+
* @param {{ format?: "png" | "jpeg" | "webp", quality?: number }} options
|
|
50
|
+
*/
|
|
51
|
+
export async function encodeImage(pixels, width, height, { format = "png", quality = 80 } = {}) {
|
|
52
|
+
const image = new Bun.Image(rgbaBitmap(pixels, width, height));
|
|
53
|
+
const kind = IMAGE_FORMATS.has(format) ? format : "png";
|
|
54
|
+
const level = Math.min(100, Math.max(0, Number.isFinite(quality) ? quality : 80));
|
|
55
|
+
return kind === "png" ? image.png().bytes() : image[kind]({ quality: level }).bytes();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A 32-bit BMP (BITMAPV4HEADER, BI_BITFIELDS in RGBA byte order, top-down rows) around RGBA pixels. */
|
|
59
|
+
export function rgbaBitmap(pixels, width, height) {
|
|
60
|
+
const headerSize = 14 + 108;
|
|
61
|
+
const bitmap = new Uint8Array(headerSize + pixels.byteLength);
|
|
62
|
+
const view = new DataView(bitmap.buffer);
|
|
63
|
+
view.setUint8(0, 0x42);
|
|
64
|
+
view.setUint8(1, 0x4d);
|
|
65
|
+
view.setUint32(2, bitmap.byteLength, true);
|
|
66
|
+
view.setUint32(10, headerSize, true);
|
|
67
|
+
view.setUint32(14, 108, true);
|
|
68
|
+
view.setInt32(18, width, true);
|
|
69
|
+
view.setInt32(22, -height, true);
|
|
70
|
+
view.setUint16(26, 1, true);
|
|
71
|
+
view.setUint16(28, 32, true);
|
|
72
|
+
view.setUint32(30, 3, true);
|
|
73
|
+
view.setUint32(34, pixels.byteLength, true);
|
|
74
|
+
view.setUint32(54, 0x000000ff, true);
|
|
75
|
+
view.setUint32(58, 0x0000ff00, true);
|
|
76
|
+
view.setUint32(62, 0x00ff0000, true);
|
|
77
|
+
view.setUint32(66, 0xff000000, true);
|
|
78
|
+
view.setUint32(70, 0x73524742, true);
|
|
79
|
+
bitmap.set(pixels, headerSize);
|
|
80
|
+
return bitmap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** RGBA bytes for a CSS color string such as "rgba(255, 255, 255, 1)". */
|
|
84
|
+
export function backgroundBytes(color) {
|
|
85
|
+
const match = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(String(color));
|
|
86
|
+
if (!match) return [255, 255, 255, 255];
|
|
87
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), Math.round(Number(match[4] ?? 1) * 255)];
|
|
88
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { deflateSync } from "node:zlib";
|
|
2
|
+
const chunk = (name, data) => {
|
|
3
|
+
const out = Buffer.allocUnsafe(data.length + 12);
|
|
4
|
+
out.writeUInt32BE(data.length, 0);
|
|
5
|
+
out.write(name, 4, 4, "ascii");
|
|
6
|
+
out.set(data, 8);
|
|
7
|
+
out.writeUInt32BE(
|
|
8
|
+
Bun.hash.crc32(out.subarray(4, 8 + data.length)) >>> 0,
|
|
9
|
+
8 + data.length,
|
|
10
|
+
);
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
// Lossless RGB/RGBA PNG, Sub filter + low compression level.
|
|
14
|
+
// The direct compositor guarantees opaque output; fallback output is inspected.
|
|
15
|
+
export function encodePng(pixels, width, height, bgra = true, opaque = false) {
|
|
16
|
+
if (!opaque) opaque = pixels.every((pixel) => pixel >>> 24 === 255);
|
|
17
|
+
const channels = opaque ? 3 : 4;
|
|
18
|
+
const redShift = bgra ? 16 : 0,
|
|
19
|
+
blueShift = bgra ? 0 : 16;
|
|
20
|
+
const stride = width * channels + 1,
|
|
21
|
+
filtered = Buffer.allocUnsafe(stride * height);
|
|
22
|
+
if (opaque) {
|
|
23
|
+
// Uint8 writes keep the low byte of each difference (PNG Sub modulo 256).
|
|
24
|
+
// Keep the previous packed pixel instead of extracting/masking six channels.
|
|
25
|
+
for (let y = 0; y < height; y++) {
|
|
26
|
+
let target = y * stride;
|
|
27
|
+
filtered[target++] = 1;
|
|
28
|
+
let source = y * width, previous = 0;
|
|
29
|
+
const end = source + width;
|
|
30
|
+
for (; source + 1 < end; source += 2) {
|
|
31
|
+
const p = pixels[source], q = pixels[source + 1];
|
|
32
|
+
filtered[target++] = (p >>> redShift) - (previous >>> redShift);
|
|
33
|
+
filtered[target++] = (p >>> 8) - (previous >>> 8);
|
|
34
|
+
filtered[target++] = (p >>> blueShift) - (previous >>> blueShift);
|
|
35
|
+
filtered[target++] = (q >>> redShift) - (p >>> redShift);
|
|
36
|
+
filtered[target++] = (q >>> 8) - (p >>> 8);
|
|
37
|
+
filtered[target++] = (q >>> blueShift) - (p >>> blueShift);
|
|
38
|
+
previous = q;
|
|
39
|
+
}
|
|
40
|
+
if (source < end) {
|
|
41
|
+
const p = pixels[source];
|
|
42
|
+
filtered[target++] = (p >>> redShift) - (previous >>> redShift);
|
|
43
|
+
filtered[target++] = (p >>> 8) - (previous >>> 8);
|
|
44
|
+
filtered[target++] = (p >>> blueShift) - (previous >>> blueShift);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
for (let y = 0; y < height; y++) {
|
|
49
|
+
let target = y * stride;
|
|
50
|
+
filtered[target++] = 1;
|
|
51
|
+
let r = 0,
|
|
52
|
+
g = 0,
|
|
53
|
+
b = 0,
|
|
54
|
+
a = 0;
|
|
55
|
+
for (let x = 0; x < width; x++) {
|
|
56
|
+
const p = pixels[y * width + x],
|
|
57
|
+
nr = (p >>> redShift) & 255,
|
|
58
|
+
ng = (p >>> 8) & 255,
|
|
59
|
+
nb = (p >>> blueShift) & 255;
|
|
60
|
+
filtered[target++] = (nr - r) & 255;
|
|
61
|
+
filtered[target++] = (ng - g) & 255;
|
|
62
|
+
filtered[target++] = (nb - b) & 255;
|
|
63
|
+
if (!opaque) {
|
|
64
|
+
const na = p >>> 24;
|
|
65
|
+
filtered[target++] = (na - a) & 255;
|
|
66
|
+
a = na;
|
|
67
|
+
}
|
|
68
|
+
r = nr;
|
|
69
|
+
g = ng;
|
|
70
|
+
b = nb;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const header = Buffer.alloc(13);
|
|
75
|
+
header.writeUInt32BE(width, 0);
|
|
76
|
+
header.writeUInt32BE(height, 4);
|
|
77
|
+
header[8] = 8;
|
|
78
|
+
header[9] = opaque ? 2 : 6;
|
|
79
|
+
return Buffer.concat([
|
|
80
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
81
|
+
chunk("IHDR", header),
|
|
82
|
+
chunk("IDAT", deflateSync(filtered, { level: 1 })),
|
|
83
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
84
|
+
]);
|
|
85
|
+
}
|