capture-flow 0.0.0-stage → 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 +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background patterns drawn over the fill at `opacity`. `scale` (a fraction
|
|
3
|
+
* of u) sets the spacing between features:
|
|
4
|
+
* dots dot grid, one dot per `spacing` square
|
|
5
|
+
* grid hairline grid with `spacing` cells
|
|
6
|
+
* diagonal 45deg lines, `spacing` apart (measured perpendicular)
|
|
7
|
+
* noise film grain; grains are spacing / 20 (at least one pixel)
|
|
8
|
+
* waves horizontal sine lines, `spacing` apart, wavelength 3 x spacing
|
|
9
|
+
* rings concentric circles around the centre of the background box
|
|
10
|
+
* Tiled kinds are rendered once into a small cached tile and filled with a
|
|
11
|
+
* repeating pattern anchored at the background box origin, so in panorama
|
|
12
|
+
* mode (box = the whole strip, translated per slide) the pattern runs on
|
|
13
|
+
* seamlessly from one slide to the next.
|
|
14
|
+
*/
|
|
15
|
+
import type { Pattern } from '../model/types';
|
|
16
|
+
import type { Ctx2D, ImageLike, RenderEnv } from './types';
|
|
17
|
+
import { parseColor, rgba, type RGBA } from './color';
|
|
18
|
+
import { clamp, hash2, num, TAU, type Rect } from './geometry';
|
|
19
|
+
import { engineCache } from './cache';
|
|
20
|
+
|
|
21
|
+
const tiles = engineCache<string, ImageLike>(48, 8 * 1024 * 1024);
|
|
22
|
+
/** CanvasPattern objects belong to a context; keep one per (context, tile) without pinning either. */
|
|
23
|
+
const patternCache = new WeakMap<Ctx2D, WeakMap<object, CanvasPattern>>();
|
|
24
|
+
|
|
25
|
+
interface TileSpec {
|
|
26
|
+
key: string;
|
|
27
|
+
/** Period of the pattern in canvas pixels (may be fractional). */
|
|
28
|
+
pw: number;
|
|
29
|
+
ph: number;
|
|
30
|
+
/** Draws one period in period units; the context is already scaled to the tile. */
|
|
31
|
+
draw(g: Ctx2D, color: string, rgb: RGBA): void;
|
|
32
|
+
/** Noise is drawn pixel by pixel and must not be resampled. */
|
|
33
|
+
exact?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const KINDS = new Set(['dots', 'grid', 'diagonal', 'noise', 'waves', 'rings']);
|
|
37
|
+
|
|
38
|
+
/** Spacing in pixels for a pattern on a canvas with unit u (clamped to sane limits). */
|
|
39
|
+
export function patternSpacing(pattern: Pattern, u: number): number {
|
|
40
|
+
return clamp(num(pattern.scale, 0) * u, 2, Math.max(4, u * 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tileSpec(kind: string, spacing: number, res: number): TileSpec | null {
|
|
44
|
+
const s = spacing;
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case 'dots': {
|
|
47
|
+
const r = clamp(s * 0.09, 0.6, s * 0.45);
|
|
48
|
+
return {
|
|
49
|
+
key: `dots|${s}`,
|
|
50
|
+
pw: s,
|
|
51
|
+
ph: s,
|
|
52
|
+
draw(g, color) {
|
|
53
|
+
g.fillStyle = color;
|
|
54
|
+
g.beginPath();
|
|
55
|
+
g.arc(s / 2, s / 2, r, 0, TAU);
|
|
56
|
+
g.fill();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
case 'grid': {
|
|
61
|
+
const lw = clamp(s * 0.03, 1, s * 0.3);
|
|
62
|
+
// Lines run through the middle of each cell (pixel-snapped), so the slide edge never
|
|
63
|
+
// gets a line that reads as a border.
|
|
64
|
+
const o = Math.round((s - lw) / 2);
|
|
65
|
+
return {
|
|
66
|
+
key: `grid|${s}`,
|
|
67
|
+
pw: s,
|
|
68
|
+
ph: s,
|
|
69
|
+
draw(g, color) {
|
|
70
|
+
g.fillStyle = color;
|
|
71
|
+
g.beginPath();
|
|
72
|
+
g.rect(0, o, s, lw);
|
|
73
|
+
g.rect(o, 0, lw, s);
|
|
74
|
+
// One fill for both lines: crossings are not painted twice with translucent colours.
|
|
75
|
+
g.fill();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
case 'diagonal': {
|
|
80
|
+
const p = s * Math.SQRT2;
|
|
81
|
+
const lw = clamp(s * 0.1, 1, s * 0.45);
|
|
82
|
+
return {
|
|
83
|
+
key: `diagonal|${s}`,
|
|
84
|
+
pw: p,
|
|
85
|
+
ph: p,
|
|
86
|
+
draw(g, color) {
|
|
87
|
+
g.strokeStyle = color;
|
|
88
|
+
g.lineWidth = lw;
|
|
89
|
+
g.beginPath();
|
|
90
|
+
// Three parallel "/" lines so the stroke also covers the tile corners after wrapping.
|
|
91
|
+
for (let k = -1; k <= 1; k++) {
|
|
92
|
+
g.moveTo(k * p - lw, p + lw);
|
|
93
|
+
g.lineTo((k + 1) * p + lw, -lw);
|
|
94
|
+
}
|
|
95
|
+
g.stroke();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case 'waves': {
|
|
100
|
+
const lambda = s * 3;
|
|
101
|
+
const amp = s * 0.18;
|
|
102
|
+
const lw = clamp(s * 0.06, 1, s * 0.3);
|
|
103
|
+
return {
|
|
104
|
+
key: `waves|${s}`,
|
|
105
|
+
pw: lambda,
|
|
106
|
+
ph: s,
|
|
107
|
+
draw(g, color) {
|
|
108
|
+
g.strokeStyle = color;
|
|
109
|
+
g.lineWidth = lw;
|
|
110
|
+
g.lineJoin = 'round';
|
|
111
|
+
g.beginPath();
|
|
112
|
+
const steps = 48;
|
|
113
|
+
// Rows above and below, and a little past both sides, so strokes meet across tile seams.
|
|
114
|
+
for (const dy of [-s, 0, s]) {
|
|
115
|
+
for (let i = -2; i <= steps + 2; i++) {
|
|
116
|
+
const x = (i / steps) * lambda;
|
|
117
|
+
const y = s / 2 + dy + amp * Math.sin((i / steps) * TAU);
|
|
118
|
+
if (i === -2) g.moveTo(x, y);
|
|
119
|
+
else g.lineTo(x, y);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
g.stroke();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'noise': {
|
|
127
|
+
// Grains are whole device pixels; the period is given in canvas units.
|
|
128
|
+
const grain = Math.max(1, Math.round((s / 20) * res));
|
|
129
|
+
const cells = grain === 1 ? 256 : clamp(Math.floor(384 / grain), 32, 192);
|
|
130
|
+
const size = cells * grain;
|
|
131
|
+
return {
|
|
132
|
+
key: `noise|${grain}`,
|
|
133
|
+
pw: size / res,
|
|
134
|
+
ph: size / res,
|
|
135
|
+
exact: true,
|
|
136
|
+
draw(g, _color, rgb) {
|
|
137
|
+
const img = g.createImageData(size, size);
|
|
138
|
+
const d = img.data;
|
|
139
|
+
for (let cy = 0; cy < cells; cy++) {
|
|
140
|
+
for (let cx = 0; cx < cells; cx++) {
|
|
141
|
+
const a = Math.round(hash2(cx, cy, 0x5eed) * 255 * rgb.a);
|
|
142
|
+
for (let y = cy * grain; y < (cy + 1) * grain; y++) {
|
|
143
|
+
for (let x = cx * grain; x < (cx + 1) * grain; x++) {
|
|
144
|
+
const i = (y * size + x) * 4;
|
|
145
|
+
d[i] = rgb.r;
|
|
146
|
+
d[i + 1] = rgb.g;
|
|
147
|
+
d[i + 2] = rgb.b;
|
|
148
|
+
d[i + 3] = a;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
g.putImageData(img, 0, 0);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tiles larger than this are rendered at reduced resolution (only for extreme spacings). */
|
|
163
|
+
const MAX_TILE_SIDE = 2048;
|
|
164
|
+
const MAX_TILE_PIXELS = 1_500_000;
|
|
165
|
+
|
|
166
|
+
function tileFor(spec: TileSpec, rgb: RGBA, env: RenderEnv, res: number): ImageLike | null {
|
|
167
|
+
const color = rgba(rgb);
|
|
168
|
+
// Device resolution (res = transform scale), capped so huge spacings never allocate a giant
|
|
169
|
+
// tile every frame (it would never fit the cache).
|
|
170
|
+
const k = spec.exact ? 1 : Math.min(res, MAX_TILE_SIDE / Math.max(spec.pw, spec.ph), Math.sqrt(MAX_TILE_PIXELS / (spec.pw * spec.ph)));
|
|
171
|
+
const tw = spec.exact ? Math.round(spec.pw * res) : Math.max(2, Math.round(spec.pw * k));
|
|
172
|
+
const th = spec.exact ? Math.round(spec.ph * res) : Math.max(2, Math.round(spec.ph * k));
|
|
173
|
+
const key = `${spec.key}|${color}|${tw}x${th}`;
|
|
174
|
+
const hit = tiles.get(key);
|
|
175
|
+
if (hit) return hit;
|
|
176
|
+
let made: { canvas: ImageLike; ctx: Ctx2D };
|
|
177
|
+
try {
|
|
178
|
+
made = env.createCanvas(tw, th);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const g = made.ctx;
|
|
183
|
+
g.save();
|
|
184
|
+
if (!spec.exact) g.scale(tw / spec.pw, th / spec.ph);
|
|
185
|
+
spec.draw(g, color, rgb);
|
|
186
|
+
g.restore();
|
|
187
|
+
tiles.set(key, made.canvas, tw * th * 4);
|
|
188
|
+
return made.canvas;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function patternFor(ctx: Ctx2D, tile: ImageLike): CanvasPattern | null {
|
|
192
|
+
let perCtx = patternCache.get(ctx);
|
|
193
|
+
if (!perCtx) {
|
|
194
|
+
perCtx = new WeakMap();
|
|
195
|
+
patternCache.set(ctx, perCtx);
|
|
196
|
+
}
|
|
197
|
+
let p = perCtx.get(tile);
|
|
198
|
+
if (!p) {
|
|
199
|
+
p = ctx.createPattern(tile, 'repeat') ?? undefined;
|
|
200
|
+
if (!p) return null;
|
|
201
|
+
perCtx.set(tile, p);
|
|
202
|
+
}
|
|
203
|
+
return p;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function drawRings(ctx: Ctx2D, box: Rect, area: Rect, spacing: number, color: string): void {
|
|
207
|
+
const cx = box.x + box.w / 2;
|
|
208
|
+
const cy = box.y + box.h / 2;
|
|
209
|
+
const nearX = Math.max(0, area.x - cx, cx - (area.x + area.w));
|
|
210
|
+
const nearY = Math.max(0, area.y - cy, cy - (area.y + area.h));
|
|
211
|
+
const farX = Math.max(Math.abs(area.x - cx), Math.abs(area.x + area.w - cx));
|
|
212
|
+
const farY = Math.max(Math.abs(area.y - cy), Math.abs(area.y + area.h - cy));
|
|
213
|
+
const lw = clamp(spacing * 0.05, 1, spacing * 0.4);
|
|
214
|
+
const first = Math.max(1, Math.floor((Math.hypot(nearX, nearY) - lw) / spacing));
|
|
215
|
+
const last = Math.min(first + 1500, Math.ceil((Math.hypot(farX, farY) + lw) / spacing));
|
|
216
|
+
ctx.strokeStyle = color;
|
|
217
|
+
ctx.lineWidth = lw;
|
|
218
|
+
ctx.beginPath();
|
|
219
|
+
for (let k = first; k <= last; k++) {
|
|
220
|
+
const r = k * spacing;
|
|
221
|
+
ctx.moveTo(cx + r, cy);
|
|
222
|
+
ctx.arc(cx, cy, r, 0, TAU);
|
|
223
|
+
}
|
|
224
|
+
ctx.stroke();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Paints a pattern laid out over `box` into `area`. Invalid patterns
|
|
229
|
+
* (unknown kind, zero opacity, non-positive scale) paint nothing.
|
|
230
|
+
*/
|
|
231
|
+
export function paintPattern(ctx: Ctx2D, pattern: Pattern | null | undefined, box: Rect, area: Rect, u: number, env: RenderEnv, pxScale = 1): void {
|
|
232
|
+
if (!pattern || typeof pattern !== 'object' || !KINDS.has(pattern.kind)) return;
|
|
233
|
+
const opacity = clamp(num(pattern.opacity, 0), 0, 1);
|
|
234
|
+
if (opacity <= 0 || !(num(pattern.scale, 0) > 0) || !(area.w > 0) || !(area.h > 0)) return;
|
|
235
|
+
const spacing = patternSpacing(pattern, u);
|
|
236
|
+
const rgb = parseColor(typeof pattern.color === 'string' ? pattern.color : '#000000');
|
|
237
|
+
const color = rgba(rgb);
|
|
238
|
+
ctx.save();
|
|
239
|
+
try {
|
|
240
|
+
ctx.globalAlpha *= opacity;
|
|
241
|
+
if (pattern.kind === 'rings') {
|
|
242
|
+
drawRings(ctx, box, area, spacing, color);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const res = clamp(num(pxScale, 1), 0.1, 4);
|
|
246
|
+
const spec = tileSpec(pattern.kind, spacing, res);
|
|
247
|
+
if (!spec) return;
|
|
248
|
+
const tile = tileFor(spec, rgb, env, res);
|
|
249
|
+
if (!tile) return;
|
|
250
|
+
const pat = patternFor(ctx, tile);
|
|
251
|
+
if (!pat) return;
|
|
252
|
+
const sx = spec.pw / tile.width;
|
|
253
|
+
const sy = spec.ph / tile.height;
|
|
254
|
+
ctx.translate(box.x, box.y);
|
|
255
|
+
ctx.scale(sx, sy);
|
|
256
|
+
ctx.fillStyle = pat;
|
|
257
|
+
// Tiles are rendered at device resolution and drawn ~1:1; bilinear sampling is enough.
|
|
258
|
+
ctx.imageSmoothingQuality = 'low';
|
|
259
|
+
ctx.fillRect((area.x - box.x) / sx, (area.y - box.y) / sy, area.w / sx, area.h / sy);
|
|
260
|
+
} finally {
|
|
261
|
+
ctx.restore();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slide composition: background, pattern, then every element in painter's
|
|
3
|
+
* order. Each element is drawn translated to its anchor, rotated about its
|
|
4
|
+
* centre and faded by its opacity, and renderSlide reports where every
|
|
5
|
+
* visible element ended up so the editor can hit-test and draw handles.
|
|
6
|
+
*
|
|
7
|
+
* Bad data never throws out of here: sizes, positions and colours are
|
|
8
|
+
* sanitised, and an element that still fails to draw is skipped so the rest
|
|
9
|
+
* of the slide renders.
|
|
10
|
+
*/
|
|
11
|
+
import type { DeviceElement, DeviceFrameId, FamilyId, ImageElement, ShapeElement, TextElement } from '../model/types';
|
|
12
|
+
import { FAMILIES } from '../model/catalog';
|
|
13
|
+
import { mirrorElement } from '../model/mirror';
|
|
14
|
+
import { resolveShot, resolveText } from '../model/resolve';
|
|
15
|
+
import { styleForLanguage } from '../model/typography';
|
|
16
|
+
import type { Ctx2D, DeviceGeometry, ElementBox, ImageLike, RenderInput, RenderResult, RenderSlideFn } from './types';
|
|
17
|
+
import { drawDevice } from './devices';
|
|
18
|
+
import { paintBackground } from './background';
|
|
19
|
+
import { drawShape, shapeSize } from './shapes';
|
|
20
|
+
import { drawImageElement, resolveImage, type ImageSources } from './images';
|
|
21
|
+
import { drawTextBlock, layoutTextBlock } from './text';
|
|
22
|
+
import { clamp, DEG, num, roundedRect, transformScale } from './geometry';
|
|
23
|
+
import type { PaintContext } from './paint';
|
|
24
|
+
|
|
25
|
+
/** Used when neither the screenshot nor the family knows the screen shape (iPhone 6.9″). */
|
|
26
|
+
const DEFAULT_SCREEN_ASPECT = 2868 / 1320;
|
|
27
|
+
|
|
28
|
+
interface Scene {
|
|
29
|
+
input: RenderInput;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
u: number;
|
|
33
|
+
pxScale: number;
|
|
34
|
+
pc: PaintContext;
|
|
35
|
+
sources: ImageSources;
|
|
36
|
+
result: RenderResult;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resetState(ctx: Ctx2D): void {
|
|
40
|
+
ctx.globalAlpha = 1;
|
|
41
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
42
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0)';
|
|
43
|
+
ctx.shadowBlur = 0;
|
|
44
|
+
ctx.shadowOffsetX = 0;
|
|
45
|
+
ctx.shadowOffsetY = 0;
|
|
46
|
+
ctx.imageSmoothingEnabled = true;
|
|
47
|
+
ctx.imageSmoothingQuality = 'high';
|
|
48
|
+
ctx.lineWidth = 1;
|
|
49
|
+
ctx.lineCap = 'butt';
|
|
50
|
+
ctx.lineJoin = 'miter';
|
|
51
|
+
ctx.textAlign = 'left';
|
|
52
|
+
ctx.textBaseline = 'alphabetic';
|
|
53
|
+
ctx.direction = 'ltr';
|
|
54
|
+
if (typeof ctx.setLineDash === 'function') ctx.setLineDash([]);
|
|
55
|
+
if ('filter' in ctx) ctx.filter = 'none';
|
|
56
|
+
if ('letterSpacing' in ctx) ctx.letterSpacing = '0px';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const elementId = (el: { id: unknown }): string => (typeof el.id === 'string' ? el.id : String(el.id ?? ''));
|
|
60
|
+
|
|
61
|
+
function attempt<T>(fn: () => T, fallback: T): T {
|
|
62
|
+
try {
|
|
63
|
+
return fn();
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Translates to the element centre, rotates, applies opacity, draws; the caller saves/restores. */
|
|
70
|
+
function place(ctx: Ctx2D, cx: number, cy: number, rotation: number, opacity: number, draw: () => void): void {
|
|
71
|
+
ctx.translate(cx, cy);
|
|
72
|
+
if (rotation) ctx.rotate(rotation * DEG);
|
|
73
|
+
ctx.globalAlpha *= opacity;
|
|
74
|
+
draw();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function textElement(ctx: Ctx2D, el: TextElement, scene: Scene, cx: number, ay: number, rotation: number, opacity: number): ElementBox {
|
|
78
|
+
const { input, u } = scene;
|
|
79
|
+
const raw = attempt(() => resolveText(input.slide, el.key, input.lang, input.baseLang), '');
|
|
80
|
+
const layout = layoutTextBlock(ctx, {
|
|
81
|
+
text: typeof raw === 'string' ? raw : '',
|
|
82
|
+
style: el.style && typeof el.style === 'object' ? styleForLanguage(el.style, input.typography) : el.style,
|
|
83
|
+
boxWidth: Math.max(0, num(el.width, 0) * u),
|
|
84
|
+
unit: u,
|
|
85
|
+
rtl: input.rtl === true,
|
|
86
|
+
lang: input.lang,
|
|
87
|
+
});
|
|
88
|
+
const h = layout.height;
|
|
89
|
+
// y is the top edge, centre or bottom edge of the block; boxes are always centred.
|
|
90
|
+
const cy = el.valign === 'top' ? ay + h / 2 : el.valign === 'bottom' ? ay - h / 2 : ay;
|
|
91
|
+
if (layout.overflow) scene.result.overflowingText.push(elementId(el));
|
|
92
|
+
attempt(() => place(ctx, cx, cy, rotation, opacity, () => drawTextBlock(ctx, layout, { unit: u, pxScale: scene.pxScale })), undefined);
|
|
93
|
+
return { id: elementId(el), cx, cy, w: layout.boxWidth, h, rotation };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeGeometry(g: unknown, fallbackW: number, fallbackH: number): { w: number; h: number } {
|
|
97
|
+
if (g && typeof g === 'object') {
|
|
98
|
+
const o = g as Partial<DeviceGeometry>;
|
|
99
|
+
const w = num(o.width, -1);
|
|
100
|
+
const h = num(o.height, -1);
|
|
101
|
+
if (w >= 0 && h >= 0) return { w, h };
|
|
102
|
+
}
|
|
103
|
+
return { w: fallbackW, h: fallbackH };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Plain stand-in used only if the device painter fails, so the layout stays legible. */
|
|
107
|
+
function drawFallbackDevice(ctx: Ctx2D, w: number, h: number, screen: ImageLike | null): void {
|
|
108
|
+
ctx.fillStyle = '#1d1d22';
|
|
109
|
+
ctx.beginPath();
|
|
110
|
+
roundedRect(ctx, -w / 2, -h / 2, w, h, w * 0.12);
|
|
111
|
+
ctx.fill();
|
|
112
|
+
const inset = w * 0.035;
|
|
113
|
+
ctx.save();
|
|
114
|
+
try {
|
|
115
|
+
ctx.beginPath();
|
|
116
|
+
roundedRect(ctx, -w / 2 + inset, -h / 2 + inset, w - 2 * inset, h - 2 * inset, w * 0.09);
|
|
117
|
+
ctx.clip();
|
|
118
|
+
ctx.fillStyle = '#3a3a42';
|
|
119
|
+
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
120
|
+
if (screen) {
|
|
121
|
+
const sw = w - 2 * inset;
|
|
122
|
+
const sh = h - 2 * inset;
|
|
123
|
+
const k = Math.max(sw / screen.width, sh / screen.height);
|
|
124
|
+
ctx.drawImage(screen, -(screen.width * k) / 2, -(screen.height * k) / 2, screen.width * k, screen.height * k);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
ctx.restore();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function deviceElement(ctx: Ctx2D, el: DeviceElement, index: number, scene: Scene, cx: number, cy: number, rotation: number, opacity: number): ElementBox {
|
|
132
|
+
const { input, u, sources, result } = scene;
|
|
133
|
+
const widthPx = Math.max(0, num(el.width, 0) * u);
|
|
134
|
+
const shot = attempt(() => resolveShot(input.slide, input.family, el.slot, input.lang, input.baseLang), null);
|
|
135
|
+
const assetId = typeof shot === 'string' && shot ? shot : null;
|
|
136
|
+
const known = assetId ? sources.getAssetAspect(assetId) : null;
|
|
137
|
+
const screenAspect = deviceScreenAspect(el.frame, input.family, known);
|
|
138
|
+
const image = assetId ? sources.getImage(assetId) : null;
|
|
139
|
+
let placeholder: 'mock' | 'empty' = input.placeholder === 'empty' ? 'empty' : 'mock';
|
|
140
|
+
if (!assetId) result.missingShots.push(elementId(el));
|
|
141
|
+
else if (!image) {
|
|
142
|
+
// Resolved but still decoding: a calm empty screen instead of a mock that flashes away.
|
|
143
|
+
result.pendingImages.push(elementId(el));
|
|
144
|
+
placeholder = 'empty';
|
|
145
|
+
}
|
|
146
|
+
const fallback = { w: widthPx, h: widthPx * screenAspect };
|
|
147
|
+
if (widthPx < 1) return { id: elementId(el), cx, cy, w: fallback.w, h: fallback.h, rotation };
|
|
148
|
+
|
|
149
|
+
const theme = input.theme;
|
|
150
|
+
const palette = Array.isArray(theme?.palette) ? theme.palette.filter((c): c is string => typeof c === 'string') : [];
|
|
151
|
+
const slideIndex = Math.floor(num(input.slideIndex, 0));
|
|
152
|
+
let size = fallback;
|
|
153
|
+
ctx.translate(cx, cy);
|
|
154
|
+
if (rotation) ctx.rotate(rotation * DEG);
|
|
155
|
+
ctx.globalAlpha *= opacity;
|
|
156
|
+
try {
|
|
157
|
+
const geometry = drawDevice(ctx, {
|
|
158
|
+
el,
|
|
159
|
+
widthPx,
|
|
160
|
+
unit: u,
|
|
161
|
+
screenAspect,
|
|
162
|
+
screen: image,
|
|
163
|
+
placeholder,
|
|
164
|
+
mock: { seed: mockSeed(slideIndex, index), palette, dark: theme?.mockDark === true },
|
|
165
|
+
env: input.env,
|
|
166
|
+
});
|
|
167
|
+
size = sanitizeGeometry(geometry, fallback.w, fallback.h);
|
|
168
|
+
} catch {
|
|
169
|
+
attempt(() => drawFallbackDevice(ctx, fallback.w, fallback.h, image), undefined);
|
|
170
|
+
}
|
|
171
|
+
return { id: elementId(el), cx, cy, w: size.w, h: size.h, rotation };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shapeElement(ctx: Ctx2D, el: ShapeElement, scene: Scene, cx: number, cy: number, rotation: number, opacity: number): ElementBox {
|
|
175
|
+
const { w, h } = shapeSize(el, scene.u);
|
|
176
|
+
// Decoration, so on a mirrored slide its outline and fill flip too (a blob, a gradient); a picture fill keeps reading normally.
|
|
177
|
+
const flip = scene.input.mirror === true && el.fill?.type !== 'image';
|
|
178
|
+
attempt(
|
|
179
|
+
() =>
|
|
180
|
+
place(ctx, cx, cy, rotation, opacity, () => {
|
|
181
|
+
if (flip) ctx.scale(-1, 1);
|
|
182
|
+
drawShape(ctx, el, scene.u, scene.pc);
|
|
183
|
+
}),
|
|
184
|
+
undefined,
|
|
185
|
+
);
|
|
186
|
+
return { id: elementId(el), cx, cy, w, h, rotation };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function imageElement(ctx: Ctx2D, el: ImageElement, scene: Scene, cx: number, cy: number, rotation: number, opacity: number): ElementBox {
|
|
190
|
+
const { aspect } = resolveImage(el, scene.sources);
|
|
191
|
+
const w = Math.max(0, num(el.width, 0) * scene.u);
|
|
192
|
+
const h = w * aspect;
|
|
193
|
+
attempt(() => place(ctx, cx, cy, rotation, opacity, () => drawImageElement(ctx, el, scene.u, scene.pxScale, scene.sources)), undefined);
|
|
194
|
+
return { id: elementId(el), cx, cy, w, h, rotation };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The family whose screen each frame shows; frame "none" has no shape of its own. */
|
|
198
|
+
const FRAME_FAMILY: Record<DeviceFrameId, FamilyId | null> = {
|
|
199
|
+
iphone: 'iphone',
|
|
200
|
+
ipad: 'ipad',
|
|
201
|
+
'android-phone': 'android-phone',
|
|
202
|
+
'android-tablet': 'android-tablet',
|
|
203
|
+
macbook: 'mac',
|
|
204
|
+
watch: 'watch',
|
|
205
|
+
tv: 'apple-tv',
|
|
206
|
+
vision: 'vision',
|
|
207
|
+
browser: 'mac',
|
|
208
|
+
none: null,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Frames that can lie on their side: phones and tablets. */
|
|
212
|
+
const TURNABLE_FRAMES: ReadonlySet<DeviceFrameId> = new Set(['iphone', 'ipad', 'android-phone', 'android-tablet']);
|
|
213
|
+
|
|
214
|
+
/** How far a screenshot's aspect may differ from its frame's before it is cropped to the frame instead. */
|
|
215
|
+
const SHOT_ASPECT_TOLERANCE = 0.2;
|
|
216
|
+
|
|
217
|
+
const near = (a: number, b: number) => Math.abs(a / b - 1) <= SHOT_ASPECT_TOLERANCE;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Screen aspect (height / width) of a device element. A frame keeps its own
|
|
221
|
+
* screen shape: a MacBook stays a laptop on an iPhone slide, a phone stays a
|
|
222
|
+
* phone on a Mac slide. Phones and tablets lie on their side on landscape
|
|
223
|
+
* slides, and turn to fit a screenshot taken the other way round. A
|
|
224
|
+
* screenshot close to the frame's shape (a phone shot of a slightly
|
|
225
|
+
* different model) sets the exact aspect; any other is cover-fitted into the
|
|
226
|
+
* frame's screen. Frame "none" shows the screenshot as it is, or the slide
|
|
227
|
+
* family's screen without one.
|
|
228
|
+
*/
|
|
229
|
+
export function deviceScreenAspect(frame: DeviceFrameId, family: FamilyId, assetAspect: number | null): number {
|
|
230
|
+
const shot = assetAspect !== null && Number.isFinite(assetAspect) && assetAspect > 0 ? assetAspect : null;
|
|
231
|
+
const own = FRAME_FAMILY[frame] ?? null;
|
|
232
|
+
const familyAspect = num(FAMILIES[own ?? family]?.screenAspect, 0);
|
|
233
|
+
let natural = familyAspect > 0 ? familyAspect : DEFAULT_SCREEN_ASPECT;
|
|
234
|
+
const turnable = TURNABLE_FRAMES.has(frame);
|
|
235
|
+
if (turnable && FAMILIES[family]?.landscape === true) natural = 1 / natural;
|
|
236
|
+
let aspect = natural;
|
|
237
|
+
if (shot !== null && (own === null || near(shot, natural) || (turnable && near(shot, 1 / natural)))) aspect = shot;
|
|
238
|
+
return clamp(aspect, 0.05, 20);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Seed of the generated mock screen shown by the device at `elementIndex` of slide `slideIndex`. */
|
|
242
|
+
export function mockSeed(slideIndex: number, elementIndex: number): number {
|
|
243
|
+
return slideIndex * 31 + elementIndex * 7 + 3;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether a mirrored slide flips its background. Gradients, mesh blobs and
|
|
248
|
+
* patterns do; a photo or picture does not (its text would read backwards),
|
|
249
|
+
* unless it is a panorama: mirrored slides keep a panorama's seams joined,
|
|
250
|
+
* because the store lists right-to-left screenshots from the right.
|
|
251
|
+
*/
|
|
252
|
+
function flipsBackground(input: RenderInput): boolean {
|
|
253
|
+
const own = input.layout?.background ?? null;
|
|
254
|
+
const fill = (own ?? input.theme?.background ?? null)?.fill;
|
|
255
|
+
return fill?.type !== 'image' || (!own && input.theme?.panorama === true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Renders one slide into `ctx` at (0, 0, width, height). See RenderSlideFn. */
|
|
259
|
+
export const renderSlide: RenderSlideFn = (ctx, input) => {
|
|
260
|
+
const result: RenderResult = { boxes: [], missingShots: [], overflowingText: [], pendingImages: [] };
|
|
261
|
+
const width = num(input?.width, 0);
|
|
262
|
+
const height = num(input?.height, 0);
|
|
263
|
+
if (!(width >= 1) || !(height >= 1)) return result;
|
|
264
|
+
const u = Math.min(width, height);
|
|
265
|
+
|
|
266
|
+
const getImage = (id: string): ImageLike | null =>
|
|
267
|
+
attempt(() => {
|
|
268
|
+
const img = input.getImage(id);
|
|
269
|
+
return img && img.width > 0 && img.height > 0 ? img : null;
|
|
270
|
+
}, null);
|
|
271
|
+
const getAssetAspect = (id: string): number | null =>
|
|
272
|
+
attempt(() => {
|
|
273
|
+
const a = input.getAssetAspect(id);
|
|
274
|
+
return typeof a === 'number' && Number.isFinite(a) && a > 0 ? a : null;
|
|
275
|
+
}, null);
|
|
276
|
+
const pxScale = transformScale(ctx);
|
|
277
|
+
const scene: Scene = {
|
|
278
|
+
input,
|
|
279
|
+
width,
|
|
280
|
+
height,
|
|
281
|
+
u,
|
|
282
|
+
pxScale,
|
|
283
|
+
pc: { env: input.env, u, getImage, pxScale },
|
|
284
|
+
sources: { getImage, getAssetAspect },
|
|
285
|
+
result,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
ctx.save();
|
|
289
|
+
try {
|
|
290
|
+
resetState(ctx);
|
|
291
|
+
// Elements may hang off the slide; when several slides share one canvas (thumbnails),
|
|
292
|
+
// they must not bleed into the neighbours.
|
|
293
|
+
ctx.beginPath();
|
|
294
|
+
ctx.rect(0, 0, width, height);
|
|
295
|
+
ctx.clip();
|
|
296
|
+
|
|
297
|
+
const theme = input.theme;
|
|
298
|
+
const mirror = input.mirror === true;
|
|
299
|
+
ctx.save();
|
|
300
|
+
if (mirror && flipsBackground(input)) {
|
|
301
|
+
ctx.translate(width, 0);
|
|
302
|
+
ctx.scale(-1, 1);
|
|
303
|
+
}
|
|
304
|
+
attempt(
|
|
305
|
+
() =>
|
|
306
|
+
paintBackground(
|
|
307
|
+
ctx,
|
|
308
|
+
{
|
|
309
|
+
width,
|
|
310
|
+
height,
|
|
311
|
+
own: input.layout?.background ?? null,
|
|
312
|
+
theme: theme?.background ?? null,
|
|
313
|
+
panorama: theme?.panorama === true,
|
|
314
|
+
slideIndex: input.slideIndex,
|
|
315
|
+
slideCount: input.slideCount,
|
|
316
|
+
},
|
|
317
|
+
scene.pc,
|
|
318
|
+
),
|
|
319
|
+
undefined,
|
|
320
|
+
);
|
|
321
|
+
ctx.restore();
|
|
322
|
+
|
|
323
|
+
const elements: unknown[] = Array.isArray(input.layout?.elements) ? input.layout.elements : [];
|
|
324
|
+
elements.forEach((item, index) => {
|
|
325
|
+
if (!item || typeof item !== 'object') return;
|
|
326
|
+
const own = item as DeviceElement | TextElement | ShapeElement | ImageElement;
|
|
327
|
+
const el = mirror ? attempt(() => mirrorElement(own), own) : own;
|
|
328
|
+
if (el.hidden === true) return;
|
|
329
|
+
const opacity = clamp(num(el.opacity, 1), 0, 1);
|
|
330
|
+
if (opacity <= 0) return;
|
|
331
|
+
const cx = num(el.x, 0.5) * width;
|
|
332
|
+
const cy = num(el.y, 0.5) * height;
|
|
333
|
+
const rotation = num(el.rotation, 0);
|
|
334
|
+
ctx.save();
|
|
335
|
+
try {
|
|
336
|
+
let box: ElementBox | null = null;
|
|
337
|
+
switch (el.kind) {
|
|
338
|
+
case 'device':
|
|
339
|
+
box = deviceElement(ctx, el, index, scene, cx, cy, rotation, opacity);
|
|
340
|
+
break;
|
|
341
|
+
case 'text':
|
|
342
|
+
box = textElement(ctx, el, scene, cx, cy, rotation, opacity);
|
|
343
|
+
break;
|
|
344
|
+
case 'shape':
|
|
345
|
+
box = shapeElement(ctx, el, scene, cx, cy, rotation, opacity);
|
|
346
|
+
break;
|
|
347
|
+
case 'image':
|
|
348
|
+
box = imageElement(ctx, el, scene, cx, cy, rotation, opacity);
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
box = null;
|
|
352
|
+
}
|
|
353
|
+
if (box) result.boxes.push(box);
|
|
354
|
+
} catch {
|
|
355
|
+
// Skip the element; the rest of the slide still renders.
|
|
356
|
+
} finally {
|
|
357
|
+
ctx.restore();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
} finally {
|
|
361
|
+
ctx.restore();
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
};
|