@glissade/backend-canvas2d 0.2.0 → 0.4.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 +16 -17
- package/dist/index.js +20 -171
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @glissade/backend-canvas2d
|
|
2
|
+
|
|
3
|
+
The browser rasterizer: consumes a DisplayList and draws it to a `<canvas>` / `OffscreenCanvas` 2D context — transforms, paths, text, group compositing. Per-path deterministic twin of `@glissade/backend-skia`; the two are held together by an SSIM parity suite in CI.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @glissade/backend-canvas2d
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Canvas2DBackend } from '@glissade/backend-canvas2d';
|
|
11
|
+
import { evaluate } from '@glissade/scene';
|
|
12
|
+
|
|
13
|
+
const backend = new Canvas2DBackend(canvas);
|
|
14
|
+
backend.render(evaluate(scene, doc, t));
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Most apps don't use this directly — `mount()` from `@glissade/player` wires it up.
|
|
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,33 @@
|
|
|
1
|
-
import { DisplayList, DrawCommand, FontSpec, VideoFrameSource } from "@glissade/scene";
|
|
1
|
+
import { DisplayList, DrawCommand, FontSpec, ShaderCaps, ShaderRef, TextMetricsLite, TextMetricsLite as TextMetricsLite$1, VideoFrameSource } from "@glissade/scene";
|
|
2
2
|
|
|
3
3
|
//#region src/index.d.ts
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* §3.7 shader runner seam: @glissade/effects-webgpu registers here at load
|
|
7
|
+
* time (the loadYogaLayoutEngine pattern). This package never imports GPU
|
|
8
|
+
* code — headless paths stay clean by construction.
|
|
9
|
+
*/
|
|
10
|
+
interface ShaderRunner {
|
|
11
|
+
apply(layer: AnyCanvas, shader: ShaderRef, w: number, h: number): Drawable | null;
|
|
12
|
+
}
|
|
13
|
+
declare function setShaderRunner(runner: ShaderRunner | null): void;
|
|
5
14
|
type Drawable = Exclude<CanvasImageSource, SVGImageElement>;
|
|
6
15
|
type AnyCanvas = HTMLCanvasElement | OffscreenCanvas;
|
|
7
|
-
interface TextMetricsLite {
|
|
8
|
-
width: number;
|
|
9
|
-
ascent: number;
|
|
10
|
-
descent: number;
|
|
11
|
-
}
|
|
12
16
|
declare class Canvas2DBackend {
|
|
13
17
|
private readonly target;
|
|
14
|
-
private readonly
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
constructor(target: AnyCanvas);
|
|
18
|
+
private readonly raster;
|
|
19
|
+
constructor(target: AnyCanvas, opts?: {
|
|
20
|
+
shaderCaps?: ShaderCaps;
|
|
21
|
+
});
|
|
19
22
|
/** Register a decoded still (kind 'image' assets). */
|
|
20
23
|
setImageAsset(assetId: string, image: Drawable): void;
|
|
21
24
|
/** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
|
|
22
25
|
setVideoAsset(assetId: string, source: VideoFrameSource): void;
|
|
23
|
-
|
|
24
|
-
measureText(text: string, font: FontSpec): TextMetricsLite;
|
|
26
|
+
measureText(text: string, font: FontSpec): TextMetricsLite$1;
|
|
25
27
|
render(list: DisplayList): void;
|
|
26
28
|
readPixels(): Promise<Uint8ClampedArray>;
|
|
27
29
|
dispose(): void;
|
|
28
30
|
private context;
|
|
29
|
-
private path;
|
|
30
|
-
private acquire;
|
|
31
|
-
private release;
|
|
32
31
|
}
|
|
33
32
|
//#endregion
|
|
34
|
-
export { Canvas2DBackend, type DisplayList, type DrawCommand, TextMetricsLite };
|
|
33
|
+
export { Canvas2DBackend, type DisplayList, type DrawCommand, ShaderRunner, type TextMetricsLite, setShaderRunner };
|
package/dist/index.js
CHANGED
|
@@ -1,71 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Raster2D, fontString } from "@glissade/scene";
|
|
2
2
|
//#region src/index.ts
|
|
3
3
|
/**
|
|
4
4
|
* @glissade/backend-canvas2d — rasterize DisplayLists onto Canvas 2D
|
|
5
|
-
* (DESIGN.md §3.4–§3.5).
|
|
6
|
-
*
|
|
5
|
+
* (DESIGN.md §3.4–§3.5). A thin adapter over the shared Raster2D interpreter
|
|
6
|
+
* in @glissade/scene: this file owns only the DOM canvas flavor (context
|
|
7
|
+
* acquisition, OffscreenCanvas layers, Path2D) and text measurement.
|
|
7
8
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function buildPath(segs) {
|
|
12
|
-
const p = new Path2D();
|
|
13
|
-
for (const seg of segs) switch (seg[0]) {
|
|
14
|
-
case "M":
|
|
15
|
-
p.moveTo(seg[1], seg[2]);
|
|
16
|
-
break;
|
|
17
|
-
case "L":
|
|
18
|
-
p.lineTo(seg[1], seg[2]);
|
|
19
|
-
break;
|
|
20
|
-
case "C":
|
|
21
|
-
p.bezierCurveTo(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6]);
|
|
22
|
-
break;
|
|
23
|
-
case "Q":
|
|
24
|
-
p.quadraticCurveTo(seg[1], seg[2], seg[3], seg[4]);
|
|
25
|
-
break;
|
|
26
|
-
case "E":
|
|
27
|
-
p.ellipse(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6], seg[7]);
|
|
28
|
-
break;
|
|
29
|
-
case "Z":
|
|
30
|
-
p.closePath();
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
return p;
|
|
9
|
+
let shaderRunner = null;
|
|
10
|
+
function setShaderRunner(runner) {
|
|
11
|
+
shaderRunner = runner;
|
|
34
12
|
}
|
|
35
13
|
var Canvas2DBackend = class {
|
|
36
14
|
target;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
images = /* @__PURE__ */ new Map();
|
|
40
|
-
videos = /* @__PURE__ */ new Map();
|
|
41
|
-
constructor(target) {
|
|
15
|
+
raster;
|
|
16
|
+
constructor(target, opts = {}) {
|
|
42
17
|
this.target = target;
|
|
18
|
+
this.raster = new Raster2D({
|
|
19
|
+
context: (c) => this.context(c),
|
|
20
|
+
createCanvas: (w, h) => new OffscreenCanvas(w, h),
|
|
21
|
+
newPath: () => new Path2D(),
|
|
22
|
+
applyShader: (layer, shader, w, h) => shaderRunner?.apply(layer, shader, w, h) ?? null
|
|
23
|
+
}, opts.shaderCaps ?? "warn");
|
|
43
24
|
}
|
|
44
25
|
/** Register a decoded still (kind 'image' assets). */
|
|
45
26
|
setImageAsset(assetId, image) {
|
|
46
|
-
this.
|
|
27
|
+
this.raster.setImageAsset(assetId, image);
|
|
47
28
|
}
|
|
48
29
|
/** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
|
|
49
30
|
setVideoAsset(assetId, source) {
|
|
50
|
-
this.
|
|
51
|
-
}
|
|
52
|
-
resolveDrawable(res, id) {
|
|
53
|
-
if (res.kind === "image") {
|
|
54
|
-
const img = this.images.get(res.assetId);
|
|
55
|
-
if (!img) throw new ColdAssetError(res.assetId, "no decoded image registered");
|
|
56
|
-
return img;
|
|
57
|
-
}
|
|
58
|
-
if (res.kind === "videoFrame") {
|
|
59
|
-
const source = this.videos.get(res.assetId);
|
|
60
|
-
if (!source) throw new ColdAssetError(res.assetId, "no VideoFrameSource registered", res.mediaT);
|
|
61
|
-
try {
|
|
62
|
-
return source.getFrameSync(res.mediaT);
|
|
63
|
-
} catch (e) {
|
|
64
|
-
if (e instanceof ColdAssetError) throw new ColdAssetError(res.assetId, e.detail, res.mediaT);
|
|
65
|
-
throw e;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
throw new Error(`resource ${id} is not drawable`);
|
|
31
|
+
this.raster.setVideoAsset(assetId, source);
|
|
69
32
|
}
|
|
70
33
|
measureText(text, font) {
|
|
71
34
|
const ctx = this.context(this.target);
|
|
@@ -80,133 +43,19 @@ var Canvas2DBackend = class {
|
|
|
80
43
|
};
|
|
81
44
|
}
|
|
82
45
|
render(list) {
|
|
83
|
-
|
|
84
|
-
const { w, h } = list.size;
|
|
85
|
-
if (this.target.width !== w) this.target.width = w;
|
|
86
|
-
if (this.target.height !== h) this.target.height = h;
|
|
87
|
-
base.resetTransform();
|
|
88
|
-
base.clearRect(0, 0, w, h);
|
|
89
|
-
const layers = [{
|
|
90
|
-
ctx: base,
|
|
91
|
-
canvas: null,
|
|
92
|
-
opacity: 1,
|
|
93
|
-
blend: "source-over"
|
|
94
|
-
}];
|
|
95
|
-
const ctxOf = () => layers[layers.length - 1].ctx;
|
|
96
|
-
for (const cmd of list.commands) switch (cmd.op) {
|
|
97
|
-
case "save":
|
|
98
|
-
ctxOf().save();
|
|
99
|
-
break;
|
|
100
|
-
case "restore":
|
|
101
|
-
ctxOf().restore();
|
|
102
|
-
break;
|
|
103
|
-
case "transform":
|
|
104
|
-
ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
|
|
105
|
-
break;
|
|
106
|
-
case "clip":
|
|
107
|
-
ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
|
|
108
|
-
break;
|
|
109
|
-
case "fillPath": {
|
|
110
|
-
const ctx = ctxOf();
|
|
111
|
-
ctx.fillStyle = cmd.paint.color;
|
|
112
|
-
ctx.fill(this.path(list.resources, cmd.path));
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
case "strokePath": {
|
|
116
|
-
const ctx = ctxOf();
|
|
117
|
-
ctx.strokeStyle = cmd.paint.color;
|
|
118
|
-
ctx.lineWidth = cmd.stroke.width;
|
|
119
|
-
ctx.lineCap = cmd.stroke.cap ?? "butt";
|
|
120
|
-
ctx.lineJoin = cmd.stroke.join ?? "miter";
|
|
121
|
-
if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
|
|
122
|
-
ctx.stroke(this.path(list.resources, cmd.path));
|
|
123
|
-
if (cmd.stroke.dash) ctx.setLineDash([]);
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
case "fillText": {
|
|
127
|
-
const ctx = ctxOf();
|
|
128
|
-
ctx.font = fontString(cmd.font);
|
|
129
|
-
ctx.fillStyle = cmd.paint.color;
|
|
130
|
-
ctx.textBaseline = "alphabetic";
|
|
131
|
-
ctx.textAlign = cmd.align ?? "left";
|
|
132
|
-
ctx.fillText(cmd.text, cmd.x, cmd.y);
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
case "drawImage": {
|
|
136
|
-
const res = list.resources[cmd.image];
|
|
137
|
-
if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
|
|
138
|
-
const drawable = this.resolveDrawable(res, cmd.image);
|
|
139
|
-
const ctx = ctxOf();
|
|
140
|
-
if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
|
|
141
|
-
const { x, y, w: dw, h: dh } = cmd.dst;
|
|
142
|
-
if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
|
|
143
|
-
else ctx.drawImage(drawable, x, y, dw, dh);
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
case "pushGroup": {
|
|
147
|
-
const parent = ctxOf();
|
|
148
|
-
const layerCanvas = this.acquire(w, h);
|
|
149
|
-
const layerCtx = layerCanvas.getContext("2d");
|
|
150
|
-
layerCtx.resetTransform();
|
|
151
|
-
layerCtx.clearRect(0, 0, w, h);
|
|
152
|
-
layerCtx.setTransform(parent.getTransform());
|
|
153
|
-
layers.push({
|
|
154
|
-
ctx: layerCtx,
|
|
155
|
-
canvas: layerCanvas,
|
|
156
|
-
opacity: cmd.opacity,
|
|
157
|
-
blend: cmd.blend
|
|
158
|
-
});
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
case "popGroup": {
|
|
162
|
-
const layer = layers.pop();
|
|
163
|
-
if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
|
|
164
|
-
const parent = ctxOf();
|
|
165
|
-
parent.save();
|
|
166
|
-
parent.resetTransform();
|
|
167
|
-
parent.globalAlpha = layer.opacity;
|
|
168
|
-
parent.globalCompositeOperation = layer.blend;
|
|
169
|
-
parent.drawImage(layer.canvas, 0, 0);
|
|
170
|
-
parent.restore();
|
|
171
|
-
this.release(layer.canvas);
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (layers.length !== 1) throw new Error("unbalanced pushGroup/popGroup in DisplayList");
|
|
46
|
+
this.raster.render(this.target, list);
|
|
176
47
|
}
|
|
177
48
|
async readPixels() {
|
|
178
49
|
return this.context(this.target).getImageData(0, 0, this.target.width, this.target.height).data;
|
|
179
50
|
}
|
|
180
51
|
dispose() {
|
|
181
|
-
this.
|
|
52
|
+
this.raster.dispose();
|
|
182
53
|
}
|
|
183
54
|
context(canvas) {
|
|
184
55
|
const ctx = canvas.getContext("2d");
|
|
185
56
|
if (!ctx) throw new Error("canvas 2d context unavailable");
|
|
186
57
|
return ctx;
|
|
187
58
|
}
|
|
188
|
-
path(resources, id) {
|
|
189
|
-
const res = resources[id];
|
|
190
|
-
if (!res || res.kind !== "path") throw new Error(`resource ${id} is not a path`);
|
|
191
|
-
let p = this.pathCache.get(res);
|
|
192
|
-
if (!p) {
|
|
193
|
-
p = buildPath(res.segs);
|
|
194
|
-
this.pathCache.set(res, p);
|
|
195
|
-
}
|
|
196
|
-
return p;
|
|
197
|
-
}
|
|
198
|
-
acquire(w, h) {
|
|
199
|
-
const pooled = this.pool.pop();
|
|
200
|
-
if (pooled) {
|
|
201
|
-
if (pooled.width !== w) pooled.width = w;
|
|
202
|
-
if (pooled.height !== h) pooled.height = h;
|
|
203
|
-
return pooled;
|
|
204
|
-
}
|
|
205
|
-
return new OffscreenCanvas(w, h);
|
|
206
|
-
}
|
|
207
|
-
release(canvas) {
|
|
208
|
-
if (this.pool.length < 8) this.pool.push(canvas);
|
|
209
|
-
}
|
|
210
59
|
};
|
|
211
60
|
//#endregion
|
|
212
|
-
export { Canvas2DBackend };
|
|
61
|
+
export { Canvas2DBackend, setShaderRunner };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/backend-canvas2d",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "glissade Canvas 2D render backend: DisplayList -> CanvasRenderingContext2D/OffscreenCanvas.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"dist"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@glissade/core": "0.
|
|
19
|
-
"@glissade/scene": "0.
|
|
18
|
+
"@glissade/core": "0.4.0",
|
|
19
|
+
"@glissade/scene": "0.4.0"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|