capture-flow 0.0.0-stage → 0.1.1

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +56 -4
  5. package/skills/capture-flow/SKILL.md +248 -0
  6. package/skills/capture-flow/references/capture-recipes.md +155 -0
  7. package/skills/capture-flow/references/store-sizes.md +91 -0
  8. package/src/build.ts +186 -0
  9. package/src/capture.ts +77 -0
  10. package/src/cli.ts +139 -0
  11. package/src/index.ts +11 -0
  12. package/src/manifest.ts +98 -0
  13. package/src/render.ts +75 -0
  14. package/src/verify.ts +90 -0
  15. package/vendor/wajha/LICENSE +21 -0
  16. package/vendor/wajha/NOTICE +21 -0
  17. package/vendor/wajha/engine/background.ts +55 -0
  18. package/vendor/wajha/engine/bidi.ts +589 -0
  19. package/vendor/wajha/engine/cache.ts +96 -0
  20. package/vendor/wajha/engine/color.ts +125 -0
  21. package/vendor/wajha/engine/devices/android.ts +53 -0
  22. package/vendor/wajha/engine/devices/browser.ts +170 -0
  23. package/vendor/wajha/engine/devices/finish.ts +154 -0
  24. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  25. package/vendor/wajha/engine/devices/index.ts +143 -0
  26. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  27. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  28. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  29. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  31. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  32. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  33. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  34. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  35. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  36. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  37. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  38. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  39. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  40. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  41. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  42. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  43. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  44. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  45. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  46. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  47. package/vendor/wajha/engine/devices/paths.ts +113 -0
  48. package/vendor/wajha/engine/devices/screen.ts +75 -0
  49. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  50. package/vendor/wajha/engine/devices/slab.ts +200 -0
  51. package/vendor/wajha/engine/devices/vision.ts +43 -0
  52. package/vendor/wajha/engine/devices/watch.ts +14 -0
  53. package/vendor/wajha/engine/geometry.ts +152 -0
  54. package/vendor/wajha/engine/images.ts +96 -0
  55. package/vendor/wajha/engine/index.ts +20 -0
  56. package/vendor/wajha/engine/linebreak.ts +226 -0
  57. package/vendor/wajha/engine/markup.ts +84 -0
  58. package/vendor/wajha/engine/mock.ts +158 -0
  59. package/vendor/wajha/engine/paint.ts +474 -0
  60. package/vendor/wajha/engine/patterns.ts +263 -0
  61. package/vendor/wajha/engine/render.ts +364 -0
  62. package/vendor/wajha/engine/shapes.ts +260 -0
  63. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  64. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  65. package/vendor/wajha/engine/text.ts +694 -0
  66. package/vendor/wajha/engine/types.ts +190 -0
  67. package/vendor/wajha/export/paths.ts +197 -0
  68. package/vendor/wajha/export/png.ts +223 -0
  69. package/vendor/wajha/export/types.ts +60 -0
  70. package/vendor/wajha/model/adapt.ts +889 -0
  71. package/vendor/wajha/model/catalog.ts +369 -0
  72. package/vendor/wajha/model/clone.ts +58 -0
  73. package/vendor/wajha/model/colorMath.ts +128 -0
  74. package/vendor/wajha/model/defaults.ts +386 -0
  75. package/vendor/wajha/model/geometry.ts +95 -0
  76. package/vendor/wajha/model/ids.ts +13 -0
  77. package/vendor/wajha/model/instantiate.ts +280 -0
  78. package/vendor/wajha/model/mirror.ts +43 -0
  79. package/vendor/wajha/model/resolve.ts +87 -0
  80. package/vendor/wajha/model/rules.ts +249 -0
  81. package/vendor/wajha/model/stub.ts +4 -0
  82. package/vendor/wajha/model/types.ts +456 -0
  83. package/vendor/wajha/model/typography.ts +28 -0
  84. package/vendor/wajha/model/validate.ts +917 -0
  85. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  86. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  87. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  88. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  89. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  90. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  91. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  92. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  93. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  94. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  95. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  96. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  97. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  98. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  99. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  100. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  101. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  102. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  103. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  104. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  105. package/vendor/wajha/templates/index.ts +55 -0
  106. package/vendor/wajha/templates/kit.ts +761 -0
  107. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Mock screens: deterministic fake apps shown in every device until the
