freshcoat 0.1.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/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +110 -0
- package/package.json +53 -0
- package/src/adjust.d.ts +18 -0
- package/src/adjust.js +153 -0
- package/src/approx-layout.d.ts +2 -0
- package/src/approx-layout.js +91 -0
- package/src/bake-text.d.ts +12 -0
- package/src/bake-text.js +321 -0
- package/src/browser.d.ts +7 -0
- package/src/browser.js +79 -0
- package/src/canvaskit.d.ts +4 -0
- package/src/canvaskit.js +1674 -0
- package/src/compile-scene.d.ts +24 -0
- package/src/compile-scene.js +325 -0
- package/src/css.d.ts +5 -0
- package/src/css.js +153 -0
- package/src/decode.d.ts +22 -0
- package/src/decode.js +112 -0
- package/src/export-scale.d.ts +25 -0
- package/src/export-scale.js +74 -0
- package/src/font-bytes.d.ts +4 -0
- package/src/font-bytes.js +165 -0
- package/src/font-metrics.d.ts +5 -0
- package/src/font-metrics.js +62 -0
- package/src/headless.d.ts +24 -0
- package/src/headless.js +58 -0
- package/src/index.d.ts +25 -0
- package/src/index.js +29 -0
- package/src/jpeg.d.ts +5 -0
- package/src/jpeg.js +20 -0
- package/src/line-height.d.ts +7 -0
- package/src/line-height.js +80 -0
- package/src/node.d.ts +139 -0
- package/src/node.js +47 -0
- package/src/paint-cache.d.ts +49 -0
- package/src/paint-cache.js +109 -0
- package/src/paint-helpers.d.ts +21 -0
- package/src/paint-helpers.js +71 -0
- package/src/paragraph-layout.d.ts +6 -0
- package/src/paragraph-layout.js +283 -0
- package/src/path-data.d.ts +5 -0
- package/src/path-data.js +101 -0
- package/src/png.d.ts +23 -0
- package/src/png.js +189 -0
- package/src/resolve-layout.d.ts +9 -0
- package/src/resolve-layout.js +657 -0
- package/src/runtime.d.ts +6 -0
- package/src/runtime.js +44 -0
- package/src/squircle.d.ts +1 -0
- package/src/squircle.js +60 -0
- package/src/text-cache.d.ts +14 -0
- package/src/text-cache.js +46 -0
- package/src/text-engine.d.ts +26 -0
- package/src/text-engine.js +1 -0
- package/src/text-types.d.ts +30 -0
- package/src/text-types.js +1 -0
- package/src/types.d.ts +406 -0
- package/src/types.js +13 -0
- package/src/validate-commands.d.ts +8 -0
- package/src/validate-commands.js +206 -0
package/src/canvaskit.js
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
// CanvasKit (WASM Skia) render backend. Consumes the backend-agnostic Command
|
|
2
|
+
// IR (one scene's `Command[]` from compileScene) and paints via the full Skia
|
|
3
|
+
// API: Canvas/Paint/Shader for fills, ParagraphBuilder (HarfBuzz shaping →
|
|
4
|
+
// Figma-matching kerning) for text, the ImageFilter graph for blur/shadow, and
|
|
5
|
+
// drawImageRect/image shaders for images.
|
|
6
|
+
//
|
|
7
|
+
// The caller supplies an initialized CanvasKit instance (this module does not
|
|
8
|
+
// import `canvaskit-wasm`, so it stays runtime-agnostic), the font bytes, and
|
|
9
|
+
// any image bytes keyed by src. Text is drawn at the compile-baked baseline
|
|
10
|
+
// (`line.baseline`).
|
|
11
|
+
import { exportPixelSize, resolveSupersample } from "./export-scale.js";
|
|
12
|
+
import { fontBytes } from "./font-bytes.js";
|
|
13
|
+
import { cachedFontProvider, cachedSurface, evictUnusedImages, paintCacheState, } from "./paint-cache.js";
|
|
14
|
+
import { decorationLine, fitRect, fontVariationList, insetCorner, strokeInset, } from "./paint-helpers.js";
|
|
15
|
+
import { flattenOverWhite } from "./jpeg.js";
|
|
16
|
+
import { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, encodePng, } from "./png.js";
|
|
17
|
+
import { squircleSvg } from "./squircle.js";
|
|
18
|
+
// Canvas2D shadowBlur ≈ 2·sigma. Figma's layer-blur value is ~2.27× the
|
|
19
|
+
// Gaussian sigma (bjango blur-radius comparison), so a value of N → sigma N/2.27.
|
|
20
|
+
const SHADOW_SIGMA = (blur) => blur / 2;
|
|
21
|
+
const LAYER_BLUR_SIGMA = (blur) => blur / 2.2727;
|
|
22
|
+
const WEIGHTS = {
|
|
23
|
+
100: "Thin",
|
|
24
|
+
200: "ExtraLight",
|
|
25
|
+
300: "Light",
|
|
26
|
+
400: "Normal",
|
|
27
|
+
500: "Medium",
|
|
28
|
+
600: "SemiBold",
|
|
29
|
+
700: "Bold",
|
|
30
|
+
800: "ExtraBold",
|
|
31
|
+
900: "Black",
|
|
32
|
+
};
|
|
33
|
+
function makeBin() {
|
|
34
|
+
const items = [];
|
|
35
|
+
return {
|
|
36
|
+
track: (o) => {
|
|
37
|
+
if (o && typeof o.delete === "function")
|
|
38
|
+
items.push(o);
|
|
39
|
+
return o;
|
|
40
|
+
},
|
|
41
|
+
free: () => {
|
|
42
|
+
for (const o of items) {
|
|
43
|
+
try {
|
|
44
|
+
o.delete();
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
lutImages: new Map(),
|
|
50
|
+
lut3dImages: new Map(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function toColor(ck, input) {
|
|
54
|
+
const s = input.trim();
|
|
55
|
+
// CanvasKit's parseColorString("transparent") wrongly returns opaque black,
|
|
56
|
+
// which would fill e.g. a QR's transparent background solid black.
|
|
57
|
+
if (s.toLowerCase() === "transparent")
|
|
58
|
+
return ck.TRANSPARENT;
|
|
59
|
+
if (s.startsWith("#")) {
|
|
60
|
+
let h = s.slice(1);
|
|
61
|
+
if (h.length === 3)
|
|
62
|
+
h = h
|
|
63
|
+
.split("")
|
|
64
|
+
.map((c) => c + c)
|
|
65
|
+
.join("");
|
|
66
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
67
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
68
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
69
|
+
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
70
|
+
return ck.Color(r, g, b, a);
|
|
71
|
+
}
|
|
72
|
+
const m = /rgba?\(([^)]+)\)/i.exec(s);
|
|
73
|
+
if (m) {
|
|
74
|
+
const [r, g, b, a] = m[1].split(",").map((v) => parseFloat(v.trim()));
|
|
75
|
+
return ck.Color(r, g, b, a ?? 1);
|
|
76
|
+
}
|
|
77
|
+
// Named colors / hsl() etc. — CanvasKit parses these; fall back to black.
|
|
78
|
+
try {
|
|
79
|
+
const c = ck.parseColorString(s);
|
|
80
|
+
if (c)
|
|
81
|
+
return c;
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
return ck.BLACK;
|
|
85
|
+
}
|
|
86
|
+
function shaderFor(ck, bin, fill, x, y, w, h) {
|
|
87
|
+
if (fill.kind === "linear") {
|
|
88
|
+
return bin.track(ck.Shader.MakeLinearGradient([x + fill.from.x * w, y + fill.from.y * h], [x + fill.to.x * w, y + fill.to.y * h], fill.stops.map((s) => toColor(ck, s.color)), fill.stops.map((s) => s.offset), ck.TileMode.Clamp));
|
|
89
|
+
}
|
|
90
|
+
if (fill.kind === "angular") {
|
|
91
|
+
const cx = x + fill.center.x * w;
|
|
92
|
+
const cy = y + fill.center.y * h;
|
|
93
|
+
// rotation 0 = top; a sweep's 0 is at 3 o'clock, so it turns by
|
|
94
|
+
// rotation - 90. Skia measures sweep angles from 0 to 360, so the turn
|
|
95
|
+
// is a local matrix about the centre: a start angle would leave part
|
|
96
|
+
// of the circle outside the swept window, clamped to an end stop.
|
|
97
|
+
const turn = (((fill.rotation - 90) % 360) + 360) % 360;
|
|
98
|
+
return bin.track(ck.Shader.MakeSweepGradient(cx, cy, fill.stops.map((s) => toColor(ck, s.color)), fill.stops.map((s) => s.offset), ck.TileMode.Clamp, turn === 0 ? null : ck.Matrix.rotated((turn * Math.PI) / 180, cx, cy), 0, 0, 360));
|
|
99
|
+
}
|
|
100
|
+
const cx = x + fill.center.x * w;
|
|
101
|
+
const cy = y + fill.center.y * h;
|
|
102
|
+
const longest = Math.max(w, h);
|
|
103
|
+
const rx = fill.radius * longest;
|
|
104
|
+
const ry = (fill.radiusY ?? fill.radius) * longest;
|
|
105
|
+
const rotation = fill.rotation ?? 0;
|
|
106
|
+
const colors = fill.stops.map((s) => toColor(ck, s.color));
|
|
107
|
+
const offsets = fill.stops.map((s) => s.offset);
|
|
108
|
+
// Skia only draws circles, so an elliptical gradient is that circle under a
|
|
109
|
+
// local matrix: squash the secondary axis to ry/rx, then turn the whole thing
|
|
110
|
+
// to where the primary axis points. Both operate about the center so the
|
|
111
|
+
// gradient stays put. A circular gradient skips the matrix entirely.
|
|
112
|
+
if (rx <= 0 || (ry === rx && rotation % 180 === 0)) {
|
|
113
|
+
return bin.track(ck.Shader.MakeRadialGradient([cx, cy], rx, colors, offsets, ck.TileMode.Clamp));
|
|
114
|
+
}
|
|
115
|
+
const localMatrix = ck.Matrix.multiply(ck.Matrix.rotated((rotation * Math.PI) / 180, cx, cy), ck.Matrix.scaled(1, ry / rx, cx, cy));
|
|
116
|
+
return bin.track(ck.Shader.MakeRadialGradient([cx, cy], rx, colors, offsets, ck.TileMode.Clamp, localMatrix));
|
|
117
|
+
}
|
|
118
|
+
// A TypefaceFontProvider registered from explicit font bytes (no global font
|
|
119
|
+
// registry, no cache poisoning) — Paragraph resolves families against it.
|
|
120
|
+
function makeFontProvider(ck, fonts) {
|
|
121
|
+
const provider = ck.TypefaceFontProvider.Make();
|
|
122
|
+
for (const f of fonts) {
|
|
123
|
+
const buf = f.bytes.buffer.slice(f.bytes.byteOffset, f.bytes.byteOffset + f.bytes.byteLength);
|
|
124
|
+
provider.registerFont(buf, f.family);
|
|
125
|
+
}
|
|
126
|
+
// Stash the registered families on the provider so text styles can append them
|
|
127
|
+
// as a per-glyph fallback chain (drawText → textStyleOf). Without listing them
|
|
128
|
+
// in fontFamilies, CanvasKit renders any glyph the span's font lacks (emoji,
|
|
129
|
+
// CJK, …) as tofu even though a covering font is registered here.
|
|
130
|
+
provider.__families = [
|
|
131
|
+
...new Set(fonts.map((f) => f.family)),
|
|
132
|
+
];
|
|
133
|
+
return provider;
|
|
134
|
+
}
|
|
135
|
+
const STROKE_CAP = {
|
|
136
|
+
butt: "Butt",
|
|
137
|
+
round: "Round",
|
|
138
|
+
square: "Square",
|
|
139
|
+
};
|
|
140
|
+
const STROKE_JOIN = {
|
|
141
|
+
miter: "Miter",
|
|
142
|
+
round: "Round",
|
|
143
|
+
bevel: "Bevel",
|
|
144
|
+
};
|
|
145
|
+
// Build a Skia RRect from a uniform or per-corner radius. CanvasKit's RRect is
|
|
146
|
+
// [l,t,r,b, ulX,ulY, urX,urY, lrX,lrY, llX,llY]; our CornerRadius is
|
|
147
|
+
// [topLeft, topRight, bottomRight, bottomLeft] = ul, ur, lr, ll.
|
|
148
|
+
function rrectFor(ck, rect, cr) {
|
|
149
|
+
if (typeof cr === "number")
|
|
150
|
+
return ck.RRectXY(rect, cr, cr);
|
|
151
|
+
const [tl, tr, br, bl] = cr;
|
|
152
|
+
return Float32Array.of(rect[0], rect[1], rect[2], rect[3], tl, tl, tr, tr, br, br, bl, bl);
|
|
153
|
+
}
|
|
154
|
+
function strokePaint(ck, bin, stroke) {
|
|
155
|
+
const p = bin.track(new ck.Paint());
|
|
156
|
+
p.setAntiAlias(true);
|
|
157
|
+
p.setStyle(ck.PaintStyle.Stroke);
|
|
158
|
+
p.setColor(toColor(ck, stroke.color));
|
|
159
|
+
p.setStrokeWidth(stroke.width);
|
|
160
|
+
p.setStrokeCap(ck.StrokeCap[STROKE_CAP[stroke.cap ?? "butt"]]);
|
|
161
|
+
p.setStrokeJoin(ck.StrokeJoin[STROKE_JOIN[stroke.join ?? "miter"]]);
|
|
162
|
+
if (stroke.dash && stroke.dash.length > 0)
|
|
163
|
+
p.setPathEffect(bin.track(ck.PathEffect.MakeDash(stroke.dash)));
|
|
164
|
+
return p;
|
|
165
|
+
}
|
|
166
|
+
// SVG path string for a clip/stroke shape.
|
|
167
|
+
// This build of CanvasKit exposes no imperative Path builders — only
|
|
168
|
+
// Path.MakeFromSVGString — so we describe shapes as SVG.
|
|
169
|
+
function maskSvg(clip, x, y, w, h) {
|
|
170
|
+
switch (clip.kind) {
|
|
171
|
+
case "rect": {
|
|
172
|
+
// Outset lets text fit:"clip" keep glyph overshoot whole (see ClipOutset).
|
|
173
|
+
const top = clip.outset?.top ?? 0;
|
|
174
|
+
const bottom = clip.outset?.bottom ?? 0;
|
|
175
|
+
const y0 = y - top;
|
|
176
|
+
const y1 = y + h + bottom;
|
|
177
|
+
return `M ${x} ${y0} H ${x + w} V ${y1} H ${x} Z`;
|
|
178
|
+
}
|
|
179
|
+
case "rounded-rect": {
|
|
180
|
+
const r = Math.min(clip.radius, Math.min(w, h) / 2);
|
|
181
|
+
return `M ${x + r} ${y} H ${x + w - r} A ${r} ${r} 0 0 1 ${x + w} ${y + r} V ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} H ${x + r} A ${r} ${r} 0 0 1 ${x} ${y + h - r} V ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z`;
|
|
182
|
+
}
|
|
183
|
+
case "circle": {
|
|
184
|
+
const rr = Math.min(w, h) / 2;
|
|
185
|
+
const cx = x + w / 2;
|
|
186
|
+
const cy = y + h / 2;
|
|
187
|
+
return `M ${cx - rr} ${cy} A ${rr} ${rr} 0 1 0 ${cx + rr} ${cy} A ${rr} ${rr} 0 1 0 ${cx - rr} ${cy} Z`;
|
|
188
|
+
}
|
|
189
|
+
case "ellipse": {
|
|
190
|
+
const rx = w / 2;
|
|
191
|
+
const ry = h / 2;
|
|
192
|
+
const cy = y + h / 2;
|
|
193
|
+
return `M ${x} ${cy} A ${rx} ${ry} 0 1 0 ${x + w} ${cy} A ${rx} ${ry} 0 1 0 ${x} ${cy} Z`;
|
|
194
|
+
}
|
|
195
|
+
case "polygon": {
|
|
196
|
+
const cx = x + w / 2;
|
|
197
|
+
const cy = y + h / 2;
|
|
198
|
+
const rx = w / 2;
|
|
199
|
+
const ry = h / 2;
|
|
200
|
+
const rot = ((clip.rotation ?? 0) * Math.PI) / 180;
|
|
201
|
+
let d = "";
|
|
202
|
+
for (let i = 0; i < clip.sides; i++) {
|
|
203
|
+
const a = -Math.PI / 2 + (i * 2 * Math.PI) / clip.sides + rot;
|
|
204
|
+
d += `${i === 0 ? "M" : "L"} ${cx + rx * Math.cos(a)} ${cy + ry * Math.sin(a)} `;
|
|
205
|
+
}
|
|
206
|
+
return `${d}Z`;
|
|
207
|
+
}
|
|
208
|
+
case "squircle": {
|
|
209
|
+
const max = Math.min(w, h) / 2;
|
|
210
|
+
const r = Math.min(clip.radius, max);
|
|
211
|
+
const p = Math.min(r * 1.5, max);
|
|
212
|
+
const k = p * 0.4;
|
|
213
|
+
return `M ${x + p} ${y} L ${x + w - p} ${y} C ${x + w - k} ${y} ${x + w} ${y + k} ${x + w} ${y + p} L ${x + w} ${y + h - p} C ${x + w} ${y + h - k} ${x + w - k} ${y + h} ${x + w - p} ${y + h} L ${x + p} ${y + h} C ${x + k} ${y + h} ${x} ${y + h - k} ${x} ${y + h - p} L ${x} ${y + p} C ${x} ${y + k} ${x + k} ${y} ${x + p} ${y} Z`;
|
|
214
|
+
}
|
|
215
|
+
default:
|
|
216
|
+
return `M ${x} ${y} H ${x + w} V ${y + h} H ${x} Z`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function maskPath(ck, bin, clip, x, y, w, h) {
|
|
220
|
+
return bin.track(ck.Path.MakeFromSVGString(maskSvg(clip, x, y, w, h)));
|
|
221
|
+
}
|
|
222
|
+
function textStyleOf(ck, span, cmd, fallbackFamilies = []) {
|
|
223
|
+
const weight = WEIGHTS[Math.round((span.font.weight || 400) / 100) * 100] ?? "Normal";
|
|
224
|
+
return {
|
|
225
|
+
color: toColor(ck, span.color ?? cmd.color ?? "#000000"),
|
|
226
|
+
fontFamilies: [
|
|
227
|
+
span.font.family,
|
|
228
|
+
...fallbackFamilies.filter((f) => f !== span.font.family),
|
|
229
|
+
],
|
|
230
|
+
fontSize: span.font.size,
|
|
231
|
+
fontStyle: { weight: ck.FontWeight[weight] },
|
|
232
|
+
// Instantiate a variable face at the span's weight and axes instead of
|
|
233
|
+
// drawing its default instance under synthetic bold — see paragraph-layout's
|
|
234
|
+
// spanTextStyle, which measures with the identical style.
|
|
235
|
+
fontVariations: fontVariationList(span.font.weight, span.font.variations),
|
|
236
|
+
...(span.font.letterSpacing
|
|
237
|
+
? { letterSpacing: span.font.letterSpacing }
|
|
238
|
+
: {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Render each baked line with a ParagraphBuilder — HarfBuzz shaping applies the
|
|
242
|
+
// kerning Figma uses — aligning the paragraph's baseline to the baked
|
|
243
|
+
// baseline via getLineMetrics.
|
|
244
|
+
function drawText(ck, canvas, provider, bin, cmd) {
|
|
245
|
+
// A gradient fill spans the whole text box, applied to every glyph (via a
|
|
246
|
+
// foreground paint) and its decoration, overriding per-span colors.
|
|
247
|
+
const fillShader = cmd.fill && cmd.fill.kind !== "solid"
|
|
248
|
+
? shaderFor(ck, bin, cmd.fill, cmd.pos.x, cmd.pos.y, cmd.size.width, cmd.size.height)
|
|
249
|
+
: null;
|
|
250
|
+
let fgPaint = null;
|
|
251
|
+
let bgPaint = null;
|
|
252
|
+
if (fillShader) {
|
|
253
|
+
fgPaint = bin.track(new ck.Paint());
|
|
254
|
+
fgPaint.setAntiAlias(true);
|
|
255
|
+
fgPaint.setShader(fillShader);
|
|
256
|
+
bgPaint = bin.track(new ck.Paint());
|
|
257
|
+
bgPaint.setColor(ck.TRANSPARENT);
|
|
258
|
+
}
|
|
259
|
+
const fallback = provider.__families ?? [];
|
|
260
|
+
for (const line of cmd.layout.lines) {
|
|
261
|
+
const first = line.spans[0];
|
|
262
|
+
if (!first)
|
|
263
|
+
continue;
|
|
264
|
+
const style = new ck.ParagraphStyle({
|
|
265
|
+
textStyle: textStyleOf(ck, first, cmd, fallback),
|
|
266
|
+
});
|
|
267
|
+
const builder = bin.track(ck.ParagraphBuilder.MakeFromFontProvider(style, provider));
|
|
268
|
+
for (const span of line.spans) {
|
|
269
|
+
const ts = ck.TextStyle(textStyleOf(ck, span, cmd, fallback));
|
|
270
|
+
if (fgPaint)
|
|
271
|
+
builder.pushPaintStyle(ts, fgPaint, bgPaint);
|
|
272
|
+
else
|
|
273
|
+
builder.pushStyle(ts);
|
|
274
|
+
builder.addText(span.text);
|
|
275
|
+
builder.pop();
|
|
276
|
+
}
|
|
277
|
+
const para = bin.track(builder.build());
|
|
278
|
+
para.layout(1e6); // single pre-wrapped line; no re-wrapping
|
|
279
|
+
const lm = para.getLineMetrics();
|
|
280
|
+
const ascent = lm.length ? lm[0].ascent : 0;
|
|
281
|
+
canvas.drawParagraph(para, first.x, (line.baseline ?? line.y) - ascent);
|
|
282
|
+
// Decoration lines are drawn as rects
|
|
283
|
+
// rather than via Paragraph decoration, so both backends agree.
|
|
284
|
+
const baseline = line.baseline ?? line.y;
|
|
285
|
+
for (const span of line.spans) {
|
|
286
|
+
if (!span.font.decoration)
|
|
287
|
+
continue;
|
|
288
|
+
const { top, thickness } = decorationLine(span.font.size, span.font.decoration, baseline);
|
|
289
|
+
const p = bin.track(new ck.Paint());
|
|
290
|
+
p.setAntiAlias(true);
|
|
291
|
+
if (fillShader)
|
|
292
|
+
p.setShader(fillShader);
|
|
293
|
+
else
|
|
294
|
+
p.setColor(toColor(ck, span.color));
|
|
295
|
+
canvas.drawRect(ck.XYWHRect(span.x, top, span.width, thickness), p);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Mitchell–Netravali cubic (B = C = 1/3): Skia's canonical "high quality"
|
|
300
|
+
// resampler — sharp, ring-free magnification and clean mild minification.
|
|
301
|
+
const MITCHELL = 1 / 3;
|
|
302
|
+
// Draw a scaled image slice with quality sampling chosen by scale direction.
|
|
303
|
+
// drawImageRect's bare overload falls back to SkSamplingOptions()'s default —
|
|
304
|
+
// nearest-neighbour, no mipmaps — so any up- or down-scaled placement comes out
|
|
305
|
+
// jagged and soft (visible on the OG share card, whose art is downscaled into
|
|
306
|
+
// its frame). Card art is routinely dropped into a slot larger or smaller than
|
|
307
|
+
// itself, so pick per draw:
|
|
308
|
+
// • Heavy minification (a big photo shrunk into a small slot): sample a
|
|
309
|
+
// mipmapped copy trilinearly, so shrinking doesn't alias or shimmer.
|
|
310
|
+
// • Otherwise (upscale, or a mild shrink like the OG card): Mitchell cubic.
|
|
311
|
+
function drawImageRectHQ(ck, canvas, bin, img, src, dest, paint, scale) {
|
|
312
|
+
// Below this ratio, cubic sampling of the full-res image starts to alias;
|
|
313
|
+
// mipmaps (each level pre-filtered) keep the shrink clean. Above it, cubic
|
|
314
|
+
// is both sharper and cheaper (no mip pyramid to build).
|
|
315
|
+
if (scale > 0 && scale < 0.5) {
|
|
316
|
+
const mipped = bin.track(img.makeCopyWithDefaultMipmaps());
|
|
317
|
+
canvas.drawImageRectOptions(mipped, src, dest, ck.FilterMode.Linear, ck.MipmapMode.Linear, paint);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
canvas.drawImageRectCubic(img, src, dest, MITCHELL, MITCHELL, paint);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// A light-gray field + a centered photo glyph (frame + sun + mountains) for an unresolved image.
|
|
324
|
+
function drawImagePlaceholder(ck, canvas, bin, pos, size) {
|
|
325
|
+
const field = bin.track(new ck.Paint());
|
|
326
|
+
field.setColor(toColor(ck, "#e5e7eb"));
|
|
327
|
+
canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), field);
|
|
328
|
+
const icon = Math.min(size.width, size.height) * 0.38;
|
|
329
|
+
if (icon < 6)
|
|
330
|
+
return;
|
|
331
|
+
const x = pos.x + (size.width - icon) / 2;
|
|
332
|
+
const y = pos.y + (size.height - icon) / 2;
|
|
333
|
+
const gray = toColor(ck, "#9ca3af");
|
|
334
|
+
const stroke = bin.track(new ck.Paint());
|
|
335
|
+
stroke.setColor(gray);
|
|
336
|
+
stroke.setStyle(ck.PaintStyle.Stroke);
|
|
337
|
+
stroke.setStrokeWidth(Math.max(1, icon * 0.07));
|
|
338
|
+
stroke.setAntiAlias(true);
|
|
339
|
+
canvas.drawRect(ck.XYWHRect(x, y, icon, icon), stroke);
|
|
340
|
+
const fill = bin.track(new ck.Paint());
|
|
341
|
+
fill.setColor(gray);
|
|
342
|
+
fill.setAntiAlias(true);
|
|
343
|
+
// This CanvasKit build only exposes Path.MakeFromSVGString (see maskSvg), so
|
|
344
|
+
// draw the sun as an SVG circle path rather than canvas.drawCircle.
|
|
345
|
+
const sr = icon * 0.1;
|
|
346
|
+
const scx = x + icon * 0.32;
|
|
347
|
+
const scy = y + icon * 0.3;
|
|
348
|
+
const sun = `M ${scx - sr} ${scy} A ${sr} ${sr} 0 1 0 ${scx + sr} ${scy} A ${sr} ${sr} 0 1 0 ${scx - sr} ${scy} Z`;
|
|
349
|
+
canvas.drawPath(bin.track(ck.Path.MakeFromSVGString(sun)), fill);
|
|
350
|
+
const mtn = `M ${x + icon * 0.08} ${y + icon * 0.85} L ${x + icon * 0.42} ${y + icon * 0.5} L ${x + icon * 0.62} ${y + icon * 0.68} L ${x + icon * 0.8} ${y + icon * 0.45} L ${x + icon * 0.92} ${y + icon * 0.85} Z`;
|
|
351
|
+
canvas.drawPath(bin.track(ck.Path.MakeFromSVGString(mtn)), fill);
|
|
352
|
+
}
|
|
353
|
+
// The node's outline stroke, drawn along its mask (or its box when unmasked).
|
|
354
|
+
// Shared by the painted-image and placeholder paths so both get the same border.
|
|
355
|
+
function drawImageStroke(ck, canvas, bin, cmd) {
|
|
356
|
+
if (!cmd.stroke)
|
|
357
|
+
return;
|
|
358
|
+
const { pos, size } = cmd;
|
|
359
|
+
const path = maskPath(ck, bin, cmd.clip ?? { kind: "rect" }, pos.x, pos.y, size.width, size.height);
|
|
360
|
+
canvas.drawPath(path, strokePaint(ck, bin, cmd.stroke));
|
|
361
|
+
}
|
|
362
|
+
function drawImage(ck, canvas, bin, images, cmd, issues) {
|
|
363
|
+
const { pos, size } = cmd;
|
|
364
|
+
const img = images.get(cmd.src);
|
|
365
|
+
if (!img) {
|
|
366
|
+
// No image (unfilled {{token}} in a preview, or a failed load) → draw an
|
|
367
|
+
// image placeholder (gray field + photo glyph) rather than a bare rect,
|
|
368
|
+
// clipped to the node's mask so it matches the shape a real image fills
|
|
369
|
+
// (e.g. a circular avatar), then stroked like the painted path. Reported so
|
|
370
|
+
// callers that expected a real image can tell — previews that intend the
|
|
371
|
+
// placeholder simply ignore the warning.
|
|
372
|
+
issues.missingImages.push(cmd.src);
|
|
373
|
+
canvas.save();
|
|
374
|
+
if (cmd.clip)
|
|
375
|
+
canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
|
|
376
|
+
drawImagePlaceholder(ck, canvas, bin, pos, size);
|
|
377
|
+
canvas.restore();
|
|
378
|
+
drawImageStroke(ck, canvas, bin, cmd);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
canvas.save();
|
|
382
|
+
if (cmd.clip)
|
|
383
|
+
canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
|
|
384
|
+
const paint = bin.track(new ck.Paint());
|
|
385
|
+
paint.setAntiAlias(true);
|
|
386
|
+
if (cmd.fit === "tile") {
|
|
387
|
+
const shader = bin.track(img.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None));
|
|
388
|
+
paint.setShader(shader);
|
|
389
|
+
canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), paint);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const r = fitRect(img.width(), img.height(), pos.x, pos.y, size.width, size.height, cmd.fit);
|
|
393
|
+
drawImageRectHQ(ck, canvas, bin, img, ck.XYWHRect(r.sx, r.sy, r.sw, r.sh), ck.XYWHRect(r.dx, r.dy, r.dw, r.dh), paint, Math.min(r.dw / r.sw, r.dh / r.sh));
|
|
394
|
+
}
|
|
395
|
+
canvas.restore();
|
|
396
|
+
drawImageStroke(ck, canvas, bin, cmd);
|
|
397
|
+
}
|
|
398
|
+
// A barcode reads by the widths of its bars, so an edge that lands between two
|
|
399
|
+
// pixels (a grey column either side of a bar) is a misread waiting to happen.
|
|
400
|
+
// When the bitmap is drawn upright, its module size in output pixels is floored
|
|
401
|
+
// to a whole number and the code centred in its box on whole pixels. Returns the
|
|
402
|
+
// rect in device space, or null to draw as placed.
|
|
403
|
+
function snapBarcode(canvas, cmd, grid) {
|
|
404
|
+
const m = canvas.getTotalMatrix();
|
|
405
|
+
const [a, b, c, d, e, f, g, h] = m;
|
|
406
|
+
const eps = 1e-9;
|
|
407
|
+
if (Math.abs(b) > eps ||
|
|
408
|
+
Math.abs(d) > eps ||
|
|
409
|
+
Math.abs(g) > eps ||
|
|
410
|
+
Math.abs(h) > eps ||
|
|
411
|
+
a <= 0 ||
|
|
412
|
+
e <= 0)
|
|
413
|
+
return null;
|
|
414
|
+
const { pos, size, pixelWidth, pixelHeight } = cmd;
|
|
415
|
+
const dx = (a * pos.x + c) / grid;
|
|
416
|
+
const dy = (e * pos.y + f) / grid;
|
|
417
|
+
const dw = (a * size.width) / grid;
|
|
418
|
+
const dh = (e * size.height) / grid;
|
|
419
|
+
const along = (start, extent, module, n) => {
|
|
420
|
+
if (module < 1)
|
|
421
|
+
return { start, extent };
|
|
422
|
+
const snapped = Math.floor(module + 1e-6) * n;
|
|
423
|
+
return {
|
|
424
|
+
start: Math.round(start + (extent - snapped) / 2),
|
|
425
|
+
extent: snapped,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
// A 1D code is one row stretched to the bar height, so only its columns
|
|
429
|
+
// carry modules; its top and bottom just land on whole pixels. A 2D code
|
|
430
|
+
// keeps square modules, so both axes take the smaller whole size.
|
|
431
|
+
let x;
|
|
432
|
+
let y;
|
|
433
|
+
if (pixelHeight === 1) {
|
|
434
|
+
x = along(dx, dw, dw / pixelWidth, pixelWidth);
|
|
435
|
+
const top = Math.round(dy);
|
|
436
|
+
y = { start: top, extent: Math.max(1, Math.round(dy + dh) - top) };
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
const module = Math.min(dw / pixelWidth, dh / pixelHeight);
|
|
440
|
+
x = along(dx, dw, module, pixelWidth);
|
|
441
|
+
y = along(dy, dh, module, pixelHeight);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
x: x.start * grid,
|
|
445
|
+
y: y.start * grid,
|
|
446
|
+
width: x.extent * grid,
|
|
447
|
+
height: y.extent * grid,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function drawBitmap(ck, canvas, bin, cmd, frame) {
|
|
451
|
+
const { pos, size, pixels, pixelWidth, pixelHeight } = cmd;
|
|
452
|
+
if (pixelWidth <= 0 || pixelHeight <= 0)
|
|
453
|
+
return;
|
|
454
|
+
const img = ck.MakeImage({
|
|
455
|
+
width: pixelWidth,
|
|
456
|
+
height: pixelHeight,
|
|
457
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
458
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
459
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
460
|
+
}, pixels, pixelWidth * 4);
|
|
461
|
+
if (!img)
|
|
462
|
+
return;
|
|
463
|
+
canvas.save();
|
|
464
|
+
if (cmd.clip)
|
|
465
|
+
canvas.clipPath(maskPath(ck, bin, cmd.clip, pos.x, pos.y, size.width, size.height), ck.ClipOp.Intersect, true);
|
|
466
|
+
const paint = bin.track(new ck.Paint());
|
|
467
|
+
const snapped = cmd.role === "barcode" ? snapBarcode(canvas, cmd, frame.grid ?? 1) : null;
|
|
468
|
+
// Nearest-neighbor keeps QR module / pixel-art edges crisp when scaled up.
|
|
469
|
+
const src = ck.XYWHRect(0, 0, pixelWidth, pixelHeight);
|
|
470
|
+
if (snapped) {
|
|
471
|
+
// Drawn in device space, where the snapped rect was worked out.
|
|
472
|
+
const inverse = ck.Matrix.invert(canvas.getTotalMatrix());
|
|
473
|
+
if (inverse)
|
|
474
|
+
canvas.concat(inverse);
|
|
475
|
+
canvas.drawImageRectOptions(img, src, ck.XYWHRect(snapped.x, snapped.y, snapped.width, snapped.height), ck.FilterMode.Nearest, ck.MipmapMode.None, paint);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
canvas.drawImageRectOptions(img, src, ck.XYWHRect(pos.x, pos.y, size.width, size.height), ck.FilterMode.Nearest, ck.MipmapMode.None, paint);
|
|
479
|
+
}
|
|
480
|
+
img.delete();
|
|
481
|
+
canvas.restore();
|
|
482
|
+
}
|
|
483
|
+
function drawPath(ck, canvas, bin, cmd) {
|
|
484
|
+
const path = bin.track(ck.Path.MakeFromSVGString(cmd.d));
|
|
485
|
+
if (!path)
|
|
486
|
+
return;
|
|
487
|
+
if (cmd.fillRule === "evenodd")
|
|
488
|
+
path.setFillType(ck.FillType.EvenOdd);
|
|
489
|
+
canvas.save();
|
|
490
|
+
canvas.translate(cmd.pos.x, cmd.pos.y); // path coords are origin-relative
|
|
491
|
+
// A viewBox scales the authored path into the node's size box (SVG viewBox →
|
|
492
|
+
// viewport). Fills then span the viewBox, so they map onto the full box.
|
|
493
|
+
const vb = cmd.viewBox;
|
|
494
|
+
let boxW = cmd.size.width;
|
|
495
|
+
let boxH = cmd.size.height;
|
|
496
|
+
if (vb && vb.width > 0 && vb.height > 0) {
|
|
497
|
+
canvas.scale(cmd.size.width / vb.width, cmd.size.height / vb.height);
|
|
498
|
+
if (vb.x || vb.y)
|
|
499
|
+
canvas.translate(-(vb.x ?? 0), -(vb.y ?? 0));
|
|
500
|
+
boxW = vb.width;
|
|
501
|
+
boxH = vb.height;
|
|
502
|
+
}
|
|
503
|
+
for (const fill of cmd.fills ?? []) {
|
|
504
|
+
const paint = bin.track(new ck.Paint());
|
|
505
|
+
paint.setAntiAlias(true);
|
|
506
|
+
if (fill.kind === "solid")
|
|
507
|
+
paint.setColor(toColor(ck, fill.color));
|
|
508
|
+
else
|
|
509
|
+
paint.setShader(shaderFor(ck, bin, fill, 0, 0, boxW, boxH));
|
|
510
|
+
canvas.drawPath(path, paint);
|
|
511
|
+
}
|
|
512
|
+
if (cmd.stroke)
|
|
513
|
+
canvas.drawPath(path, strokePaint(ck, bin, cmd.stroke));
|
|
514
|
+
canvas.restore();
|
|
515
|
+
}
|
|
516
|
+
function reportAdjustUnsupported(issues, cmd, component) {
|
|
517
|
+
const layer = cmd.id ?? (cmd.op === "drawImage" ? cmd.src : undefined);
|
|
518
|
+
issues.adjustUnsupported.set(`${component}\u0000${layer ?? ""}`, {
|
|
519
|
+
component,
|
|
520
|
+
layer,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function drawShape(ck, canvas, provider, images, bin, cmd, issues, frame) {
|
|
524
|
+
if (cmd.op === "drawRect") {
|
|
525
|
+
const { x, y } = cmd.pos;
|
|
526
|
+
const { width: w, height: h } = cmd.size;
|
|
527
|
+
const rect = ck.XYWHRect(x, y, w, h);
|
|
528
|
+
const cr = cmd.cornerRadius;
|
|
529
|
+
// Corner smoothing (superellipse) applies to a uniform radius; otherwise
|
|
530
|
+
// fall back to a plain (possibly per-corner) rounded rect.
|
|
531
|
+
const smoothing = cmd.cornerSmoothing ?? 0;
|
|
532
|
+
const smoothR = smoothing > 0 && typeof cr === "number" && cr > 0 ? cr : null;
|
|
533
|
+
const rr = smoothR === null &&
|
|
534
|
+
cr !== undefined &&
|
|
535
|
+
(typeof cr === "number" ? cr > 0 : cr.some((r) => r > 0))
|
|
536
|
+
? rrectFor(ck, rect, cr)
|
|
537
|
+
: null;
|
|
538
|
+
const fillPath = smoothR !== null
|
|
539
|
+
? bin.track(ck.Path.MakeFromSVGString(squircleSvg(x, y, w, h, smoothR, smoothing)))
|
|
540
|
+
: null;
|
|
541
|
+
for (const fill of cmd.fills ?? []) {
|
|
542
|
+
const paint = bin.track(new ck.Paint());
|
|
543
|
+
paint.setAntiAlias(true);
|
|
544
|
+
if (fill.kind === "solid")
|
|
545
|
+
paint.setColor(toColor(ck, fill.color));
|
|
546
|
+
else
|
|
547
|
+
paint.setShader(shaderFor(ck, bin, fill, x, y, w, h));
|
|
548
|
+
if (fillPath)
|
|
549
|
+
canvas.drawPath(fillPath, paint);
|
|
550
|
+
else if (rr)
|
|
551
|
+
canvas.drawRRect(rr, paint);
|
|
552
|
+
else
|
|
553
|
+
canvas.drawRect(rect, paint);
|
|
554
|
+
}
|
|
555
|
+
if (cmd.stroke) {
|
|
556
|
+
const sp = strokePaint(ck, bin, cmd.stroke);
|
|
557
|
+
// Offset the stroked rect for inside/outside alignment (center = 0).
|
|
558
|
+
const inset = strokeInset(cmd.stroke);
|
|
559
|
+
if (smoothR !== null) {
|
|
560
|
+
const path = bin.track(ck.Path.MakeFromSVGString(squircleSvg(x + inset, y + inset, w - 2 * inset, h - 2 * inset, Math.max(0, smoothR - inset), smoothing)));
|
|
561
|
+
canvas.drawPath(path, sp);
|
|
562
|
+
}
|
|
563
|
+
else if (inset === 0) {
|
|
564
|
+
if (rr)
|
|
565
|
+
canvas.drawRRect(rr, sp);
|
|
566
|
+
else
|
|
567
|
+
canvas.drawRect(rect, sp);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
const srect = ck.XYWHRect(x + inset, y + inset, w - 2 * inset, h - 2 * inset);
|
|
571
|
+
if (rr)
|
|
572
|
+
canvas.drawRRect(rrectFor(ck, srect, insetCorner(cr, inset)), sp);
|
|
573
|
+
else
|
|
574
|
+
canvas.drawRect(srect, sp);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else if (cmd.op === "drawText") {
|
|
579
|
+
drawText(ck, canvas, provider, bin, cmd);
|
|
580
|
+
}
|
|
581
|
+
else if (cmd.op === "drawImage") {
|
|
582
|
+
drawImage(ck, canvas, bin, images, cmd, issues);
|
|
583
|
+
}
|
|
584
|
+
else if (cmd.op === "drawBitmap") {
|
|
585
|
+
drawBitmap(ck, canvas, bin, cmd, frame);
|
|
586
|
+
}
|
|
587
|
+
else if (cmd.op === "drawPath") {
|
|
588
|
+
drawPath(ck, canvas, bin, cmd);
|
|
589
|
+
}
|
|
590
|
+
else if (cmd.op === "drawQr") {
|
|
591
|
+
const { pos, size, modules, margin = 0, foreground, background } = cmd;
|
|
592
|
+
const m = (Math.min(size.width, size.height) - margin * 2) / modules.length;
|
|
593
|
+
const bg = bin.track(new ck.Paint());
|
|
594
|
+
bg.setColor(toColor(ck, background ?? "#ffffff"));
|
|
595
|
+
canvas.drawRect(ck.XYWHRect(pos.x, pos.y, size.width, size.height), bg);
|
|
596
|
+
const fg = bin.track(new ck.Paint());
|
|
597
|
+
fg.setColor(toColor(ck, foreground));
|
|
598
|
+
for (let y = 0; y < modules.length; y++)
|
|
599
|
+
for (let x = 0; x < modules.length; x++)
|
|
600
|
+
if (modules[y][x])
|
|
601
|
+
canvas.drawRect(ck.XYWHRect(pos.x + margin + x * m, pos.y + margin + y * m, m, m), fg);
|
|
602
|
+
}
|
|
603
|
+
else if (cmd.op === "drawGroup") {
|
|
604
|
+
for (const child of cmd.children)
|
|
605
|
+
paintDrawable(ck, canvas, provider, images, bin, child, issues, frame);
|
|
606
|
+
}
|
|
607
|
+
else if (cmd.op === "drawMasked") {
|
|
608
|
+
drawMasked(ck, canvas, provider, images, bin, cmd, issues, frame);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
issues.unhandled.push(cmd.op);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// The general mask: draw children to an offscreen content layer, then composite
|
|
615
|
+
// the mask's coverage onto it. DstIn keeps content where the mask is opaque
|
|
616
|
+
// (DstOut where it's transparent, for invert); a luminance channel first maps
|
|
617
|
+
// the mask's brightness to alpha via a color matrix.
|
|
618
|
+
function drawMasked(ck, canvas, provider, images, bin, cmd, issues, frame) {
|
|
619
|
+
canvas.saveLayer();
|
|
620
|
+
for (const child of cmd.children)
|
|
621
|
+
paintDrawable(ck, canvas, provider, images, bin, child, issues, frame);
|
|
622
|
+
const maskPaint = bin.track(new ck.Paint());
|
|
623
|
+
maskPaint.setBlendMode(cmd.invert ? ck.BlendMode.DstOut : ck.BlendMode.DstIn);
|
|
624
|
+
if (cmd.channel === "luminance")
|
|
625
|
+
maskPaint.setColorFilter(bin.track(ck.ColorFilter.MakeMatrix([
|
|
626
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2126, 0.7152, 0.0722,
|
|
627
|
+
0, 0,
|
|
628
|
+
])));
|
|
629
|
+
canvas.saveLayer(maskPaint);
|
|
630
|
+
paintDrawable(ck, canvas, provider, images, bin, cmd.mask, issues, frame);
|
|
631
|
+
canvas.restore();
|
|
632
|
+
canvas.restore();
|
|
633
|
+
}
|
|
634
|
+
// The color-matrix component of an `adjust` as a Skia ColorFilter — the cheap
|
|
635
|
+
// per-pixel path, folded into the layer paint. The nonlinear `lut` and spatial
|
|
636
|
+
// `sharpen` components can't be a color filter, so they take the offscreen SkSL
|
|
637
|
+
// path (see paintAdjustedOffscreen); here we handle only the matrix.
|
|
638
|
+
function adjustColorFilter(ck, bin, cmd) {
|
|
639
|
+
const m = cmd.adjust?.colorMatrix;
|
|
640
|
+
if (!m)
|
|
641
|
+
return null;
|
|
642
|
+
return shaderSideMatrix(cmd.adjust)
|
|
643
|
+
? null
|
|
644
|
+
: bin.track(ck.ColorFilter.MakeMatrix(m));
|
|
645
|
+
}
|
|
646
|
+
// Whether a 4×5 matrix touches only RGB: alpha is neither an input to the color
|
|
647
|
+
// rows (column 3) nor transformed itself (row 3 is identity). The shader carries
|
|
648
|
+
// alpha alongside unpremultiplied RGB, so it stands in for the color filter only
|
|
649
|
+
// for these. Every matrix ./adjust builds qualifies.
|
|
650
|
+
function matrixIsRgbOnly(m) {
|
|
651
|
+
if (m.length !== 20)
|
|
652
|
+
return false;
|
|
653
|
+
const zero = (v) => Math.abs(v) < 1e-6;
|
|
654
|
+
return (zero(m[3]) &&
|
|
655
|
+
zero(m[8]) &&
|
|
656
|
+
zero(m[13]) &&
|
|
657
|
+
zero(m[15]) &&
|
|
658
|
+
zero(m[16]) &&
|
|
659
|
+
zero(m[17]) &&
|
|
660
|
+
Math.abs(m[18] - 1) < 1e-6 &&
|
|
661
|
+
zero(m[19]));
|
|
662
|
+
}
|
|
663
|
+
// "preserve-hue" needs the matrix's unclamped output, which a Skia color filter
|
|
664
|
+
// never exposes — so the matrix moves onto the shader pass with it. A matrix with
|
|
665
|
+
// alpha terms stays on the color filter and the mode reports unsupported.
|
|
666
|
+
function shaderSideMatrix(a) {
|
|
667
|
+
return (!!a &&
|
|
668
|
+
a.gamut === "preserve-hue" &&
|
|
669
|
+
!!a.colorMatrix &&
|
|
670
|
+
matrixIsRgbOnly(a.colorMatrix));
|
|
671
|
+
}
|
|
672
|
+
// True when an adjust carries components the layer-paint color filter can't do —
|
|
673
|
+
// the per-channel LUT (nonlinear), sharpen (spatial), or a matrix that has to be
|
|
674
|
+
// gamut-mapped per pixel. These take the offscreen shader path; a matrix that does
|
|
675
|
+
// not need mapping rides along inside that offscreen render as a color filter.
|
|
676
|
+
function needsShaderAdjust(cmd) {
|
|
677
|
+
const a = cmd.adjust;
|
|
678
|
+
if (!a)
|
|
679
|
+
return false;
|
|
680
|
+
if (a.lut || a.lut3d)
|
|
681
|
+
return true;
|
|
682
|
+
if (typeof a.sharpen === "number" && a.sharpen > 0)
|
|
683
|
+
return true;
|
|
684
|
+
return shaderSideMatrix(a);
|
|
685
|
+
}
|
|
686
|
+
// A 256×1 lookup image encoding a per-channel LUT (x = input 0..255, texel =
|
|
687
|
+
// output). Sampled Nearest/Clamp by the adjust shader.
|
|
688
|
+
function lutKey(lut) {
|
|
689
|
+
if (!lut)
|
|
690
|
+
throw new Error("lutKey: no lut");
|
|
691
|
+
// The bytes are the value of a curve; callers often construct separate typed
|
|
692
|
+
// arrays for the same gamma, so object identity would miss the useful cache.
|
|
693
|
+
return `${lut.r.join(",")}|${lut.g.join(",")}|${lut.b.join(",")}`;
|
|
694
|
+
}
|
|
695
|
+
function lutImage(ck, bin, lut) {
|
|
696
|
+
if (!lut)
|
|
697
|
+
throw new Error("lutImage: no lut");
|
|
698
|
+
const key = lutKey(lut);
|
|
699
|
+
const cached = bin.lutImages.get(key);
|
|
700
|
+
if (cached)
|
|
701
|
+
return cached;
|
|
702
|
+
const px = new Uint8Array(256 * 4);
|
|
703
|
+
for (let i = 0; i < 256; i++) {
|
|
704
|
+
px[i * 4] = lut.r[i];
|
|
705
|
+
px[i * 4 + 1] = lut.g[i];
|
|
706
|
+
px[i * 4 + 2] = lut.b[i];
|
|
707
|
+
px[i * 4 + 3] = 255;
|
|
708
|
+
}
|
|
709
|
+
const image = bin.track(ck.MakeImage({
|
|
710
|
+
width: 256,
|
|
711
|
+
height: 1,
|
|
712
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
713
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
714
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
715
|
+
}, px, 256 * 4));
|
|
716
|
+
bin.lutImages.set(key, image);
|
|
717
|
+
return image;
|
|
718
|
+
}
|
|
719
|
+
function lut3dKey(lut) {
|
|
720
|
+
if (!lut)
|
|
721
|
+
throw new Error("lut3dKey: no LUT");
|
|
722
|
+
return `${lut.size}:${lut.data.join(",")}`;
|
|
723
|
+
}
|
|
724
|
+
// A type guard, not a bare boolean, so `lut3dImage` can read `lut.size`/`lut.data`
|
|
725
|
+
// without re-checking after the throw.
|
|
726
|
+
function validLut3d(lut) {
|
|
727
|
+
return (!!lut &&
|
|
728
|
+
Number.isInteger(lut.size) &&
|
|
729
|
+
lut.size >= 2 &&
|
|
730
|
+
lut.data.length === lut.size ** 3 * 3);
|
|
731
|
+
}
|
|
732
|
+
// A 3D cube packed into a 2D atlas. Columns are red within blue slices and rows
|
|
733
|
+
// are green: atlas(x = b * size + r, y = g) = cube(r, g, b).
|
|
734
|
+
function lut3dImage(ck, bin, lut) {
|
|
735
|
+
if (!validLut3d(lut))
|
|
736
|
+
throw new Error("lut3dImage: invalid LUT");
|
|
737
|
+
const key = lut3dKey(lut);
|
|
738
|
+
const cached = bin.lut3dImages.get(key);
|
|
739
|
+
if (cached)
|
|
740
|
+
return cached;
|
|
741
|
+
const width = lut.size * lut.size;
|
|
742
|
+
const px = new Uint8Array(width * lut.size * 4);
|
|
743
|
+
for (let b = 0; b < lut.size; b++) {
|
|
744
|
+
for (let g = 0; g < lut.size; g++) {
|
|
745
|
+
for (let r = 0; r < lut.size; r++) {
|
|
746
|
+
const source = ((b * lut.size + g) * lut.size + r) * 3;
|
|
747
|
+
const target = (g * width + b * lut.size + r) * 4;
|
|
748
|
+
px[target] = lut.data[source];
|
|
749
|
+
px[target + 1] = lut.data[source + 1];
|
|
750
|
+
px[target + 2] = lut.data[source + 2];
|
|
751
|
+
px[target + 3] = 255;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const image = bin.track(ck.MakeImage({
|
|
756
|
+
width,
|
|
757
|
+
height: lut.size,
|
|
758
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
759
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
760
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
761
|
+
}, px, width * 4));
|
|
762
|
+
bin.lut3dImages.set(key, image);
|
|
763
|
+
return image;
|
|
764
|
+
}
|
|
765
|
+
// SkSL for the adjust post-pass, generated per feature combination. The source
|
|
766
|
+
// layer arrives as `src`; 1D curves and 3D cubes are child shaders when present.
|
|
767
|
+
// Colors are unpremultiplied before the matrix / LUT / cube / unsharp and
|
|
768
|
+
// re-premultiplied on the way out. Sharpen is a 3×3 unsharp mask
|
|
769
|
+
// (box-blur radius 1), matching the straight-alpha unsharp used elsewhere.
|
|
770
|
+
//
|
|
771
|
+
// Per-pixel order is matrix → gamut → 1D curve → 3D cube → sharpen. The gamut step
|
|
772
|
+
// sits after the matrix: it can leave [0,1], while both LUTs need in-range input.
|
|
773
|
+
function adjustShaderSksl(hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen) {
|
|
774
|
+
// Rows of the 4×5 color matrix as float4(r, g, b, bias). Declared first, so the
|
|
775
|
+
// flat uniform array the caller builds starts with them.
|
|
776
|
+
const matrixFn = hasMatrix
|
|
777
|
+
? `uniform float4 cmR; uniform float4 cmG; uniform float4 cmB;
|
|
778
|
+
half3 mtx(half3 c){ return half3(
|
|
779
|
+
dot(c, half3(cmR.rgb)) + half(cmR.w),
|
|
780
|
+
dot(c, half3(cmG.rgb)) + half(cmG.w),
|
|
781
|
+
dot(c, half3(cmB.rgb)) + half(cmB.w)); }`
|
|
782
|
+
: `half3 mtx(half3 c){ return c; }`;
|
|
783
|
+
// Scale chroma toward the color's own luma until every channel fits [0,1] — the
|
|
784
|
+
// largest t in [0,1] with l + t·(c−l) in range, applied to all three channels.
|
|
785
|
+
// Hue direction and luma survive; saturation absorbs the difference. The
|
|
786
|
+
// divisions stay under 1 (a channel above 1 has v−l > 1−l), so half precision
|
|
787
|
+
// holds.
|
|
788
|
+
const gamutFn = preserveHue
|
|
789
|
+
? `half fitScale(half v, half l){
|
|
790
|
+
half d = v - l;
|
|
791
|
+
if (v > 1.0 && d > 0.0) return (1.0 - l) / d;
|
|
792
|
+
if (v < 0.0 && d < 0.0) return -l / d;
|
|
793
|
+
return 1.0; }
|
|
794
|
+
half3 gamut(half3 c){
|
|
795
|
+
half l = clamp(dot(c, half3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
|
796
|
+
half t = min(min(fitScale(c.r, l), fitScale(c.g, l)), fitScale(c.b, l));
|
|
797
|
+
return clamp(l + (c - l) * t, 0.0, 1.0); }`
|
|
798
|
+
: `half3 gamut(half3 c){ return clamp(c, 0.0, 1.0); }`;
|
|
799
|
+
const lutFn = hasLut
|
|
800
|
+
? `uniform shader lut;
|
|
801
|
+
half3 curve(half3 c){ return half3(
|
|
802
|
+
lut.eval(float2(c.r*255.0+0.5, 0.5)).r,
|
|
803
|
+
lut.eval(float2(c.g*255.0+0.5, 0.5)).r,
|
|
804
|
+
lut.eval(float2(c.b*255.0+0.5, 0.5)).r); }`
|
|
805
|
+
: `half3 curve(half3 c){ return c; }`;
|
|
806
|
+
const lut3dFn = hasLut3d
|
|
807
|
+
? `uniform float cubeSize;
|
|
808
|
+
uniform shader cube;
|
|
809
|
+
half3 cubeAt(float r, float g, float b){
|
|
810
|
+
return cube.eval(float2(b*cubeSize+r+0.5, g+0.5)).rgb; }
|
|
811
|
+
half3 cubeCurve(half3 c){
|
|
812
|
+
float top = cubeSize - 1.0;
|
|
813
|
+
float rf = clamp(float(c.r), 0.0, 1.0) * top;
|
|
814
|
+
float gf = clamp(float(c.g), 0.0, 1.0) * top;
|
|
815
|
+
float bf = clamp(float(c.b), 0.0, 1.0) * top;
|
|
816
|
+
float r0 = floor(rf); float g0 = floor(gf); float b0 = floor(bf);
|
|
817
|
+
float r1 = min(r0 + 1.0, top); float g1 = min(g0 + 1.0, top); float b1 = min(b0 + 1.0, top);
|
|
818
|
+
half fr = half(rf-r0); half fg = half(gf-g0); half fb = half(bf-b0);
|
|
819
|
+
half3 c00 = mix(cubeAt(r0,g0,b0), cubeAt(r1,g0,b0), fr);
|
|
820
|
+
half3 c10 = mix(cubeAt(r0,g1,b0), cubeAt(r1,g1,b0), fr);
|
|
821
|
+
half3 c01 = mix(cubeAt(r0,g0,b1), cubeAt(r1,g0,b1), fr);
|
|
822
|
+
half3 c11 = mix(cubeAt(r0,g1,b1), cubeAt(r1,g1,b1), fr);
|
|
823
|
+
return mix(mix(c00,c10,fg), mix(c01,c11,fg), fb); }`
|
|
824
|
+
: `half3 cubeCurve(half3 c){ return c; }`;
|
|
825
|
+
const sharpenUniform = hasSharpen ? "uniform float amount;" : "";
|
|
826
|
+
// samp() returns the corrected, unpremultiplied colour at a pixel.
|
|
827
|
+
const common = `uniform shader src;
|
|
828
|
+
${matrixFn}
|
|
829
|
+
${gamutFn}
|
|
830
|
+
${lutFn}
|
|
831
|
+
${lut3dFn}
|
|
832
|
+
${sharpenUniform}
|
|
833
|
+
half3 samp(float2 p){ half4 s = src.eval(p); half3 c = s.a > 0.0 ? s.rgb/s.a : s.rgb; return cubeCurve(curve(gamut(mtx(c)))); }`;
|
|
834
|
+
const body = hasSharpen
|
|
835
|
+
? `half4 s0 = src.eval(xy);
|
|
836
|
+
half3 c = samp(xy);
|
|
837
|
+
half3 sum = samp(xy+float2(-1,-1))+samp(xy+float2(0,-1))+samp(xy+float2(1,-1))
|
|
838
|
+
+ samp(xy+float2(-1, 0))+c +samp(xy+float2(1, 0))
|
|
839
|
+
+ samp(xy+float2(-1, 1))+samp(xy+float2(0, 1))+samp(xy+float2(1, 1));
|
|
840
|
+
half3 outc = clamp(c + amount*(c - sum/9.0), 0.0, 1.0);
|
|
841
|
+
return half4(outc*s0.a, s0.a);`
|
|
842
|
+
: `half4 s0 = src.eval(xy);
|
|
843
|
+
half3 outc = clamp(samp(xy), 0.0, 1.0);
|
|
844
|
+
return half4(outc*s0.a, s0.a);`;
|
|
845
|
+
return `${common}
|
|
846
|
+
half4 main(float2 xy){ ${body} }`;
|
|
847
|
+
}
|
|
848
|
+
// RuntimeEffect is compiled per (ck, variant) and reused — Make() parses SkSL, so
|
|
849
|
+
// caching keeps the hot path free of recompiles. Keyed by ck so distinct CanvasKit
|
|
850
|
+
// instances (e.g. across tests) never share an effect.
|
|
851
|
+
const effectCache = new WeakMap();
|
|
852
|
+
function adjustEffect(ck, hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen) {
|
|
853
|
+
let byVariant = effectCache.get(ck);
|
|
854
|
+
if (!byVariant) {
|
|
855
|
+
byVariant = new Map();
|
|
856
|
+
effectCache.set(ck, byVariant);
|
|
857
|
+
}
|
|
858
|
+
const bit = (b) => (b ? 1 : 0);
|
|
859
|
+
const key = `${bit(hasMatrix)}${bit(preserveHue)}${bit(hasLut)}${bit(hasLut3d)}${bit(hasSharpen)}`;
|
|
860
|
+
let eff = byVariant.get(key);
|
|
861
|
+
if (eff === undefined) {
|
|
862
|
+
eff =
|
|
863
|
+
ck.RuntimeEffect.Make(adjustShaderSksl(hasMatrix, preserveHue, hasLut, hasLut3d, hasSharpen)) ?? null;
|
|
864
|
+
byVariant.set(key, eff);
|
|
865
|
+
}
|
|
866
|
+
return eff;
|
|
867
|
+
}
|
|
868
|
+
// Apply an adjust's LUTs/sharpen via an offscreen SkSL pass: render the drawable
|
|
869
|
+
// (with only its color matrix) to a frame-sized offscreen surface, then draw it
|
|
870
|
+
// back through the adjust shader. The color matrix rides the inner render, so
|
|
871
|
+
// ordering is matrix → curve → cube → sharpen — color first, spatial last. The
|
|
872
|
+
// offscreen inherits the main canvas's full CTM and is composited back in
|
|
873
|
+
// device coordinates, so an adjusted descendant follows every parent transform
|
|
874
|
+
// exactly once. Falls back to a matrix-only render (+ an adjust_unsupported warning)
|
|
875
|
+
// if the surface or effect can't be created.
|
|
876
|
+
function paintAdjustedOffscreen(ck, canvas, provider, images, bin, cmd, issues, frame) {
|
|
877
|
+
const adjust = cmd.adjust;
|
|
878
|
+
const hasLut = !!adjust.lut;
|
|
879
|
+
const hasLut3d = !!adjust.lut3d;
|
|
880
|
+
const amount = adjust.sharpen ?? 0;
|
|
881
|
+
const hasSharpen = amount > 0;
|
|
882
|
+
// "preserve-hue" pulls the matrix into this pass; otherwise it rides the inner
|
|
883
|
+
// render as a color filter, as before.
|
|
884
|
+
const matrixInShader = shaderSideMatrix(adjust);
|
|
885
|
+
if (adjust.gamut === "preserve-hue" && !matrixInShader) {
|
|
886
|
+
reportAdjustUnsupported(issues, cmd, "gamut");
|
|
887
|
+
}
|
|
888
|
+
// The inner drawable: same node, but only the (cheap) color matrix survives —
|
|
889
|
+
// lut/sharpen are this pass's job, so dropping them avoids re-entering here.
|
|
890
|
+
const inner = {
|
|
891
|
+
...cmd,
|
|
892
|
+
adjust: adjust.colorMatrix && !matrixInShader
|
|
893
|
+
? { colorMatrix: adjust.colorMatrix }
|
|
894
|
+
: undefined,
|
|
895
|
+
};
|
|
896
|
+
if (hasLut3d && !validLut3d(adjust.lut3d)) {
|
|
897
|
+
reportAdjustUnsupported(issues, cmd, "lut3d");
|
|
898
|
+
const fallback = matrixInShader
|
|
899
|
+
? { ...cmd, adjust: { colorMatrix: adjust.colorMatrix } }
|
|
900
|
+
: inner;
|
|
901
|
+
paintDrawable(ck, canvas, provider, images, bin, fallback, issues, frame);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// The offscreen matches the SURFACE, not the design box: at a 2× export the
|
|
905
|
+
// layer must be rendered at 2× too, or it would be blitted back upscaled from
|
|
906
|
+
// half-resolution pixels. The inner render draws in design units under the same
|
|
907
|
+
// scaled matrix the main canvas carries.
|
|
908
|
+
const device = exportPixelSize(frame, frame.scale);
|
|
909
|
+
const info = {
|
|
910
|
+
width: device.width,
|
|
911
|
+
height: device.height,
|
|
912
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
913
|
+
alphaType: ck.AlphaType.Premul,
|
|
914
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
915
|
+
};
|
|
916
|
+
const surface = canvas.makeSurface(info);
|
|
917
|
+
const effect = surface
|
|
918
|
+
? adjustEffect(ck, matrixInShader, matrixInShader, hasLut, hasLut3d, hasSharpen)
|
|
919
|
+
: null;
|
|
920
|
+
if (!surface || !effect) {
|
|
921
|
+
// Couldn't build the offscreen path — render matrix-only and flag what was
|
|
922
|
+
// dropped rather than pretending it applied. The matrix goes back on the
|
|
923
|
+
// color filter, so a "preserve-hue" layer still paints, clipped.
|
|
924
|
+
if (hasLut)
|
|
925
|
+
reportAdjustUnsupported(issues, cmd, "lut");
|
|
926
|
+
if (hasLut3d)
|
|
927
|
+
reportAdjustUnsupported(issues, cmd, "lut3d");
|
|
928
|
+
if (hasSharpen)
|
|
929
|
+
reportAdjustUnsupported(issues, cmd, "sharpen");
|
|
930
|
+
if (matrixInShader)
|
|
931
|
+
reportAdjustUnsupported(issues, cmd, "gamut");
|
|
932
|
+
surface?.delete();
|
|
933
|
+
const fallback = matrixInShader
|
|
934
|
+
? { ...cmd, adjust: { colorMatrix: adjust.colorMatrix } }
|
|
935
|
+
: inner;
|
|
936
|
+
paintDrawable(ck, canvas, provider, images, bin, fallback, issues, frame);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const off = surface.getCanvas();
|
|
940
|
+
off.clear(ck.TRANSPARENT);
|
|
941
|
+
// The main canvas may already carry an export scale and arbitrary ancestor
|
|
942
|
+
// transforms. Render the inner layer through that exact local→device matrix so
|
|
943
|
+
// the snapshot occupies the same texels it would on the main surface.
|
|
944
|
+
const matrix = canvas.getTotalMatrix();
|
|
945
|
+
off.concat(matrix);
|
|
946
|
+
paintDrawable(ck, off, provider, images, bin, inner, issues, frame);
|
|
947
|
+
// The snapshot is copy-on-write off this surface, and the shader samples it when
|
|
948
|
+
// the MAIN surface flushes (later, on the GPU path) — so keep the offscreen alive
|
|
949
|
+
// until bin.free() runs (after that flush), disposing (not delete()ing, which
|
|
950
|
+
// would leak the pixel buffer) it then.
|
|
951
|
+
const img = bin.track(surface.makeImageSnapshot());
|
|
952
|
+
bin.track({ delete: () => surface.dispose() });
|
|
953
|
+
const srcSh = bin.track(img.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None));
|
|
954
|
+
const children = [srcSh];
|
|
955
|
+
if (hasLut) {
|
|
956
|
+
const lutImg = lutImage(ck, bin, adjust.lut);
|
|
957
|
+
children.push(bin.track(lutImg.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None)));
|
|
958
|
+
}
|
|
959
|
+
if (hasLut3d) {
|
|
960
|
+
const cubeImg = lut3dImage(ck, bin, adjust.lut3d);
|
|
961
|
+
children.push(bin.track(cubeImg.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None)));
|
|
962
|
+
}
|
|
963
|
+
// Uniforms go in declaration order (see adjustShaderSksl): the three matrix rows
|
|
964
|
+
// as float4(r, g, b, bias), then cube size, then the sharpen amount.
|
|
965
|
+
const uniforms = [];
|
|
966
|
+
if (matrixInShader) {
|
|
967
|
+
const m = adjust.colorMatrix;
|
|
968
|
+
uniforms.push(m[0], m[1], m[2], m[4]);
|
|
969
|
+
uniforms.push(m[5], m[6], m[7], m[9]);
|
|
970
|
+
uniforms.push(m[10], m[11], m[12], m[14]);
|
|
971
|
+
}
|
|
972
|
+
if (hasLut3d)
|
|
973
|
+
uniforms.push(adjust.lut3d?.size ?? 0);
|
|
974
|
+
if (hasSharpen)
|
|
975
|
+
uniforms.push(amount);
|
|
976
|
+
const shader = bin.track(effect.makeShaderWithChildren(uniforms, children));
|
|
977
|
+
const paint = bin.track(new ck.Paint());
|
|
978
|
+
paint.setShader(shader);
|
|
979
|
+
// Blit in DEVICE pixels — undo the main canvas's complete CTM, not merely the
|
|
980
|
+
// export scale. The snapshot already includes parent rotations/transforms; a
|
|
981
|
+
// second application here would move it. The main clip remains in device space,
|
|
982
|
+
// so clipping semantics are unchanged.
|
|
983
|
+
// Transparent offscreen pixels stay transparent, so a full-frame draw only
|
|
984
|
+
// lays down the drawable.
|
|
985
|
+
const inverse = ck.Matrix.invert(matrix);
|
|
986
|
+
if (!inverse)
|
|
987
|
+
throw new Error("adjust: non-invertible canvas transform");
|
|
988
|
+
canvas.save();
|
|
989
|
+
canvas.concat(inverse);
|
|
990
|
+
canvas.drawRect(ck.XYWHRect(0, 0, device.width, device.height), paint);
|
|
991
|
+
canvas.restore();
|
|
992
|
+
}
|
|
993
|
+
// Collapse the supersampled render into the output surface, so each output pixel
|
|
994
|
+
// carries the coverage of the supersample² samples under it — an edge that a
|
|
995
|
+
// same-size render can only approximate (two abutting shapes each blending their
|
|
996
|
+
// own coverage against the background, never summing back to the solid they
|
|
997
|
+
// tile) comes out with the coverage it actually had.
|
|
998
|
+
//
|
|
999
|
+
// Done as a chain of exact 2:1 halvings, NOT one resample down to the output
|
|
1000
|
+
// size, because only at 2:1 does every sample reach the average. A bilinear tap
|
|
1001
|
+
// halving an axis lands exactly on the boundary between two source pixels, so a
|
|
1002
|
+
// step is precisely a 2×2 box average; a cubic filter run straight from 4× to 1×
|
|
1003
|
+
// keeps its narrow support and reads a fraction of the samples instead —
|
|
1004
|
+
// measurably no better than 2× — and mipmapped trilinear (drawImageRectHQ's
|
|
1005
|
+
// choice below 0.5) samples between pyramid levels rather than the render.
|
|
1006
|
+
//
|
|
1007
|
+
// Every level is kept alive in `bin` until the OUTPUT surface flushes: on the GPU
|
|
1008
|
+
// path the draws only sample them then. Surfaces are disposed rather than
|
|
1009
|
+
// delete()d, which would strand their pixel buffers.
|
|
1010
|
+
function reduceSupersampled(ck, out, src, bin, design, exportScale, supersample) {
|
|
1011
|
+
const rect = (s) => ck.XYWHRect(0, 0, s.width, s.height);
|
|
1012
|
+
const retire = (s) => bin.track({ delete: () => s.dispose() });
|
|
1013
|
+
let level = src;
|
|
1014
|
+
let levelSize = exportPixelSize(design, exportScale * supersample);
|
|
1015
|
+
retire(src);
|
|
1016
|
+
for (let factor = supersample; factor > 1; factor /= 2) {
|
|
1017
|
+
const target = exportPixelSize(design, (exportScale * factor) / 2);
|
|
1018
|
+
const last = factor === 2;
|
|
1019
|
+
const dst = last
|
|
1020
|
+
? out
|
|
1021
|
+
: level.getCanvas().makeSurface({
|
|
1022
|
+
width: target.width,
|
|
1023
|
+
height: target.height,
|
|
1024
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
1025
|
+
alphaType: ck.AlphaType.Premul,
|
|
1026
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
1027
|
+
});
|
|
1028
|
+
const img = bin.track(level.makeImageSnapshot());
|
|
1029
|
+
const paint = bin.track(new ck.Paint());
|
|
1030
|
+
if (!dst) {
|
|
1031
|
+
// An intermediate the backend wouldn't give: finish what is left in one
|
|
1032
|
+
// cubic step rather than leaving the output blank. Softer than the halving
|
|
1033
|
+
// chain, still sharper than no supersampling.
|
|
1034
|
+
const canvas = out.getCanvas();
|
|
1035
|
+
canvas.clear(ck.TRANSPARENT);
|
|
1036
|
+
const final = exportPixelSize(design, exportScale);
|
|
1037
|
+
canvas.drawImageRectCubic(img, rect(levelSize), rect(final), MITCHELL, MITCHELL, paint);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const canvas = dst.getCanvas();
|
|
1041
|
+
canvas.clear(ck.TRANSPARENT);
|
|
1042
|
+
canvas.drawImageRectOptions(img, rect(levelSize), rect(target), ck.FilterMode.Linear, ck.MipmapMode.None, paint);
|
|
1043
|
+
if (!last)
|
|
1044
|
+
retire(dst);
|
|
1045
|
+
level = dst;
|
|
1046
|
+
levelSize = target;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// SkSL for the whole-frame finishing pass. Order mirrors the classic output
|
|
1050
|
+
// pipeline: dither first (break banding), then black-extract, then white-clamp.
|
|
1051
|
+
// Colors are unpremultiplied for the threshold tests and re-premultiplied out.
|
|
1052
|
+
// Disabled ops are signalled by a sentinel threshold of -1 (dither 0).
|
|
1053
|
+
const FINISH_SKSL = `uniform shader src;
|
|
1054
|
+
uniform float whiteT;
|
|
1055
|
+
uniform float blackT;
|
|
1056
|
+
uniform float dither;
|
|
1057
|
+
uniform float ditherSeed;
|
|
1058
|
+
uniform float monochromeDither;
|
|
1059
|
+
half hash(float2 p){
|
|
1060
|
+
p += float2(ditherSeed * 0.1031, ditherSeed * 0.11369);
|
|
1061
|
+
return half(fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453) * 2.0 - 1.0);
|
|
1062
|
+
}
|
|
1063
|
+
half3 hash3(float2 p){
|
|
1064
|
+
return half3(hash(p + float2(19.19, 7.13)),
|
|
1065
|
+
hash(p + float2(43.31, 31.71)),
|
|
1066
|
+
hash(p + float2(67.67, 59.59)));
|
|
1067
|
+
}
|
|
1068
|
+
half4 main(float2 xy){
|
|
1069
|
+
half4 s = src.eval(xy);
|
|
1070
|
+
half a = s.a;
|
|
1071
|
+
half3 c = a > 0.0 ? s.rgb/a : s.rgb;
|
|
1072
|
+
if (dither > 0.0) {
|
|
1073
|
+
half n = hash(xy);
|
|
1074
|
+
half3 noise = monochromeDither > 0.5 ? half3(n) : hash3(xy);
|
|
1075
|
+
c = clamp(c + noise * dither, 0.0, 1.0);
|
|
1076
|
+
}
|
|
1077
|
+
if (blackT >= 0.0 && c.r < blackT && c.g < blackT && c.b < blackT) c = half3(0.0);
|
|
1078
|
+
if (whiteT >= 0.0 && c.r > whiteT && c.g > whiteT && c.b > whiteT) c = half3(1.0);
|
|
1079
|
+
return half4(c*a, a);
|
|
1080
|
+
}`;
|
|
1081
|
+
function finishEffect(ck) {
|
|
1082
|
+
let byVariant = effectCache.get(ck);
|
|
1083
|
+
if (!byVariant) {
|
|
1084
|
+
byVariant = new Map();
|
|
1085
|
+
effectCache.set(ck, byVariant);
|
|
1086
|
+
}
|
|
1087
|
+
let eff = byVariant.get("finish");
|
|
1088
|
+
if (eff === undefined) {
|
|
1089
|
+
eff = ck.RuntimeEffect.Make(FINISH_SKSL) ?? null;
|
|
1090
|
+
byVariant.set("finish", eff);
|
|
1091
|
+
}
|
|
1092
|
+
return eff;
|
|
1093
|
+
}
|
|
1094
|
+
function normalizeDither(dither) {
|
|
1095
|
+
if (typeof dither === "number") {
|
|
1096
|
+
return { amount: dither, seed: 0, monochrome: false };
|
|
1097
|
+
}
|
|
1098
|
+
const amount = dither?.amount ?? 0;
|
|
1099
|
+
const rawSeed = dither?.seed ?? 0;
|
|
1100
|
+
// RuntimeEffect uniforms are floats: bound the integer so every admissible seed
|
|
1101
|
+
// remains distinguishable in float precision and non-finite input is harmless.
|
|
1102
|
+
const seed = Number.isFinite(rawSeed) ? Math.trunc(rawSeed) % 1_000_000 : 0;
|
|
1103
|
+
return { amount, seed, monochrome: dither?.mode === "monochrome" };
|
|
1104
|
+
}
|
|
1105
|
+
// Run the finishing pass on the composited surface: snapshot it, clear it, then
|
|
1106
|
+
// redraw the snapshot through the finish shader. A no-op finish never reaches
|
|
1107
|
+
// here (compileScene only emits the command when something is set).
|
|
1108
|
+
function applyFrameFinish(ck, surface, bin, finish,
|
|
1109
|
+
// The surface's own pixel size — this pass reads and rewrites the composited
|
|
1110
|
+
// pixels, so it works in device pixels whatever density the scene exported at.
|
|
1111
|
+
device) {
|
|
1112
|
+
const effect = finishEffect(ck);
|
|
1113
|
+
if (!effect)
|
|
1114
|
+
return; // rt_effect unavailable — leave the frame as-is.
|
|
1115
|
+
const snap = bin.track(surface.makeImageSnapshot());
|
|
1116
|
+
const canvas = surface.getCanvas();
|
|
1117
|
+
canvas.clear(ck.TRANSPARENT);
|
|
1118
|
+
const srcSh = bin.track(snap.makeShaderOptions(ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Nearest, ck.MipmapMode.None));
|
|
1119
|
+
// Thresholds normalized to [0,1]; -1 disables. Dither gets an amplitude, stable
|
|
1120
|
+
// seed, and a scalar-vs-per-channel mode flag.
|
|
1121
|
+
// The comparisons are integer strict >/< on 8-bit values (matching a 0–255
|
|
1122
|
+
// pipeline), so the cutoff sits half a level past the threshold — robust to the
|
|
1123
|
+
// float round-trip and exact at the boundary (a pixel == threshold stays put).
|
|
1124
|
+
const whiteT = finish.whiteClamp !== undefined ? (finish.whiteClamp + 0.5) / 255 : -1;
|
|
1125
|
+
const blackT = finish.blackExtract !== undefined ? (finish.blackExtract - 0.5) / 255 : -1;
|
|
1126
|
+
const ditherConfig = normalizeDither(finish.dither);
|
|
1127
|
+
const dither = ditherConfig.amount > 0 ? ditherConfig.amount / 255 : 0;
|
|
1128
|
+
const shader = bin.track(effect.makeShaderWithChildren([
|
|
1129
|
+
whiteT,
|
|
1130
|
+
blackT,
|
|
1131
|
+
dither,
|
|
1132
|
+
ditherConfig.seed,
|
|
1133
|
+
ditherConfig.monochrome ? 1 : 0,
|
|
1134
|
+
], [srcSh]));
|
|
1135
|
+
const paint = bin.track(new ck.Paint());
|
|
1136
|
+
paint.setShader(shader);
|
|
1137
|
+
canvas.drawRect(ck.XYWHRect(0, 0, device.width, device.height), paint);
|
|
1138
|
+
}
|
|
1139
|
+
// Skia's name for each layer blend mode. Figma's linear dodge is Skia's Plus.
|
|
1140
|
+
const SKIA_BLEND_MODE = {
|
|
1141
|
+
normal: "SrcOver",
|
|
1142
|
+
multiply: "Multiply",
|
|
1143
|
+
screen: "Screen",
|
|
1144
|
+
overlay: "Overlay",
|
|
1145
|
+
darken: "Darken",
|
|
1146
|
+
lighten: "Lighten",
|
|
1147
|
+
"color-dodge": "ColorDodge",
|
|
1148
|
+
"color-burn": "ColorBurn",
|
|
1149
|
+
"hard-light": "HardLight",
|
|
1150
|
+
"soft-light": "SoftLight",
|
|
1151
|
+
difference: "Difference",
|
|
1152
|
+
exclusion: "Exclusion",
|
|
1153
|
+
hue: "Hue",
|
|
1154
|
+
saturation: "Saturation",
|
|
1155
|
+
color: "Color",
|
|
1156
|
+
luminosity: "Luminosity",
|
|
1157
|
+
plus: "Plus",
|
|
1158
|
+
};
|
|
1159
|
+
// A layer paint carrying opacity + blend + blur/shadow + adjust, so the element
|
|
1160
|
+
// composites onto everything below it exactly like a Figma layer.
|
|
1161
|
+
function layerPaint(ck, bin, cmd) {
|
|
1162
|
+
const { blendMode, opacity, blur, shadow } = cmd;
|
|
1163
|
+
const hasBlend = blendMode && blendMode !== "normal";
|
|
1164
|
+
const hasOpacity = opacity !== undefined && opacity < 1;
|
|
1165
|
+
const hasBlur = typeof blur === "number" && blur > 0;
|
|
1166
|
+
const colorFilter = adjustColorFilter(ck, bin, cmd);
|
|
1167
|
+
if (!hasBlend && !hasOpacity && !hasBlur && !shadow && !colorFilter)
|
|
1168
|
+
return null;
|
|
1169
|
+
const paint = bin.track(new ck.Paint());
|
|
1170
|
+
// The color filter runs on the layer's contents (before blur/shadow, which are
|
|
1171
|
+
// image filters on the layer result): color-correct first, spatial effects
|
|
1172
|
+
// after.
|
|
1173
|
+
if (colorFilter)
|
|
1174
|
+
paint.setColorFilter(colorFilter);
|
|
1175
|
+
if (hasOpacity)
|
|
1176
|
+
paint.setAlphaf(opacity);
|
|
1177
|
+
if (blendMode && blendMode !== "normal") {
|
|
1178
|
+
paint.setBlendMode(ck.BlendMode[SKIA_BLEND_MODE[blendMode]] ?? ck.BlendMode.SrcOver);
|
|
1179
|
+
}
|
|
1180
|
+
let filter = null;
|
|
1181
|
+
if (typeof blur === "number" && blur > 0)
|
|
1182
|
+
filter = bin.track(ck.ImageFilter.MakeBlur(LAYER_BLUR_SIGMA(blur), LAYER_BLUR_SIGMA(blur), ck.TileMode.Decal, null));
|
|
1183
|
+
const sh = shadowFilter(ck, bin, shadow);
|
|
1184
|
+
if (sh)
|
|
1185
|
+
filter = filter ? bin.track(ck.ImageFilter.MakeCompose(sh, filter)) : sh;
|
|
1186
|
+
if (filter)
|
|
1187
|
+
paint.setImageFilter(filter);
|
|
1188
|
+
return paint;
|
|
1189
|
+
}
|
|
1190
|
+
// Grow (or shrink) the silhouette a shadow is cast from. Figma's spread, and the
|
|
1191
|
+
// third length of a CSS box-shadow.
|
|
1192
|
+
function spreadSource(ck, bin, spread, input) {
|
|
1193
|
+
if (!spread)
|
|
1194
|
+
return input;
|
|
1195
|
+
const r = Math.abs(spread);
|
|
1196
|
+
return bin.track(spread > 0
|
|
1197
|
+
? ck.ImageFilter.MakeDilate(r, r, input)
|
|
1198
|
+
: ck.ImageFilter.MakeErode(r, r, input));
|
|
1199
|
+
}
|
|
1200
|
+
// A colour filter that replaces every pixel with `color` at the INVERSE of the
|
|
1201
|
+
// source's alpha: opaque where the drawable is empty, empty where it is solid.
|
|
1202
|
+
// That inverted silhouette, offset and blurred, is what an inner shadow casts —
|
|
1203
|
+
// the shape's own edges lighting inward.
|
|
1204
|
+
function invertedSilhouette(ck, bin, color) {
|
|
1205
|
+
const [r, g, b, a] = toColor(ck, color);
|
|
1206
|
+
// Skia colour matrix, row-major RGBA rows with a bias column: constant colour,
|
|
1207
|
+
// alpha = a·(1 − srcAlpha).
|
|
1208
|
+
const cf = bin.track(ck.ColorFilter.MakeMatrix([
|
|
1209
|
+
0,
|
|
1210
|
+
0,
|
|
1211
|
+
0,
|
|
1212
|
+
0,
|
|
1213
|
+
r,
|
|
1214
|
+
0,
|
|
1215
|
+
0,
|
|
1216
|
+
0,
|
|
1217
|
+
0,
|
|
1218
|
+
g,
|
|
1219
|
+
0,
|
|
1220
|
+
0,
|
|
1221
|
+
0,
|
|
1222
|
+
0,
|
|
1223
|
+
b,
|
|
1224
|
+
0,
|
|
1225
|
+
0,
|
|
1226
|
+
0,
|
|
1227
|
+
-a,
|
|
1228
|
+
a,
|
|
1229
|
+
]));
|
|
1230
|
+
return bin.track(ck.ImageFilter.MakeColorFilter(cf, null));
|
|
1231
|
+
}
|
|
1232
|
+
// The image filter for a drawable's whole shadow stack, or null when it has
|
|
1233
|
+
// none. A `null` input anywhere in the graph is the layer's own contents.
|
|
1234
|
+
//
|
|
1235
|
+
// Every shadow is cast from the ORIGINAL silhouette, not from the result of the
|
|
1236
|
+
// one below it — stacking MakeDropShadow would blur each shadow into the next.
|
|
1237
|
+
// So each is built on its own and blended: drop shadows under the contents,
|
|
1238
|
+
// inner shadows over them, each list painted bottom-up.
|
|
1239
|
+
function shadowFilter(ck, bin, shadow) {
|
|
1240
|
+
const list = (Array.isArray(shadow) ? shadow : shadow ? [shadow] : []).filter((s) => s.color !== "transparent");
|
|
1241
|
+
if (list.length === 0)
|
|
1242
|
+
return null;
|
|
1243
|
+
let under = null;
|
|
1244
|
+
let over = null;
|
|
1245
|
+
for (const s of list) {
|
|
1246
|
+
const sigma = SHADOW_SIGMA(s.blur);
|
|
1247
|
+
if (s.inset) {
|
|
1248
|
+
// Offset + blur the inverted silhouette, then keep only the part that
|
|
1249
|
+
// lands on the drawable — an inner shadow never spills outside it.
|
|
1250
|
+
let f = invertedSilhouette(ck, bin, s.color);
|
|
1251
|
+
f = spreadSource(ck, bin, s.spread ?? 0, f);
|
|
1252
|
+
if (s.dx || s.dy)
|
|
1253
|
+
f = bin.track(ck.ImageFilter.MakeOffset(s.dx, s.dy, f));
|
|
1254
|
+
if (sigma > 0)
|
|
1255
|
+
f = bin.track(ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, f));
|
|
1256
|
+
const clipped = bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcIn, null, f));
|
|
1257
|
+
over = over
|
|
1258
|
+
? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, over, clipped))
|
|
1259
|
+
: clipped;
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const cast = bin.track(ck.ImageFilter.MakeDropShadowOnly(s.dx, s.dy, sigma, sigma, toColor(ck, s.color), spreadSource(ck, bin, s.spread ?? 0, null)));
|
|
1263
|
+
under = under
|
|
1264
|
+
? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, under, cast))
|
|
1265
|
+
: cast;
|
|
1266
|
+
}
|
|
1267
|
+
// contents over the cast shadows, then the inner ones over that.
|
|
1268
|
+
let out = under
|
|
1269
|
+
? bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, under, null))
|
|
1270
|
+
: null;
|
|
1271
|
+
if (over)
|
|
1272
|
+
out = bin.track(ck.ImageFilter.MakeBlend(ck.BlendMode.SrcOver, out, over));
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
function paintDrawable(ck, canvas, provider, images, bin, cmd, issues, frame) {
|
|
1276
|
+
// lut/sharpen can't be a color filter — route the whole drawable through an
|
|
1277
|
+
// offscreen SkSL pass (which re-enters here with a matrix-only adjust).
|
|
1278
|
+
if (needsShaderAdjust(cmd)) {
|
|
1279
|
+
paintAdjustedOffscreen(ck, canvas, provider, images, bin, cmd, issues, frame);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// Past the offscreen path with "preserve-hue" still asked for: the matrix has
|
|
1283
|
+
// alpha terms the shader can't carry, so it runs through the clipping color
|
|
1284
|
+
// filter below and the mode is reported dropped rather than silently ignored.
|
|
1285
|
+
if (cmd.adjust?.gamut === "preserve-hue" && cmd.adjust.colorMatrix) {
|
|
1286
|
+
reportAdjustUnsupported(issues, cmd, "gamut");
|
|
1287
|
+
}
|
|
1288
|
+
canvas.save();
|
|
1289
|
+
if (cmd.rotation) {
|
|
1290
|
+
const cx = cmd.pos.x + cmd.size.width / 2;
|
|
1291
|
+
const cy = cmd.pos.y + cmd.size.height / 2;
|
|
1292
|
+
canvas.translate(cx, cy);
|
|
1293
|
+
canvas.rotate(cmd.rotation, 0, 0);
|
|
1294
|
+
canvas.translate(-cx, -cy);
|
|
1295
|
+
}
|
|
1296
|
+
const lp = layerPaint(ck, bin, cmd);
|
|
1297
|
+
if (lp)
|
|
1298
|
+
canvas.saveLayer(lp);
|
|
1299
|
+
// drawImage clips/strokes itself so its stroke isn't clipped.
|
|
1300
|
+
if (cmd.clip && cmd.op !== "drawImage")
|
|
1301
|
+
canvas.clipPath(maskPath(ck, bin, cmd.clip, cmd.pos.x, cmd.pos.y, cmd.size.width, cmd.size.height), ck.ClipOp.Intersect, true);
|
|
1302
|
+
drawShape(ck, canvas, provider, images, bin, cmd, issues, frame);
|
|
1303
|
+
if (lp)
|
|
1304
|
+
canvas.restore();
|
|
1305
|
+
canvas.restore();
|
|
1306
|
+
}
|
|
1307
|
+
// Collect the unique font requests + image srcs a scene's commands declare.
|
|
1308
|
+
function collectAssets(commands) {
|
|
1309
|
+
const fonts = new Map();
|
|
1310
|
+
const images = new Set();
|
|
1311
|
+
for (const cmd of commands) {
|
|
1312
|
+
if (cmd.op === "loadFonts")
|
|
1313
|
+
for (const r of cmd.requests)
|
|
1314
|
+
fonts.set(r.family, r);
|
|
1315
|
+
if (cmd.op === "loadImages")
|
|
1316
|
+
for (const s of cmd.srcs)
|
|
1317
|
+
images.add(s);
|
|
1318
|
+
}
|
|
1319
|
+
return { fonts: [...fonts.values()], images: [...images] };
|
|
1320
|
+
}
|
|
1321
|
+
// The CanvasKit paint routine env.paint runs (CanvasKit is the only backend, so
|
|
1322
|
+
// there is no painter-strategy indirection): one compiled scene -> a live surface
|
|
1323
|
+
// + a PaintOutput. freshcoat paints a single scene here — a card's multiple sides
|
|
1324
|
+
// are coatfile's concern, which calls paint() once per side. The font provider
|
|
1325
|
+
// + decoded images are built from the runtime's bytes and freed before returning;
|
|
1326
|
+
// the surface is bound to rt.canvas when present (displayable) else offscreen, and
|
|
1327
|
+
// returned live (the env disposes). encode/dispose defer to the caller/env. With
|
|
1328
|
+
// rt.cache, all three are kept by the cache instead and reused by the next paint.
|
|
1329
|
+
export async function paintScene(ck, commands, rt) {
|
|
1330
|
+
const cache = rt.cache ? paintCacheState(rt.cache) : null;
|
|
1331
|
+
if (cache)
|
|
1332
|
+
cache.stats.paints++;
|
|
1333
|
+
const { fonts, images } = collectAssets(commands);
|
|
1334
|
+
const warnings = [];
|
|
1335
|
+
const loaded = [];
|
|
1336
|
+
for (const req of fonts) {
|
|
1337
|
+
try {
|
|
1338
|
+
// CanvasKit has no native font system, so it always materializes bytes
|
|
1339
|
+
// (from the env's pre-supplied resolution or the shared fetch).
|
|
1340
|
+
for (const bytes of await fontBytes(rt.resolveFont(req)))
|
|
1341
|
+
loaded.push({ family: req.family, bytes });
|
|
1342
|
+
}
|
|
1343
|
+
catch (e) {
|
|
1344
|
+
warnings.push({
|
|
1345
|
+
kind: "font_load_failed",
|
|
1346
|
+
family: req.family,
|
|
1347
|
+
error: String(e),
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const provider = cache
|
|
1352
|
+
? cachedFontProvider(cache, loaded, () => makeFontProvider(ck, loaded))
|
|
1353
|
+
: makeFontProvider(ck, loaded);
|
|
1354
|
+
const imageMap = new Map();
|
|
1355
|
+
// Images the runtime lent through loadImage: painted, never freed here.
|
|
1356
|
+
const borrowed = new Set();
|
|
1357
|
+
for (const src of images) {
|
|
1358
|
+
if (rt.loadImage) {
|
|
1359
|
+
try {
|
|
1360
|
+
const img = await rt.loadImage(src, ck);
|
|
1361
|
+
if (img) {
|
|
1362
|
+
imageMap.set(src, img);
|
|
1363
|
+
borrowed.add(src);
|
|
1364
|
+
}
|
|
1365
|
+
else
|
|
1366
|
+
warnings.push({
|
|
1367
|
+
kind: "image_load_failed",
|
|
1368
|
+
src,
|
|
1369
|
+
error: "decode failed",
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
catch (e) {
|
|
1373
|
+
warnings.push({ kind: "image_load_failed", src, error: String(e) });
|
|
1374
|
+
}
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
const hit = cache?.images.get(src);
|
|
1378
|
+
if (hit) {
|
|
1379
|
+
imageMap.set(src, hit);
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
try {
|
|
1383
|
+
const bytes = await rt.loadImageBytes(src);
|
|
1384
|
+
if (cache)
|
|
1385
|
+
cache.stats.imageDecodes++;
|
|
1386
|
+
const img = ck.MakeImageFromEncoded(bytes);
|
|
1387
|
+
if (img) {
|
|
1388
|
+
imageMap.set(src, img);
|
|
1389
|
+
cache?.images.set(src, img);
|
|
1390
|
+
}
|
|
1391
|
+
else
|
|
1392
|
+
warnings.push({
|
|
1393
|
+
kind: "image_load_failed",
|
|
1394
|
+
src,
|
|
1395
|
+
error: "decode failed",
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
catch (e) {
|
|
1399
|
+
warnings.push({
|
|
1400
|
+
kind: "image_load_failed",
|
|
1401
|
+
src,
|
|
1402
|
+
error: String(e),
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const bin = makeBin();
|
|
1407
|
+
const create = commands.find((c) => c.op === "createCanvas");
|
|
1408
|
+
if (!create)
|
|
1409
|
+
throw new Error("canvaskit: scene has no createCanvas command");
|
|
1410
|
+
const design = { width: create.width, height: create.height };
|
|
1411
|
+
const exportScale = create.scale ?? 1;
|
|
1412
|
+
// The size the caller asked for. Everything the outside world sees — the
|
|
1413
|
+
// returned canvas, the encoded PNG, the finish pass — is this size.
|
|
1414
|
+
const device = exportPixelSize(design, exportScale);
|
|
1415
|
+
const { surface, canvas, loseContext } = cache
|
|
1416
|
+
? cachedSurface(cache, { width: device.width, height: device.height, hosted: !!rt.canvas }, () => makeSurface(ck, rt, device.width, device.height))
|
|
1417
|
+
: makeSurface(ck, rt, device.width, device.height);
|
|
1418
|
+
// Supersampling renders denser than the export and reduces (see
|
|
1419
|
+
// ./export-scale). Only the RENDER density belongs on `frame`: every pass that
|
|
1420
|
+
// steps outside the scaled matrix — the adjust offscreen, a layer blur — has to
|
|
1421
|
+
// size itself to the surface being drawn on, which is the render surface.
|
|
1422
|
+
const supersample = resolveSupersample(create.supersample, design, exportScale);
|
|
1423
|
+
const renderScale = exportScale * supersample;
|
|
1424
|
+
const render = exportPixelSize(design, renderScale);
|
|
1425
|
+
const frame = { ...design, scale: renderScale, grid: supersample };
|
|
1426
|
+
// Derived from the output surface rather than ck.MakeSurface so it stays on the
|
|
1427
|
+
// same backend (GPU under WebGL). A backend that won't give one degrades to
|
|
1428
|
+
// rendering at the export size — softer than asked for, but a render.
|
|
1429
|
+
const superSurface = supersample > 1
|
|
1430
|
+
? surface.getCanvas().makeSurface({
|
|
1431
|
+
width: render.width,
|
|
1432
|
+
height: render.height,
|
|
1433
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
1434
|
+
alphaType: ck.AlphaType.Premul,
|
|
1435
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
1436
|
+
})
|
|
1437
|
+
: null;
|
|
1438
|
+
if (supersample > 1 && !superSurface) {
|
|
1439
|
+
frame.scale = exportScale;
|
|
1440
|
+
frame.grid = 1;
|
|
1441
|
+
}
|
|
1442
|
+
// Where the drawables actually land: the supersampled offscreen when there is
|
|
1443
|
+
// one, else the output surface directly (the pre-supersampling path, untouched).
|
|
1444
|
+
const target = superSurface ?? surface;
|
|
1445
|
+
const skCanvas = target.getCanvas();
|
|
1446
|
+
skCanvas.clear(ck.TRANSPARENT);
|
|
1447
|
+
try {
|
|
1448
|
+
// Scoped to the drawable loop: the finishing pass below reads back the
|
|
1449
|
+
// composited pixels and belongs in device space.
|
|
1450
|
+
if (frame.scale !== 1) {
|
|
1451
|
+
skCanvas.save();
|
|
1452
|
+
skCanvas.scale(frame.scale, frame.scale);
|
|
1453
|
+
}
|
|
1454
|
+
for (const cmd of commands) {
|
|
1455
|
+
if (cmd.op === "createCanvas" ||
|
|
1456
|
+
cmd.op === "loadFonts" ||
|
|
1457
|
+
cmd.op === "loadImages" ||
|
|
1458
|
+
cmd.op === "finishFrame" // handled as a post-pass, after the loop
|
|
1459
|
+
)
|
|
1460
|
+
continue;
|
|
1461
|
+
const issues = {
|
|
1462
|
+
unhandled: [],
|
|
1463
|
+
missingImages: [],
|
|
1464
|
+
adjustUnsupported: new Map(),
|
|
1465
|
+
};
|
|
1466
|
+
paintDrawable(ck, skCanvas, provider, imageMap, bin, cmd, issues, frame);
|
|
1467
|
+
for (const op of issues.unhandled)
|
|
1468
|
+
warnings.push({ kind: "unhandled_op", op });
|
|
1469
|
+
for (const warning of issues.adjustUnsupported.values())
|
|
1470
|
+
warnings.push({ kind: "adjust_unsupported", ...warning });
|
|
1471
|
+
// Only srcs the loader never even attempted: a src it tried and failed
|
|
1472
|
+
// already pushed its own image_load_failed above, with the real error.
|
|
1473
|
+
for (const src of issues.missingImages) {
|
|
1474
|
+
if (!images.includes(src)) {
|
|
1475
|
+
warnings.push({
|
|
1476
|
+
kind: "image_load_failed",
|
|
1477
|
+
src,
|
|
1478
|
+
error: "no image was loaded for this src",
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
// Whole-frame finishing runs on the composited result, so after every
|
|
1484
|
+
// drawable and before the flush — and in device pixels, off the scaled
|
|
1485
|
+
// matrix the drawables used.
|
|
1486
|
+
if (frame.scale !== 1)
|
|
1487
|
+
skCanvas.restore();
|
|
1488
|
+
// The reduction comes BEFORE the finish: dither, black-extract and
|
|
1489
|
+
// white-clamp are output-resolution ops (see applyFrameFinish), and running
|
|
1490
|
+
// them on the dense render would average the dither back out and re-admit
|
|
1491
|
+
// values either threshold had just pushed off.
|
|
1492
|
+
if (superSurface)
|
|
1493
|
+
reduceSupersampled(ck, surface, superSurface, bin, design, exportScale, supersample);
|
|
1494
|
+
const finishCmd = commands.find((c) => c.op === "finishFrame");
|
|
1495
|
+
if (finishCmd)
|
|
1496
|
+
applyFrameFinish(ck, surface, bin, finishCmd.finish, device);
|
|
1497
|
+
// Flush before freeing the provider/images below: on the WebGL path the GPU
|
|
1498
|
+
// still references the decoded images until the surface is flushed.
|
|
1499
|
+
surface.flush();
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
if (cache)
|
|
1503
|
+
evictUnusedImages(cache, images);
|
|
1504
|
+
else {
|
|
1505
|
+
provider.delete();
|
|
1506
|
+
for (const [src, img] of imageMap)
|
|
1507
|
+
if (!borrowed.has(src))
|
|
1508
|
+
img.delete();
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
bin.free();
|
|
1512
|
+
return {
|
|
1513
|
+
canvas,
|
|
1514
|
+
warnings,
|
|
1515
|
+
// Raw RGBA at the device size, unpremultiplied — the surface's pixels with
|
|
1516
|
+
// no encoder in the way. Conformance compares backends here rather than on
|
|
1517
|
+
// encoded bytes, which would be comparing PNG writers.
|
|
1518
|
+
readPixels: () => {
|
|
1519
|
+
const snap = surface.makeImageSnapshot();
|
|
1520
|
+
try {
|
|
1521
|
+
const w = snap.width();
|
|
1522
|
+
const h = snap.height();
|
|
1523
|
+
const data = snap.readPixels(0, 0, {
|
|
1524
|
+
width: w,
|
|
1525
|
+
height: h,
|
|
1526
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
1527
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
1528
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
1529
|
+
});
|
|
1530
|
+
return data
|
|
1531
|
+
? { data: new Uint8Array(data), width: w, height: h }
|
|
1532
|
+
: null;
|
|
1533
|
+
}
|
|
1534
|
+
finally {
|
|
1535
|
+
snap.delete();
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
encode: async (encodeOpts) => {
|
|
1539
|
+
// Skia's own PNG writer is ~25-40% larger than the same pixels through
|
|
1540
|
+
// ./png (it keeps an alpha channel an opaque frame doesn't need, and
|
|
1541
|
+
// filters rows that compress better unfiltered), so read the pixels back
|
|
1542
|
+
// and encode them here. makeImageSnapshot() returns a WASM-backed Image
|
|
1543
|
+
// that must be freed; readPixels copies out, so delete right after.
|
|
1544
|
+
const snap = surface.makeImageSnapshot();
|
|
1545
|
+
const skiaPng = () => ({
|
|
1546
|
+
bytes: snap.encodeToBytes(),
|
|
1547
|
+
format: "png",
|
|
1548
|
+
});
|
|
1549
|
+
try {
|
|
1550
|
+
// WebP is Skia's own encoder and there is no hand-rolled alternative
|
|
1551
|
+
// the way there is for PNG: lossy WebP is a codec, not a deflate with
|
|
1552
|
+
// choices to make. A build compiled without it answers null, and PNG
|
|
1553
|
+
// — which every build has — is what comes back instead, said so in
|
|
1554
|
+
// `format` rather than passed off as the WebP that was asked for.
|
|
1555
|
+
if (encodeOpts?.format === "webp") {
|
|
1556
|
+
const webp = snap.encodeToBytes(ck.ImageFormat.WEBP, encodeOpts.quality ?? DEFAULT_WEBP_QUALITY);
|
|
1557
|
+
if (webp)
|
|
1558
|
+
return { bytes: webp, format: "webp" };
|
|
1559
|
+
}
|
|
1560
|
+
// JPEG is Skia's too, and falls back to PNG the same way.
|
|
1561
|
+
if (encodeOpts?.format === "jpeg") {
|
|
1562
|
+
const jpeg = encodeJpeg(ck, snap, encodeOpts.quality ?? DEFAULT_JPEG_QUALITY);
|
|
1563
|
+
if (jpeg)
|
|
1564
|
+
return { bytes: jpeg, format: "jpeg" };
|
|
1565
|
+
}
|
|
1566
|
+
const w = snap.width();
|
|
1567
|
+
const h = snap.height();
|
|
1568
|
+
const pixels = snap.readPixels(0, 0, {
|
|
1569
|
+
width: w,
|
|
1570
|
+
height: h,
|
|
1571
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
1572
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
1573
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
1574
|
+
});
|
|
1575
|
+
// A surface that won't read back (a lost context) still has Skia's
|
|
1576
|
+
// encoder, which works off the snapshot rather than a pixel buffer —
|
|
1577
|
+
// as does a runtime without CompressionStream. Bigger bytes beat no
|
|
1578
|
+
// bytes, so both fall back to it rather than failing the render.
|
|
1579
|
+
if (!pixels)
|
|
1580
|
+
return skiaPng();
|
|
1581
|
+
try {
|
|
1582
|
+
return {
|
|
1583
|
+
bytes: await encodePng(new Uint8Array(pixels), w, h, encodeOpts),
|
|
1584
|
+
format: "png",
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
return skiaPng();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
finally {
|
|
1592
|
+
snap.delete();
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
// Surface is freed with dispose(), not delete() — delete() leaves the
|
|
1596
|
+
// backing pixel buffer allocated (a ~w·h·4 leak per frame). On the WebGL
|
|
1597
|
+
// path this also drops the DOM canvas's GL context (releaseGL) so a full
|
|
1598
|
+
// release means dispose(); no consumer has to reach into result.canvas.
|
|
1599
|
+
// A cached surface is the cache's to free (clear/dispose), not the result's.
|
|
1600
|
+
dispose: cache
|
|
1601
|
+
? () => { }
|
|
1602
|
+
: () => {
|
|
1603
|
+
surface.dispose();
|
|
1604
|
+
loseContext();
|
|
1605
|
+
},
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
// Bind to rt.canvas when present (WebGL, SW fallback) -> displayable; otherwise an
|
|
1609
|
+
// offscreen raster surface. MakeWebGLCanvasSurface -> null IS the capability probe
|
|
1610
|
+
// (a host canvas that can't back WebGL), so this needs no environment flag.
|
|
1611
|
+
// `loseContext` drops the DOM canvas's WebGL context on dispose (see releaseGL); it
|
|
1612
|
+
// is a no-op for the SW/offscreen paths, which hold no such context.
|
|
1613
|
+
function makeSurface(ck, rt, w, h) {
|
|
1614
|
+
const noop = () => { };
|
|
1615
|
+
if (rt.canvas) {
|
|
1616
|
+
try {
|
|
1617
|
+
const el = rt.canvas.createCanvas(w, h);
|
|
1618
|
+
const gl = ck.MakeWebGLCanvasSurface(el);
|
|
1619
|
+
if (gl)
|
|
1620
|
+
return { surface: gl, canvas: el, loseContext: () => releaseGL(el) };
|
|
1621
|
+
const sw = ck.MakeSWCanvasSurface(el);
|
|
1622
|
+
if (sw)
|
|
1623
|
+
return { surface: sw, canvas: el, loseContext: noop };
|
|
1624
|
+
}
|
|
1625
|
+
catch {
|
|
1626
|
+
// Fall through to an offscreen surface.
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const surface = ck.MakeSurface(w, h);
|
|
1630
|
+
return {
|
|
1631
|
+
surface,
|
|
1632
|
+
canvas: { width: w, height: h, getContext: () => null },
|
|
1633
|
+
loseContext: noop,
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
// Free the WebGL context MakeWebGLCanvasSurface grabbed on this canvas so it stops
|
|
1637
|
+
// counting against the browser's live-context cap (~16 in Chrome). surface.dispose()
|
|
1638
|
+
// frees Skia's GPU resources but leaves the DOM canvas's context alive until GC —
|
|
1639
|
+
// under repeated repaints that races the cap and the browser force-drops the oldest
|
|
1640
|
+
// live context. getContext returns the SAME context Skia bound (a canvas is locked
|
|
1641
|
+
// to one context type), so this loses exactly that one and never allocates a new one.
|
|
1642
|
+
function releaseGL(canvas) {
|
|
1643
|
+
const c = canvas;
|
|
1644
|
+
const gl = c.getContext?.("webgl2") ?? c.getContext?.("webgl");
|
|
1645
|
+
gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
1646
|
+
}
|
|
1647
|
+
// The snapshot as a JPEG, flattened over white first because JPEG keeps no
|
|
1648
|
+
// alpha. Reads the pixels back rather than drawing the snapshot onto a second
|
|
1649
|
+
// surface, which works the same whether the frame is on the GPU or in memory.
|
|
1650
|
+
// null when the pixels will not read back or the build has no JPEG encoder.
|
|
1651
|
+
function encodeJpeg(ck, snap, quality) {
|
|
1652
|
+
const width = snap.width();
|
|
1653
|
+
const height = snap.height();
|
|
1654
|
+
const info = {
|
|
1655
|
+
width,
|
|
1656
|
+
height,
|
|
1657
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
1658
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
1659
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
1660
|
+
};
|
|
1661
|
+
const pixels = snap.readPixels(0, 0, info);
|
|
1662
|
+
if (!pixels)
|
|
1663
|
+
return null;
|
|
1664
|
+
const flat = ck.MakeImage({ ...info, alphaType: ck.AlphaType.Opaque }, flattenOverWhite(new Uint8Array(pixels)), width * 4);
|
|
1665
|
+
if (!flat)
|
|
1666
|
+
return null;
|
|
1667
|
+
try {
|
|
1668
|
+
return (flat.encodeToBytes(ck.ImageFormat.JPEG, quality) ??
|
|
1669
|
+
null);
|
|
1670
|
+
}
|
|
1671
|
+
finally {
|
|
1672
|
+
flat.delete();
|
|
1673
|
+
}
|
|
1674
|
+
}
|