@glissade/backend-skia 0.1.0 → 0.3.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/README.md +26 -0
- package/dist/index.d.ts +4 -16
- package/dist/index.js +15 -172
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @glissade/backend-skia
|
|
2
|
+
|
|
3
|
+
The headless rasterizer: DisplayList → Skia via `@napi-rs/canvas` (prebuilt N-API, no browser anywhere). This is what makes glissade's determinism claims testable — golden frames render **byte-identically** across machines on the pinned toolchain, and CI byte-compares committed PNGs on every push.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @glissade/backend-skia
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { SkiaBackend } from '@glissade/backend-skia';
|
|
11
|
+
|
|
12
|
+
const backend = new SkiaBackend(640, 360);
|
|
13
|
+
backend.render(evaluate(scene, doc, t));
|
|
14
|
+
writeFileSync('frame.png', backend.encodePng());
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node-only. The `gs` CLI (`@glissade/cli`) drives it for full renders.
|
|
18
|
+
|
|
19
|
+
## Part of glissade
|
|
20
|
+
|
|
21
|
+
*(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
|
|
22
|
+
|
|
23
|
+
- [Repository & full README](https://github.com/tyevco/glissade)
|
|
24
|
+
- [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
|
|
25
|
+
|
|
26
|
+
Apache-2.0.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,34 +1,22 @@
|
|
|
1
1
|
import { Canvas, Image } from "@napi-rs/canvas";
|
|
2
|
-
import { DisplayList, FontSpec, VideoFrameSource } from "@glissade/scene";
|
|
2
|
+
import { DisplayList, FontSpec, TextMetricsLite, TextMetricsLite as TextMetricsLite$1, VideoFrameSource } from "@glissade/scene";
|
|
3
3
|
|
|
4
4
|
//#region src/index.d.ts
|
|
5
5
|
|
|
6
6
|
type Drawable = Canvas | Image;
|
|
7
|
-
interface TextMetricsLite {
|
|
8
|
-
width: number;
|
|
9
|
-
ascent: number;
|
|
10
|
-
descent: number;
|
|
11
|
-
}
|
|
12
7
|
declare class SkiaBackend {
|
|
13
8
|
private readonly canvas;
|
|
14
|
-
private readonly
|
|
15
|
-
private pathCache;
|
|
16
|
-
private readonly images;
|
|
17
|
-
private readonly videos;
|
|
9
|
+
private readonly raster;
|
|
18
10
|
constructor(width: number, height: number);
|
|
19
11
|
setImageAsset(assetId: string, image: Drawable): void;
|
|
20
12
|
setVideoAsset(assetId: string, source: VideoFrameSource): void;
|
|
21
|
-
|
|
22
|
-
measureText(text: string, font: FontSpec): TextMetricsLite;
|
|
13
|
+
measureText(text: string, font: FontSpec): TextMetricsLite$1;
|
|
23
14
|
render(list: DisplayList): void;
|
|
24
15
|
/** Raw RGBA — the FFmpeg pipe path (§5.1d). Synchronous; no GPU readback. */
|
|
25
16
|
readPixels(): Uint8ClampedArray;
|
|
26
17
|
/** Deterministic PNG bytes for golden frames and `gs render` output. */
|
|
27
18
|
encodePng(): Buffer;
|
|
28
19
|
dispose(): void;
|
|
29
|
-
private path;
|
|
30
|
-
private acquire;
|
|
31
|
-
private release;
|
|
32
20
|
}
|
|
33
21
|
//#endregion
|
|
34
|
-
export { SkiaBackend, TextMetricsLite };
|
|
22
|
+
export { SkiaBackend, type TextMetricsLite };
|
package/dist/index.js
CHANGED
|
@@ -1,72 +1,29 @@
|
|
|
1
1
|
import { Path2D, createCanvas } from "@napi-rs/canvas";
|
|
2
|
-
import {
|
|
2
|
+
import { Raster2D, fontString } from "@glissade/scene";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* @glissade/backend-skia — headless DisplayList rasterizer over @napi-rs/canvas
|
|
6
|
-
* (DESIGN.md §3.4). The per-path-deterministic twin of backend-canvas2d
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* (DESIGN.md §3.4). The per-path-deterministic twin of backend-canvas2d, now
|
|
7
|
+
* sharing the ONE Raster2D interpreter in @glissade/scene — the twins
|
|
8
|
+
* structurally cannot drift. This file owns only the @napi-rs canvas flavor
|
|
9
|
+
* plus headless concerns (PNG encode, sync readPixels, text measurement).
|
|
10
10
|
*/
|
|
11
|
-
function fontString(font) {
|
|
12
|
-
return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
|
|
13
|
-
}
|
|
14
|
-
function buildPath(segs) {
|
|
15
|
-
const p = new Path2D();
|
|
16
|
-
for (const seg of segs) switch (seg[0]) {
|
|
17
|
-
case "M":
|
|
18
|
-
p.moveTo(seg[1], seg[2]);
|
|
19
|
-
break;
|
|
20
|
-
case "L":
|
|
21
|
-
p.lineTo(seg[1], seg[2]);
|
|
22
|
-
break;
|
|
23
|
-
case "C":
|
|
24
|
-
p.bezierCurveTo(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6]);
|
|
25
|
-
break;
|
|
26
|
-
case "Q":
|
|
27
|
-
p.quadraticCurveTo(seg[1], seg[2], seg[3], seg[4]);
|
|
28
|
-
break;
|
|
29
|
-
case "E":
|
|
30
|
-
p.ellipse(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6], seg[7]);
|
|
31
|
-
break;
|
|
32
|
-
case "Z":
|
|
33
|
-
p.closePath();
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
|
-
return p;
|
|
37
|
-
}
|
|
38
11
|
var SkiaBackend = class {
|
|
39
12
|
canvas;
|
|
40
|
-
|
|
41
|
-
pathCache = /* @__PURE__ */ new WeakMap();
|
|
42
|
-
images = /* @__PURE__ */ new Map();
|
|
43
|
-
videos = /* @__PURE__ */ new Map();
|
|
13
|
+
raster;
|
|
44
14
|
constructor(width, height) {
|
|
45
15
|
this.canvas = createCanvas(width, height);
|
|
16
|
+
this.raster = new Raster2D({
|
|
17
|
+
context: (c) => c.getContext("2d"),
|
|
18
|
+
createCanvas: (w, h) => createCanvas(w, h),
|
|
19
|
+
newPath: () => new Path2D()
|
|
20
|
+
});
|
|
46
21
|
}
|
|
47
22
|
setImageAsset(assetId, image) {
|
|
48
|
-
this.
|
|
23
|
+
this.raster.setImageAsset(assetId, image);
|
|
49
24
|
}
|
|
50
25
|
setVideoAsset(assetId, source) {
|
|
51
|
-
this.
|
|
52
|
-
}
|
|
53
|
-
resolveDrawable(res, id) {
|
|
54
|
-
if (res.kind === "image") {
|
|
55
|
-
const img = this.images.get(res.assetId);
|
|
56
|
-
if (!img) throw new ColdAssetError(res.assetId, "no decoded image registered");
|
|
57
|
-
return img;
|
|
58
|
-
}
|
|
59
|
-
if (res.kind === "videoFrame") {
|
|
60
|
-
const source = this.videos.get(res.assetId);
|
|
61
|
-
if (!source) throw new ColdAssetError(res.assetId, "no VideoFrameSource registered", res.mediaT);
|
|
62
|
-
try {
|
|
63
|
-
return source.getFrameSync(res.mediaT);
|
|
64
|
-
} catch (e) {
|
|
65
|
-
if (e instanceof ColdAssetError) throw new ColdAssetError(res.assetId, e.detail, res.mediaT);
|
|
66
|
-
throw e;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
throw new Error(`resource ${id} is not drawable`);
|
|
26
|
+
this.raster.setVideoAsset(assetId, source);
|
|
70
27
|
}
|
|
71
28
|
measureText(text, font) {
|
|
72
29
|
const ctx = this.canvas.getContext("2d");
|
|
@@ -81,99 +38,7 @@ var SkiaBackend = class {
|
|
|
81
38
|
};
|
|
82
39
|
}
|
|
83
40
|
render(list) {
|
|
84
|
-
|
|
85
|
-
if (this.canvas.width !== w) this.canvas.width = w;
|
|
86
|
-
if (this.canvas.height !== h) this.canvas.height = h;
|
|
87
|
-
const base = this.canvas.getContext("2d");
|
|
88
|
-
base.resetTransform();
|
|
89
|
-
base.clearRect(0, 0, w, h);
|
|
90
|
-
const layers = [{
|
|
91
|
-
ctx: base,
|
|
92
|
-
canvas: null,
|
|
93
|
-
opacity: 1,
|
|
94
|
-
blend: "source-over"
|
|
95
|
-
}];
|
|
96
|
-
const ctxOf = () => layers[layers.length - 1].ctx;
|
|
97
|
-
for (const cmd of list.commands) switch (cmd.op) {
|
|
98
|
-
case "save":
|
|
99
|
-
ctxOf().save();
|
|
100
|
-
break;
|
|
101
|
-
case "restore":
|
|
102
|
-
ctxOf().restore();
|
|
103
|
-
break;
|
|
104
|
-
case "transform":
|
|
105
|
-
ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
|
|
106
|
-
break;
|
|
107
|
-
case "clip":
|
|
108
|
-
ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
|
|
109
|
-
break;
|
|
110
|
-
case "fillPath": {
|
|
111
|
-
const ctx = ctxOf();
|
|
112
|
-
ctx.fillStyle = cmd.paint.color;
|
|
113
|
-
ctx.fill(this.path(list.resources, cmd.path));
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
case "strokePath": {
|
|
117
|
-
const ctx = ctxOf();
|
|
118
|
-
ctx.strokeStyle = cmd.paint.color;
|
|
119
|
-
ctx.lineWidth = cmd.stroke.width;
|
|
120
|
-
ctx.lineCap = cmd.stroke.cap ?? "butt";
|
|
121
|
-
ctx.lineJoin = cmd.stroke.join ?? "miter";
|
|
122
|
-
if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
|
|
123
|
-
ctx.stroke(this.path(list.resources, cmd.path));
|
|
124
|
-
if (cmd.stroke.dash) ctx.setLineDash([]);
|
|
125
|
-
break;
|
|
126
|
-
}
|
|
127
|
-
case "fillText": {
|
|
128
|
-
const ctx = ctxOf();
|
|
129
|
-
ctx.font = fontString(cmd.font);
|
|
130
|
-
ctx.fillStyle = cmd.paint.color;
|
|
131
|
-
ctx.textBaseline = "alphabetic";
|
|
132
|
-
ctx.textAlign = cmd.align ?? "left";
|
|
133
|
-
ctx.fillText(cmd.text, cmd.x, cmd.y);
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
case "drawImage": {
|
|
137
|
-
const res = list.resources[cmd.image];
|
|
138
|
-
if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
|
|
139
|
-
const drawable = this.resolveDrawable(res, cmd.image);
|
|
140
|
-
const ctx = ctxOf();
|
|
141
|
-
if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
|
|
142
|
-
const { x, y, w: dw, h: dh } = cmd.dst;
|
|
143
|
-
if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
|
|
144
|
-
else ctx.drawImage(drawable, x, y, dw, dh);
|
|
145
|
-
break;
|
|
146
|
-
}
|
|
147
|
-
case "pushGroup": {
|
|
148
|
-
const parent = ctxOf();
|
|
149
|
-
const layerCanvas = this.acquire(w, h);
|
|
150
|
-
const layerCtx = layerCanvas.getContext("2d");
|
|
151
|
-
layerCtx.resetTransform();
|
|
152
|
-
layerCtx.clearRect(0, 0, w, h);
|
|
153
|
-
layerCtx.setTransform(parent.getTransform());
|
|
154
|
-
layers.push({
|
|
155
|
-
ctx: layerCtx,
|
|
156
|
-
canvas: layerCanvas,
|
|
157
|
-
opacity: cmd.opacity,
|
|
158
|
-
blend: cmd.blend
|
|
159
|
-
});
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
case "popGroup": {
|
|
163
|
-
const layer = layers.pop();
|
|
164
|
-
if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
|
|
165
|
-
const parent = ctxOf();
|
|
166
|
-
parent.save();
|
|
167
|
-
parent.resetTransform();
|
|
168
|
-
parent.globalAlpha = layer.opacity;
|
|
169
|
-
parent.globalCompositeOperation = layer.blend;
|
|
170
|
-
parent.drawImage(layer.canvas, 0, 0);
|
|
171
|
-
parent.restore();
|
|
172
|
-
this.release(layer.canvas);
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (layers.length !== 1) throw new Error("unbalanced pushGroup/popGroup in DisplayList");
|
|
41
|
+
this.raster.render(this.canvas, list);
|
|
177
42
|
}
|
|
178
43
|
/** Raw RGBA — the FFmpeg pipe path (§5.1d). Synchronous; no GPU readback. */
|
|
179
44
|
readPixels() {
|
|
@@ -184,29 +49,7 @@ var SkiaBackend = class {
|
|
|
184
49
|
return this.canvas.toBuffer("image/png");
|
|
185
50
|
}
|
|
186
51
|
dispose() {
|
|
187
|
-
this.
|
|
188
|
-
}
|
|
189
|
-
path(resources, id) {
|
|
190
|
-
const res = resources[id];
|
|
191
|
-
if (!res || res.kind !== "path") throw new Error(`resource ${id} is not a path`);
|
|
192
|
-
let p = this.pathCache.get(res);
|
|
193
|
-
if (!p) {
|
|
194
|
-
p = buildPath(res.segs);
|
|
195
|
-
this.pathCache.set(res, p);
|
|
196
|
-
}
|
|
197
|
-
return p;
|
|
198
|
-
}
|
|
199
|
-
acquire(w, h) {
|
|
200
|
-
const pooled = this.pool.pop();
|
|
201
|
-
if (pooled) {
|
|
202
|
-
if (pooled.width !== w) pooled.width = w;
|
|
203
|
-
if (pooled.height !== h) pooled.height = h;
|
|
204
|
-
return pooled;
|
|
205
|
-
}
|
|
206
|
-
return createCanvas(w, h);
|
|
207
|
-
}
|
|
208
|
-
release(canvas) {
|
|
209
|
-
if (this.pool.length < 8) this.pool.push(canvas);
|
|
52
|
+
this.raster.dispose();
|
|
210
53
|
}
|
|
211
54
|
};
|
|
212
55
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/backend-skia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "glissade headless render backend: DisplayList -> @napi-rs/canvas (Skia). Node-only; the CI-grade deterministic path.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@napi-rs/canvas": "^0.1.65",
|
|
19
|
-
"@glissade/core": "0.
|
|
20
|
-
"@glissade/scene": "0.
|
|
19
|
+
"@glissade/core": "0.3.0",
|
|
20
|
+
"@glissade/scene": "0.3.0"
|
|
21
21
|
},
|
|
22
22
|
"repository": {
|
|
23
23
|
"type": "git",
|