3
+ * user drops in a screenshot. They are what template previews show, so
4
+ * they are drawn like polished product shots: real layouts, soft cards,
5
+ * charts and photos, in the project palette, light or dark.
6
+ *
7
+ * Text is language-neutral (skeleton bars), except digits. Ten app types
8
+ * are picked by seed; phones get a status bar and tab bar, tablets a
9
+ * sidebar split view, desktops (family "mac" or any wide screen) a
10
+ * sidebar + toolbar shell without status bar.
11
+ */
12
+ import type { Color, FamilyId } from '../model/types';
13
+ import type { Ctx2D, DrawMockScreenFn } from './types';
14
+ import { rgba } from './color';
15
+ import { APPS } from './devices/mock/apps';
16
+ import { desktopShell, homeIndicator, statusBar, statusHeight, tabBar, tabBarHeight, tabletShell, webShell } from './devices/mock/chrome';
17
+ import type { Form, Mk } from './devices/mock/kit';
18
+ import { linear } from './devices/mock/kit';
19
+ import { createRng } from './devices/mock/rng';
20
+ import { mockTheme } from './devices/mock/theme';
21
+ import type { MockArchetypeId } from './devices/mock/widgets';
22
+ import { currentScale } from './devices/shadow';
23
+
24
+ export type { MockArchetypeId } from './devices/mock/widgets';
25
+ export type MockLayout = Form;
26
+
27
+ export interface MockScreenOptions {
28
+ seed: number;
29
+ palette: Color[];
30
+ dark: boolean;
31
+ family?: FamilyId;
32
+ /** "web": drawn inside a browser window, so no status bar or window controls. */
33
+ chrome?: 'device' | 'web';
34
+ }
35
+
36
+ /** Point widths the mock is designed at; the context is scaled to the real width. */
37
+ const DESIGN_WIDTH: Record<Form, number> = { phone: 390, tablet: 820, desktop: 1280 };
38
+
39
+ /** Layouts fill at most this many widths of height; anything taller is plain background. */
40
+ const MAX_LAYOUT_ASPECT = 8;
41
+
42
+ const SEED_SPAN = 1_000_000;
43
+
44
+ const ANDROID: ReadonlySet<FamilyId | undefined> = new Set<FamilyId | undefined>(['android-phone', 'android-tablet', 'play-feature']);
45
+
46
+ function seedIndex(seed: number): number {
47
+ const k = Number.isFinite(seed) ? Math.floor(seed) : 0;
48
+ const n = APPS.length;
49
+ return ((k % n) + n) % n;
50
+ }
51
+
52
+ /** Which app a seed shows. Consecutive seeds always show different apps. */
53
+ export function mockArchetype(seed: number): MockArchetypeId {
54
+ return APPS[seedIndex(seed)].id;
55
+ }
56
+
57
+ /** Every app type, in seed order. */
58
+ export const MOCK_ARCHETYPES: readonly MockArchetypeId[] = APPS.map((a) => a.id);
59
+
60
+ /** Phone, tablet split view or desktop shell for a screen size and family. */
61
+ export function mockLayoutMode(width: number, height: number, family?: FamilyId): MockLayout {
62
+ if (family === 'mac' || width > height) return 'desktop';
63
+ if (family === 'ipad' || family === 'android-tablet') return 'tablet';
64
+ if (family === 'iphone' || family === 'android-phone' || family === 'play-feature' || family === 'watch') return 'phone';
65
+ return height / width < 1.6 ? 'tablet' : 'phone';
66
+ }
67
+
68
+ /** Neutralises context state a caller may have left behind, inside the caller's save(). */
69
+ function resetState(ctx: Ctx2D): void {
70
+ ctx.shadowColor = 'rgba(0, 0, 0, 0)';
71
+ ctx.shadowBlur = 0;
72
+ ctx.shadowOffsetX = 0;
73
+ ctx.shadowOffsetY = 0;
74
+ ctx.globalCompositeOperation = 'source-over';
75
+ ctx.lineWidth = 1;
76
+ ctx.lineCap = 'butt';
77
+ ctx.lineJoin = 'miter';
78
+ if (typeof ctx.setLineDash === 'function') ctx.setLineDash([]);
79
+ if ('direction' in ctx) ctx.direction = 'ltr';
80
+ if ('letterSpacing' in ctx) ctx.letterSpacing = '0px';
81
+ }
82
+
83
+ /**
84
+ * Fills (0, 0, width, height) with a mock app. Deterministic for a given
85
+ * seed, palette, appearance, family and size. Restores the context.
86
+ */
87
+ export function paintMockScreen(ctx: Ctx2D, width: number, height: number, opts: MockScreenOptions): void {
88
+ if (!(width > 0) || !(height > 0) || !Number.isFinite(width) || !Number.isFinite(height)) return;
89
+ const form = mockLayoutMode(width, height, opts.family);
90
+ const os = ANDROID.has(opts.family) ? 'android' : 'ios';
91
+ const chrome = opts.chrome ?? 'device';
92
+ const W = DESIGN_WIDTH[form];
93
+ const s = width / W;
94
+ const fullH = height / s;
95
+ // Bounds every fill-to-the-bottom loop, whatever size a caller passes.
96
+ const H = Math.min(fullH, W * MAX_LAYOUT_ASPECT);
97
+ const app = APPS[seedIndex(opts.seed)];
98
+ // Apps index arrays with the seed; keep it a small non-negative integer.
99
+ const k = Number.isFinite(opts.seed) ? Math.floor(opts.seed) : 0;
100
+ const seed = ((k % SEED_SPAN) + SEED_SPAN) % SEED_SPAN;
101
+ const t = mockTheme(Array.isArray(opts.palette) ? opts.palette : [], !!opts.dark);
102
+
103
+ ctx.save();
104
+ resetState(ctx);
105
+ ctx.beginPath();
106
+ ctx.rect(0, 0, width, height);
107
+ ctx.clip();
108
+ ctx.scale(s, s);
109
+ const m: Mk = { ctx, t, rng: createRng(seed * 7 + 1), px: currentScale(ctx), os, form, seed };
110
+ const full = { x: 0, y: 0, w: W, h: H };
111
+ const shell = { nav: app.nav, selected: 0, glyph: app.glyph };
112
+
113
+ ctx.fillStyle = t.bg;
114
+ ctx.fillRect(0, 0, W, fullH);
115
+ if (app.backdrop && form !== 'desktop') app.backdrop(m, W, H);
116
+ const ink = app.ink && form === 'phone' ? app.ink(m) : t.text;
117
+
118
+ if (form === 'desktop') {
119
+ const area = chrome === 'web' ? webShell(m, W, H, shell) : desktopShell(m, W, H, shell, opts.family === 'mac');
120
+ app.wide(m, area);
121
+ } else if (form === 'tablet') {
122
+ const top = chrome === 'web' ? 12 : statusHeight(m);
123
+ const area = tabletShell(m, W, H, top, shell);
124
+ // Keep wide content (halos, full-bleed maps) from spilling over the sidebar.
125
+ ctx.save();
126
+ ctx.beginPath();
127
+ ctx.rect(area.x - 8, 0, W - area.x + 8, H);
128
+ ctx.clip();
129
+ app.narrow(m, { ...area, h: area.h - 24 }, area);
130
+ ctx.restore();
131
+ if (chrome === 'device') {
132
+ statusBar(m, W, ink);
133
+ homeIndicator(m, W, H, t.text);
134
+ }
135
+ } else {
136
+ const top = chrome === 'web' ? 8 : statusHeight(m);
137
+ const bottom = app.tabs ? tabBarHeight(m) : os === 'ios' ? 34 : 24;
138
+ app.narrow(m, { x: 0, y: top, w: W, h: H - top - bottom }, full);
139
+ if (app.tabs) {
140
+ // Scroll-edge effect: content fades out under the tab bar, as on iOS 26 / Material 3.
141
+ const fadeTop = H - tabBarHeight(m) - 30;
142
+ ctx.fillStyle = linear(m, 0, fadeTop, 0, H - 40, [
143
+ [0, rgba(t.bg, 0)],
144
+ [1, rgba(t.bg, 0.97)],
145
+ ]);
146
+ ctx.fillRect(0, fadeTop, W, H - fadeTop);
147
+ tabBar(m, W, H, app.tabs, app.tabSelected);
148
+ }
149
+ if (chrome === 'device') {
150
+ statusBar(m, W, ink);
151
+ homeIndicator(m, W, H, app.tabs ? t.text : ink);
152
+ }
153
+ }
154
+ ctx.restore();
155
+ }
156
+
157
+ /** Contract entry point (see engine/types.ts). */
158
+ export const drawMockScreen: DrawMockScreenFn = (ctx, width, height, opts) => paintMockScreen(ctx, width, height, opts);
@@ -0,0 +1,474 @@
1
+ /**
2
+ * The Fill painter shared by slide backgrounds and shapes. A fill is always
3
+ * painted relative to a box: the slide (or the whole panorama strip) for
4
+ * backgrounds, the shape's own bounds for shapes. `area` only limits how
5
+ * much is actually filled (the visible window), so a gradient or image
6
+ * keeps its geometry no matter which part of the box is on screen.
7
+ */
8
+ import type { Fill } from '../model/types';
9
+ import type { Ctx2D, ImageLike, RenderEnv } from './types';
10
+ import { parseColor, rgba, type RGBA } from './color';
11
+ import { clamp, intersect, linearGradientLine, num, withFastSampling, type Rect } from './geometry';
12
+ import { engineCache, objectId } from './cache';
13
+
14
+ export interface PaintContext {
15
+ env: RenderEnv;
16
+ /** u in pixels; image blur radii are fractions of it. */
17
+ u: number;
18
+ /** Decoded image or null; never throws. */
19
+ getImage(assetId: string): ImageLike | null;
20
+ /** Device pixels per canvas unit (the caller's transform); tiles are rendered at this resolution. */
21
+ pxScale?: number;
22
+ }
23
+
24
+ /** Resolution multiplier for cached tiles, from the context's transform scale. */
25
+ export function tileResolution(pc: { pxScale?: number }): number {
26
+ return clamp(num(pc.pxScale, 1), 0.1, 4);
27
+ }
28
+
29
+ /** Shown where an image fill has no decoded image yet. */
30
+ export const NEUTRAL_FILL = '#e4e6eb';
31
+ /** Tiled image fills repeat every u / 4 so the preview and every export size look the same. */
32
+ export const TILE_FRACTION = 0.25;
33
+
34
+ /** A canvas colour string for any value; bad colours become opaque black like parseColor. */
35
+ export function cssColor(color: unknown, alphaMul = 1): string {
36
+ return rgba(typeof color === 'string' ? color : '#000000', alphaMul);
37
+ }
38
+
39
+ export interface Stop {
40
+ offset: number;
41
+ color: RGBA;
42
+ }
43
+
44
+ /** Valid, sorted gradient stops (offsets clamped to 0..1, at most 64). */
45
+ export function gradientStops(stops: unknown): Stop[] {
46
+ if (!Array.isArray(stops)) return [];
47
+ const out: Stop[] = [];
48
+ for (const s of stops.slice(0, 64)) {
49
+ if (!s || typeof s !== 'object') continue;
50
+ const o = s as Record<string, unknown>;
51
+ out.push({ offset: clamp(num(o.offset, 0), 0, 1), color: parseColor(typeof o.color === 'string' ? o.color : '#000000') });
52
+ }
53
+ // Array sort is stable: equal offsets keep their order (hard colour edges).
54
+ out.sort((a, b) => a.offset - b.offset);
55
+ return fixTransparentStops(out);
56
+ }
57
+
58
+ const sameRgb = (a: RGBA, b: RGBA) => a.r === b.r && a.g === b.g && a.b === b.b;
59
+
60
+ /**
61
+ * Canvas gradients interpolate unpremultiplied colours, so "red -> #0000"
62
+ * would pass through murky grey. CSS interpolates premultiplied; giving a
63
+ * fully transparent stop the colour of its neighbours reproduces that.
64
+ */
65
+ function fixTransparentStops(stops: Stop[]): Stop[] {
66
+ if (!stops.some((s) => s.color.a === 0)) return stops;
67
+ const nearest = (from: number, step: number): Stop | null => {
68
+ for (let i = from + step; i >= 0 && i < stops.length; i += step) if (stops[i].color.a > 0) return stops[i];
69
+ return null;
70
+ };
71
+ const out: Stop[] = [];
72
+ stops.forEach((s, i) => {
73
+ if (s.color.a > 0) {
74
+ out.push(s);
75
+ return;
76
+ }
77
+ const tinted = (c: RGBA): Stop => ({ offset: s.offset, color: { r: c.r, g: c.g, b: c.b, a: 0 } });
78
+ const prev = nearest(i, -1);
79
+ const next = nearest(i, 1);
80
+ if (prev && next && !sameRgb(prev.color, next.color)) out.push(tinted(prev.color), tinted(next.color));
81
+ else {
82
+ const ref = prev ?? next;
83
+ out.push(ref ? tinted(ref.color) : s);
84
+ }
85
+ });
86
+ return out;
87
+ }
88
+
89
+ function addStops(g: CanvasGradient, stops: Stop[]): void {
90
+ for (const s of stops) g.addColorStop(s.offset, rgba(s.color));
91
+ }
92
+
93
+ /** CSS-style linear gradient across `box`, or a plain colour for degenerate input. */
94
+ export function linearGradient(ctx: Ctx2D, angle: number, stops: Stop[], box: Rect): CanvasGradient | string | null {
95
+ if (!stops.length) return null;
96
+ const last = rgba(stops[stops.length - 1].color);
97
+ if (stops.length === 1) return last;
98
+ const l = linearGradientLine(angle, box);
99
+ if (![l.x0, l.y0, l.x1, l.y1].every(Number.isFinite) || (Math.abs(l.x1 - l.x0) < 1e-9 && Math.abs(l.y1 - l.y0) < 1e-9)) return last;
100
+ const g = ctx.createLinearGradient(l.x0, l.y0, l.x1, l.y1);
101
+ addStops(g, stops);
102
+ return g;
103
+ }
104
+
105
+ /** Radial gradient: centre in box fractions, radius a fraction of the box's longer side. */
106
+ export function radialGradient(ctx: Ctx2D, cx: number, cy: number, radius: number, stops: Stop[], box: Rect): CanvasGradient | string | null {
107
+ if (!stops.length) return null;
108
+ const last = rgba(stops[stops.length - 1].color);
109
+ if (stops.length === 1) return last;
110
+ const x = box.x + clamp(num(cx, 0.5), -100, 100) * box.w;
111
+ const y = box.y + clamp(num(cy, 0.5), -100, 100) * box.h;
112
+ const r = Math.max(0, num(radius, 0.5)) * Math.max(box.w, box.h);
113
+ // Like CSS, a zero-size radial gradient shows its last colour.
114
+ if (!(r > 1e-3) || !Number.isFinite(r) || !Number.isFinite(x) || !Number.isFinite(y)) return last;
115
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
116
+ addStops(g, stops);
117
+ return g;
118
+ }
119
+
120
+ /** A single canvas fill style for solid, linear and radial fills; null for mesh, image and invalid fills. */
121
+ export function simpleFillStyle(ctx: Ctx2D, fill: Fill | null | undefined, box: Rect): CanvasGradient | string | null {
122
+ if (!fill || typeof fill !== 'object') return null;
123
+ switch (fill.type) {
124
+ case 'solid':
125
+ return cssColor(fill.color);
126
+ case 'linear':
127
+ return linearGradient(ctx, fill.angle, gradientStops(fill.stops), box);
128
+ case 'radial':
129
+ return radialGradient(ctx, fill.cx, fill.cy, fill.radius, gradientStops(fill.stops), box);
130
+ default:
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /** Whether painting the fill produces anything (unknown kinds and stop-less gradients do not). */
136
+ export function isPaintable(fill: Fill | null | undefined): boolean {
137
+ if (!fill || typeof fill !== 'object') return false;
138
+ switch (fill.type) {
139
+ case 'solid':
140
+ case 'mesh':
141
+ case 'image':
142
+ return true;
143
+ case 'linear':
144
+ case 'radial':
145
+ return gradientStops(fill.stops).length > 0;
146
+ default:
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /** A representative colour of a fill (glow colour for solid fills, previews). */
152
+ export function fillColor(fill: Fill | null | undefined): string {
153
+ if (!fill || typeof fill !== 'object') return '#000000';
154
+ switch (fill.type) {
155
+ case 'solid':
156
+ return typeof fill.color === 'string' ? fill.color : '#000000';
157
+ case 'linear':
158
+ case 'radial': {
159
+ const stops = gradientStops(fill.stops);
160
+ return stops.length ? rgba(stops[0].color) : '#000000';
161
+ }
162
+ case 'mesh':
163
+ return typeof fill.base === 'string' ? fill.base : '#000000';
164
+ default:
165
+ return NEUTRAL_FILL;
166
+ }
167
+ }
168
+
169
+ /* ------------------------------------------------------------------ */
170
+ /* Mesh */
171
+ /* ------------------------------------------------------------------ */
172
+
173
+ const MESH_STOPS = 12;
174
+ /**
175
+ * Alpha profile of a mesh blob: 1 - smoothstep(t). Flat at the centre and
176
+ * at the rim, so blobs melt into each other without a visible edge, and a
177
+ * dozen stops keep the piecewise-linear gradient from banding.
178
+ */
179
+ const MESH_FALLOFF = Array.from({ length: MESH_STOPS + 1 }, (_, k) => {
180
+ const t = k / MESH_STOPS;
181
+ return 1 - t * t * (3 - 2 * t);
182
+ });
183
+
184
+ /** Rendered meshes: smooth, low-frequency images, so a few hundred pixels hold all the detail. */
185
+ const meshes = engineCache<string, ImageLike>(32, 6 * 1024 * 1024);
186
+ const MESH_MAX_SIDE = 384;
187
+ /** Extra scratch pixels around the window, so upscaling never samples past the bitmap edge. */
188
+ const MESH_MARGIN = 2;
189
+
190
+ /**
191
+ * Mesh fills cost one full-area radial gradient per point. They are drawn
192
+ * once into a small cached bitmap and scaled up each frame (identical
193
+ * looking, since the content is all soft gradients), which keeps editor
194
+ * repaints cheap even on CPU-rasterised canvases.
195
+ */
196
+ function paintMesh(ctx: Ctx2D, fill: Extract<Fill, { type: 'mesh' }>, box: Rect, area: Rect, env: RenderEnv): void {
197
+ const s = Math.min(1, MESH_MAX_SIDE / Math.max(area.w, area.h));
198
+ const m = MESH_MARGIN / s; // margin in canvas units
199
+ const outer: Rect = { x: area.x - m, y: area.y - m, w: area.w + 2 * m, h: area.h + 2 * m };
200
+ const sw = Math.max(1, Math.ceil(outer.w * s));
201
+ const sh = Math.max(1, Math.ceil(outer.h * s));
202
+ const f = (v: number) => v.toFixed(2);
203
+ const key = `${JSON.stringify(fill)}|${f(box.x - area.x)},${f(box.y - area.y)},${f(box.w)},${f(box.h)}|${f(area.w)}x${f(area.h)}|${sw}x${sh}`;
204
+ let image = meshes.get(key);
205
+ if (!image) {
206
+ try {
207
+ const made = env.createCanvas(sw, sh);
208
+ made.ctx.scale(sw / outer.w, sh / outer.h);
209
+ made.ctx.translate(-outer.x, -outer.y);
210
+ paintMeshDirect(made.ctx, fill, box, outer);
211
+ image = made.canvas;
212
+ meshes.set(key, image, sw * sh * 4);
213
+ } catch {
214
+ paintMeshDirect(ctx, fill, box, area);
215
+ return;
216
+ }
217
+ }
218
+ const img = image;
219
+ withFastSampling(ctx, () => ctx.drawImage(img, outer.x, outer.y, outer.w, outer.h));
220
+ }
221
+
222
+ function paintMeshDirect(ctx: Ctx2D, fill: Extract<Fill, { type: 'mesh' }>, box: Rect, area: Rect): void {
223
+ ctx.fillStyle = cssColor(fill.base);
224
+ ctx.fillRect(area.x, area.y, area.w, area.h);
225
+ const points: unknown[] = Array.isArray(fill.points) ? fill.points.slice(0, 32) : [];
226
+ const maxDim = Math.max(box.w, box.h);
227
+ for (const p of points) {
228
+ if (!p || typeof p !== 'object') continue;
229
+ const o = p as Record<string, unknown>;
230
+ const x = box.x + num(o.x, 0.5) * box.w;
231
+ const y = box.y + num(o.y, 0.5) * box.h;
232
+ const r = Math.min(Math.max(0, num(o.radius, 0)), 50) * maxDim;
233
+ if (!(r >= 0.5) || !Number.isFinite(x) || !Number.isFinite(y)) continue;
234
+ const region = intersect({ x: x - r, y: y - r, w: 2 * r, h: 2 * r }, area);
235
+ if (!region) continue;
236
+ const c = parseColor(typeof o.color === 'string' ? o.color : '#000000');
237
+ if (c.a <= 0) continue;
238
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
239
+ for (let k = 0; k <= MESH_STOPS; k++) g.addColorStop(k / MESH_STOPS, rgba(c, MESH_FALLOFF[k]));
240
+ ctx.fillStyle = g;
241
+ ctx.fillRect(region.x, region.y, region.w, region.h);
242
+ }
243
+ }
244
+
245
+ /* ------------------------------------------------------------------ */
246
+ /* Images */
247
+ /* ------------------------------------------------------------------ */
248
+
249
+ function safeImage(pc: PaintContext, assetId: unknown): ImageLike | null {
250
+ if (typeof assetId !== 'string' || !assetId) return null;
251
+ try {
252
+ const img = pc.getImage(assetId);
253
+ return img && img.width > 0 && img.height > 0 ? img : null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /** Blurred / resampled images: small (blur hides detail), keyed by image instance and size. */
260
+ const processed = engineCache<string, ImageLike>(24, 12 * 1024 * 1024);
261
+
262
+ /** Blur is computed at the resolution where the Gaussian sigma is about this many pixels. */
263
+ const WORK_SIGMA = 2.5;
264
+ const MAX_WORK_PIXELS = 400_000;
265
+
266
+ /**
267
+ * The image resampled for drawing at `w` x `h` and blurred by `blurPx`
268
+ * (shadowBlur semantics: Gaussian sigma = blurPx / 2). Blurred images are
269
+ * built at reduced resolution, blurred with a 3-pass box filter (an
270
+ * accurate Gaussian), then drawn scaled up; `wrap` blurs seamlessly for
271
+ * tiles. Returns null when nothing better than the source can be made.
272
+ */
273
+ export function processedImage(img: ImageLike, w: number, h: number, blurPx: number, wrap: boolean, env: RenderEnv): ImageLike | null {
274
+ if (!(w >= 1) || !(h >= 1) || !Number.isFinite(w * h)) return null;
275
+ const sigma = Math.max(0, num(blurPx, 0)) / 2;
276
+ let s = sigma > 0 ? Math.min(1, WORK_SIGMA / sigma) : 1;
277
+ const cap = sigma > 0 ? MAX_WORK_PIXELS : MAX_WORK_PIXELS * 4;
278
+ if (w * h * s * s > cap) s = Math.sqrt(cap / (w * h));
279
+ const sw = Math.max(1, Math.round(w * s));
280
+ const sh = Math.max(1, Math.round(h * s));
281
+ const workSigma = Math.round(sigma * s * 100) / 100;
282
+ const key = `${objectId(img)}|${sw}x${sh}|${workSigma}|${wrap ? 1 : 0}`;
283
+ const hit = processed.get(key);
284
+ if (hit) return hit;
285
+ let out: { canvas: ImageLike; ctx: Ctx2D };
286
+ try {
287
+ out = env.createCanvas(sw, sh);
288
+ } catch {
289
+ return null;
290
+ }
291
+ const g = out.ctx;
292
+ g.imageSmoothingEnabled = true;
293
+ g.imageSmoothingQuality = 'high';
294
+ try {
295
+ g.drawImage(img, 0, 0, sw, sh);
296
+ } catch {
297
+ // A closed or broken bitmap (e.g. evicted by the image cache mid-render).
298
+ return null;
299
+ }
300
+ if (workSigma >= 0.3) {
301
+ try {
302
+ const data = g.getImageData(0, 0, sw, sh);
303
+ gaussianBlur(data.data, sw, sh, workSigma, wrap);
304
+ g.putImageData(data, 0, 0);
305
+ } catch {
306
+ // Tainted or unsupported: the downscale alone still softens the image.
307
+ }
308
+ }
309
+ processed.set(key, out.canvas, sw * sh * 4);
310
+ return out.canvas;
311
+ }
312
+
313
+ /** Box widths whose 3 passes approximate a Gaussian of `sigma` (Kovesi / Kutskir). */
314
+ export function boxesForGauss(sigma: number, n = 3): number[] {
315
+ const wIdeal = Math.sqrt((12 * sigma * sigma) / n + 1);
316
+ let wl = Math.floor(wIdeal);
317
+ if (wl % 2 === 0) wl--;
318
+ const wu = wl + 2;
319
+ const m = Math.round((12 * sigma * sigma - n * wl * wl - 4 * n * wl - 3 * n) / (-4 * wl - 4));
320
+ return Array.from({ length: n }, (_, i) => Math.max(1, i < m ? wl : wu));
321
+ }
322
+
323
+ /** In-place Gaussian blur of RGBA pixels (premultiplied while blurring, so edges do not darken). */
324
+ export function gaussianBlur(data: Uint8ClampedArray, w: number, h: number, sigma: number, wrap: boolean): void {
325
+ const n = w * h;
326
+ if (n === 0 || !(sigma > 0)) return;
327
+ const a = new Float32Array(n * 4);
328
+ const b = new Float32Array(n * 4);
329
+ for (let i = 0; i < n * 4; i += 4) {
330
+ const al = data[i + 3] / 255;
331
+ a[i] = data[i] * al;
332
+ a[i + 1] = data[i + 1] * al;
333
+ a[i + 2] = data[i + 2] * al;
334
+ a[i + 3] = data[i + 3];
335
+ }
336
+ for (const size of boxesForGauss(sigma)) {
337
+ const r = (size - 1) >> 1;
338
+ boxPass(a, b, w, h, r, true, wrap);
339
+ boxPass(b, a, w, h, r, false, wrap);
340
+ }
341
+ for (let i = 0; i < n * 4; i += 4) {
342
+ const al = a[i + 3];
343
+ if (al < 0.5) {
344
+ data[i] = data[i + 1] = data[i + 2] = data[i + 3] = 0;
345
+ continue;
346
+ }
347
+ const k = 255 / al;
348
+ data[i] = a[i] * k;
349
+ data[i + 1] = a[i + 1] * k;
350
+ data[i + 2] = a[i + 2] * k;
351
+ data[i + 3] = al;
352
+ }
353
+ }
354
+
355
+ function boxPass(src: Float32Array, dst: Float32Array, w: number, h: number, r: number, horizontal: boolean, wrap: boolean): void {
356
+ if (r <= 0) {
357
+ dst.set(src);
358
+ return;
359
+ }
360
+ const len = horizontal ? w : h;
361
+ const lines = horizontal ? h : w;
362
+ const step = horizontal ? 4 : w * 4;
363
+ const lineStep = horizontal ? w * 4 : 4;
364
+ const inv = 1 / (2 * r + 1);
365
+ const at = wrap ? (k: number) => ((k % len) + len) % len : (k: number) => (k < 0 ? 0 : k >= len ? len - 1 : k);
366
+ for (let line = 0; line < lines; line++) {
367
+ const base = line * lineStep;
368
+ for (let c = 0; c < 4; c++) {
369
+ let acc = 0;
370
+ for (let k = -r; k <= r; k++) acc += src[base + at(k) * step + c];
371
+ for (let i = 0; i < len; i++) {
372
+ dst[base + i * step + c] = acc * inv;
373
+ acc += src[base + at(i + r + 1) * step + c] - src[base + at(i - r) * step + c];
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ function paintImage(ctx: Ctx2D, fill: Extract<Fill, { type: 'image' }>, box: Rect, pc: PaintContext, area: Rect): void {
380
+ const img = safeImage(pc, fill.assetId);
381
+ if (!img) {
382
+ ctx.fillStyle = NEUTRAL_FILL;
383
+ ctx.fillRect(area.x, area.y, area.w, area.h);
384
+ } else {
385
+ const opacity = clamp(num(fill.opacity, 1), 0, 1);
386
+ const blurPx = Math.max(0, num(fill.blur, 0)) * pc.u;
387
+ if (opacity > 0) {
388
+ // drawImage throws for closed bitmaps; never leave the state stack unbalanced.
389
+ ctx.save();
390
+ try {
391
+ ctx.globalAlpha *= opacity;
392
+ if (fill.fit === 'tile') paintTiled(ctx, img, box, area, pc, blurPx);
393
+ else {
394
+ const scale = fill.fit === 'contain' ? Math.min(box.w / img.width, box.h / img.height) : Math.max(box.w / img.width, box.h / img.height);
395
+ const dw = img.width * scale;
396
+ const dh = img.height * scale;
397
+ if (dw > 0 && dh > 0 && Number.isFinite(dw * dh)) {
398
+ const src = blurPx >= 0.5 ? (processedImage(img, dw, dh, blurPx, false, pc.env) ?? img) : img;
399
+ // A small blurred bitmap scaled up can fade at its outermost pixels in some engines;
400
+ // overscan by one of its pixels (invisible on a blurred image) so edges stay solid.
401
+ const ex = src === img ? 0 : dw / src.width;
402
+ const ey = src === img ? 0 : dh / src.height;
403
+ const draw = () => ctx.drawImage(src, box.x + (box.w - dw) / 2 - ex, box.y + (box.h - dh) / 2 - ey, dw + 2 * ex, dh + 2 * ey);
404
+ // Sharp photos keep the high-quality filter (they are usually downscaled); blurred ones do not need it.
405
+ if (src === img) draw();
406
+ else withFastSampling(ctx, draw);
407
+ }
408
+ }
409
+ } finally {
410
+ ctx.restore();
411
+ }
412
+ }
413
+ }
414
+ if (typeof fill.tint === 'string' && fill.tint) {
415
+ ctx.fillStyle = cssColor(fill.tint);
416
+ ctx.fillRect(area.x, area.y, area.w, area.h);
417
+ }
418
+ }
419
+
420
+ function paintTiled(ctx: Ctx2D, img: ImageLike, box: Rect, area: Rect, pc: PaintContext, blurPx: number): void {
421
+ const tw = Math.max(1, TILE_FRACTION * pc.u);
422
+ const th = (tw * img.height) / img.width;
423
+ // Rendered at device resolution, so scaled contexts (thumbnails, DPR transforms) stay crisp.
424
+ const res = tileResolution(pc);
425
+ const tileW = Math.max(1, Math.round(tw * res));
426
+ const tileH = Math.max(1, Math.round(th * res));
427
+ // Resampling once to the tile size keeps every frame from filtering a full-size image.
428
+ const tile = processedImage(img, tileW, tileH, blurPx * res, true, pc.env) ?? img;
429
+ const pattern = ctx.createPattern(tile, 'repeat');
430
+ if (!pattern) return;
431
+ const sx = tw / tile.width;
432
+ const sy = th / tile.height;
433
+ ctx.save();
434
+ try {
435
+ ctx.translate(box.x, box.y);
436
+ ctx.scale(sx, sy);
437
+ ctx.fillStyle = pattern;
438
+ ctx.imageSmoothingQuality = 'low';
439
+ ctx.fillRect((area.x - box.x) / sx, (area.y - box.y) / sy, area.w / sx, area.h / sy);
440
+ } finally {
441
+ ctx.restore();
442
+ }
443
+ }
444
+
445
+ /* ------------------------------------------------------------------ */
446
+ /* Entry point */
447
+ /* ------------------------------------------------------------------ */
448
+
449
+ /**
450
+ * Paints `fill` laid out over `box`, filling only `area` (defaults to the
451
+ * box). Unknown fill kinds paint nothing.
452
+ */
453
+ export function paintFill(ctx: Ctx2D, fill: Fill | null | undefined, box: Rect, pc: PaintContext, area: Rect = box): void {
454
+ if (!fill || typeof fill !== 'object' || !(area.w > 0) || !(area.h > 0)) return;
455
+ switch (fill.type) {
456
+ case 'solid':
457
+ case 'linear':
458
+ case 'radial': {
459
+ const style = simpleFillStyle(ctx, fill, box);
460
+ if (style === null) return;
461
+ ctx.fillStyle = style;
462
+ ctx.fillRect(area.x, area.y, area.w, area.h);
463
+ return;
464
+ }
465
+ case 'mesh':
466
+ paintMesh(ctx, fill, box, area, pc.env);
467
+ return;
468
+ case 'image':
469
+ paintImage(ctx, fill, box, pc, area);
470
+ return;
471
+ default:
472
+ return;
473
+ }
474
+ }