@glissade/backend-canvas2d 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 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,22 @@
1
- import { DisplayList, DrawCommand, FontSpec, VideoFrameSource } from "@glissade/scene";
1
+ import { DisplayList, DrawCommand, FontSpec, TextMetricsLite, TextMetricsLite as TextMetricsLite$1, VideoFrameSource } from "@glissade/scene";
2
2
 
3
3
  //#region src/index.d.ts
4
4
 
5
5
  type Drawable = Exclude<CanvasImageSource, SVGImageElement>;
6
6
  type AnyCanvas = HTMLCanvasElement | OffscreenCanvas;
7
- interface TextMetricsLite {
8
- width: number;
9
- ascent: number;
10
- descent: number;
11
- }
12
7
  declare class Canvas2DBackend {
13
8
  private readonly target;
14
- private readonly pool;
15
- private pathCache;
16
- private readonly images;
17
- private readonly videos;
9
+ private readonly raster;
18
10
  constructor(target: AnyCanvas);
19
11
  /** Register a decoded still (kind 'image' assets). */
20
12
  setImageAsset(assetId: string, image: Drawable): void;
21
13
  /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
22
14
  setVideoAsset(assetId: string, source: VideoFrameSource): void;
23
- private resolveDrawable;
24
- measureText(text: string, font: FontSpec): TextMetricsLite;
15
+ measureText(text: string, font: FontSpec): TextMetricsLite$1;
25
16
  render(list: DisplayList): void;
26
17
  readPixels(): Promise<Uint8ClampedArray>;
27
18
  dispose(): void;
28
19
  private context;
29
- private path;
30
- private acquire;
31
- private release;
32
20
  }
33
21
  //#endregion
34
- export { Canvas2DBackend, type DisplayList, type DrawCommand, TextMetricsLite };
22
+ export { Canvas2DBackend, type DisplayList, type DrawCommand, type TextMetricsLite };
package/dist/index.js CHANGED
@@ -1,71 +1,29 @@
1
- import { ColdAssetError } from "@glissade/scene";
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). Groups realize as pooled temporary canvases so
6
- * group opacity/blend composite correctly (children don't individually fade).
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
- function fontString(font) {
9
- return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
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;
34
- }
35
9
  var Canvas2DBackend = class {
36
10
  target;
37
- pool = [];
38
- pathCache = /* @__PURE__ */ new WeakMap();
39
- images = /* @__PURE__ */ new Map();
40
- videos = /* @__PURE__ */ new Map();
11
+ raster;
41
12
  constructor(target) {
42
13
  this.target = target;
14
+ this.raster = new Raster2D({
15
+ context: (c) => this.context(c),
16
+ createCanvas: (w, h) => new OffscreenCanvas(w, h),
17
+ newPath: () => new Path2D()
18
+ });
43
19
  }
44
20
  /** Register a decoded still (kind 'image' assets). */
45
21
  setImageAsset(assetId, image) {
46
- this.images.set(assetId, image);
22
+ this.raster.setImageAsset(assetId, image);
47
23
  }
48
24
  /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
49
25
  setVideoAsset(assetId, source) {
50
- this.videos.set(assetId, source);
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`);
26
+ this.raster.setVideoAsset(assetId, source);
69
27
  }
70
28
  measureText(text, font) {
71
29
  const ctx = this.context(this.target);
@@ -80,133 +38,19 @@ var Canvas2DBackend = class {
80
38
  };
81
39
  }
82
40
  render(list) {
83
- const base = this.context(this.target);
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");
41
+ this.raster.render(this.target, list);
176
42
  }
177
43
  async readPixels() {
178
44
  return this.context(this.target).getImageData(0, 0, this.target.width, this.target.height).data;
179
45
  }
180
46
  dispose() {
181
- this.pool.length = 0;
47
+ this.raster.dispose();
182
48
  }
183
49
  context(canvas) {
184
50
  const ctx = canvas.getContext("2d");
185
51
  if (!ctx) throw new Error("canvas 2d context unavailable");
186
52
  return ctx;
187
53
  }
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
54
  };
211
55
  //#endregion
212
56
  export { Canvas2DBackend };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/backend-canvas2d",
3
- "version": "0.1.0",
3
+ "version": "0.3.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.1.0",
19
- "@glissade/scene": "0.1.0"
18
+ "@glissade/core": "0.3.0",
19
+ "@glissade/scene": "0.3.0"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",