@arraypress/covers 1.0.0 → 1.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/CHANGELOG.md +76 -0
- package/README.md +296 -11
- package/package.json +33 -5
- package/recipes/brand.json +1143 -0
- package/schema/recipe.schema.json +7300 -770
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/builtins.js +4 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +4 -3
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +246 -53
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +1 -1
- package/src/icons.js +43 -9
- package/src/index.d.ts +205 -19
- package/src/index.js +12 -4
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +181 -23
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +23 -4
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +125 -41
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +77 -28
- package/src/print.js +8 -7
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +4 -3
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +303 -212
- package/src/text.d.ts +29 -0
- package/src/text.js +22 -15
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blur, shadow, glow and die-cut borders.
|
|
3
|
+
*
|
|
4
|
+
* @module effects/light
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { num } from '../svg.js';
|
|
8
|
+
import { len } from '../geometry.js';
|
|
9
|
+
import { flood } from './util.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `blur`: `radius`, or `x` and `y` for an axis-aligned blur.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('./index.js').Effect & { kind: 'blur' }} fx
|
|
15
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function blur(fx, ctx) {
|
|
19
|
+
const sd = fx.x != null || fx.y != null ? `${num(len(fx.x, ctx.canvas, 0))} ${num(len(fx.y, ctx.canvas, 0))}` : num(len(fx.radius, ctx.canvas, '1vmin'));
|
|
20
|
+
return `<feGaussianBlur stdDeviation="${sd}"${fx.edge === 'duplicate' ? ' edgeMode="duplicate"' : ''}/>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `shadow` and `glow`: the alpha, tinted, blurred and offset under the source.
|
|
25
|
+
*
|
|
26
|
+
* @param {import('./index.js').Effect & { kind: 'shadow' | 'glow' }} fx
|
|
27
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function shadow(fx, ctx) {
|
|
31
|
+
const glow = fx.kind === 'glow';
|
|
32
|
+
const op = fx.opacity ?? (glow ? 0.6 : 0.35);
|
|
33
|
+
const spread = fx.spread ? `<feMorphology operator="dilate" radius="${num(len(fx.spread, ctx.canvas))}"/>` : '';
|
|
34
|
+
const dx = glow ? 0 : len(fx.x, ctx.canvas, 0);
|
|
35
|
+
const dy = glow ? 0 : len(fx.y, ctx.canvas, '0.8vmin');
|
|
36
|
+
return `<feColorMatrix in="SourceAlpha" type="matrix" values="${flood(fx.color ?? (glow ? '#ffffff' : '#000000'), [0, 0, 0, op, 0])}"/>${spread}<feGaussianBlur stdDeviation="${num(len(fx.blur, ctx.canvas, glow ? '1.2vmin' : '1.5vmin'))}"/><feOffset dx="${num(dx)}" dy="${num(dy)}" result="s"/><feMerge><feMergeNode in="s"/><feMergeNode in="SourceGraphic"/></feMerge>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `border`: a die-cut sticker border round the alpha, and an optional cut
|
|
41
|
+
* line outside it.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('./index.js').Effect & { kind: 'border' }} fx
|
|
44
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function border(fx, ctx) {
|
|
48
|
+
const w = len(fx.width, ctx.canvas, '1.2vmin');
|
|
49
|
+
/** @param {number} r @param {string} c @param {string} name */
|
|
50
|
+
const ring = (r, c, name) => `<feMorphology in="SourceAlpha" operator="dilate" radius="${num(r)}" result="${name}d"/><feColorMatrix in="${name}d" type="matrix" values="${flood(c, [0, 0, 0, 1, 0])}" result="${name}"/>`;
|
|
51
|
+
let out = '';
|
|
52
|
+
let merge = '';
|
|
53
|
+
if (fx.line) {
|
|
54
|
+
out += ring(w + len(fx.lineWidth, ctx.canvas, '0.25vmin'), fx.line === true ? '#c9c6c0' : fx.line, 'ln');
|
|
55
|
+
merge += '<feMergeNode in="ln"/>';
|
|
56
|
+
}
|
|
57
|
+
out += ring(w, fx.color ?? '#ffffff', 'bd');
|
|
58
|
+
return `${out}<feMerge>${merge}<feMergeNode in="bd"/><feMergeNode in="SourceGraphic"/></feMerge>`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Graded presets.
|
|
2
|
+
import type { Effect } from './index.js';
|
|
3
|
+
|
|
4
|
+
/** Preset names: fade, crossProcess, bleachBypass, tealOrange, warmFilm, coolFilm, noir. */
|
|
5
|
+
export const LOOK_NAMES: readonly LookName[];
|
|
6
|
+
/** A look's name. */
|
|
7
|
+
export type LookName = 'fade' | 'crossProcess' | 'bleachBypass' | 'tealOrange' | 'warmFilm' | 'coolFilm' | 'noir';
|
|
8
|
+
/** The `look` effect's filter primitives. */
|
|
9
|
+
export function look(fx: Extract<Effect, { kind: 'look' }>): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `look`: graded presets, built from the grading filters, `amount` of the
|
|
3
|
+
* way from the original (0–1): `fade` (lifted blacks, softened whites),
|
|
4
|
+
* `crossProcess` (slide film in negative chemistry: yellow highlights,
|
|
5
|
+
* cyan-blue shadows, hard contrast), `bleachBypass` (silver kept in:
|
|
6
|
+
* desaturated, harsh, metallic), `tealOrange` (warm skin against cool
|
|
7
|
+
* shadows), `warmFilm` and `coolFilm` (balanced stock, gently faded), and
|
|
8
|
+
* `noir` (hard black and white).
|
|
9
|
+
*
|
|
10
|
+
* @module effects/looks
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { num } from '../svg.js';
|
|
14
|
+
import { curveTable, transfer, balanceTables } from './grade.js';
|
|
15
|
+
import { LUMA, mat } from './util.js';
|
|
16
|
+
|
|
17
|
+
/** @param {number} v @returns {string} */
|
|
18
|
+
const saturate = (v) => `<feColorMatrix type="saturate" values="${num(v, 3)}"/>`;
|
|
19
|
+
|
|
20
|
+
/** @param {[number, number][]} pts @returns {number[]} */
|
|
21
|
+
const curve = (pts) => curveTable(pts, 96);
|
|
22
|
+
|
|
23
|
+
/** A curve on all three channels. @param {[number, number][]} pts @returns {string} */
|
|
24
|
+
const tone = (pts) => { const t = curve(pts); return transfer([t, t, t]); };
|
|
25
|
+
|
|
26
|
+
/** A table per channel. @param {[number, number][][]} rgbPts @returns {string} */
|
|
27
|
+
const split = (rgbPts) => transfer(rgbPts.map(curve));
|
|
28
|
+
|
|
29
|
+
/** A colour balance. @param {Parameters<typeof balanceTables>[0]} z @returns {string} */
|
|
30
|
+
const balance = (z) => transfer(balanceTables(z).map((t) => t));
|
|
31
|
+
|
|
32
|
+
/** The presets, as filter primitives. @type {Record<string, () => string>} */
|
|
33
|
+
const LOOKS = {
|
|
34
|
+
fade: () => tone([[0, 0.11], [0.25, 0.3], [0.75, 0.76], [1, 0.93]]) + saturate(0.82),
|
|
35
|
+
crossProcess: () => split([
|
|
36
|
+
[[0, 0.04], [0.25, 0.15], [0.75, 0.9], [1, 1]],
|
|
37
|
+
[[0, 0], [0.25, 0.17], [0.75, 0.9], [1, 1]],
|
|
38
|
+
[[0, 0.2], [0.5, 0.46], [1, 0.66]],
|
|
39
|
+
]) + saturate(1.12),
|
|
40
|
+
bleachBypass: () => `<feColorMatrix in="SourceGraphic" type="matrix" values="${mat([...LUMA, 0, 0, ...LUMA, 0, 0, ...LUMA, 0, 0, 0, 0, 0, 1, 0])}" result="lkg"/>`
|
|
41
|
+
+ '<feBlend in="lkg" in2="SourceGraphic" mode="overlay" result="lko"/>'
|
|
42
|
+
+ '<feComposite in="lko" in2="lkg" operator="arithmetic" k2="0.55" k3="0.45"/>'
|
|
43
|
+
+ tone([[0, 0], [0.22, 0.14], [0.5, 0.5], [0.8, 0.88], [1, 1]]),
|
|
44
|
+
tealOrange: () => tone([[0, 0.02], [0.25, 0.21], [0.75, 0.8], [1, 0.98]])
|
|
45
|
+
+ balance({ shadows: [-0.32, 0.06, 0.3], midtones: [0.06, 0, -0.08], highlights: [0.26, 0.06, -0.3] })
|
|
46
|
+
+ saturate(1.12),
|
|
47
|
+
warmFilm: () => tone([[0, 0.06], [0.3, 0.3], [0.7, 0.74], [1, 0.95]])
|
|
48
|
+
+ balance({ shadows: [0.06, 0.02, -0.06], midtones: [0.1, 0.03, -0.12], highlights: [0.08, 0.04, -0.12] })
|
|
49
|
+
+ saturate(0.92),
|
|
50
|
+
coolFilm: () => tone([[0, 0.06], [0.3, 0.29], [0.7, 0.73], [1, 0.95]])
|
|
51
|
+
+ balance({ shadows: [-0.1, 0.04, 0.14], midtones: [-0.08, 0.01, 0.1], highlights: [-0.04, 0.02, 0.06] })
|
|
52
|
+
+ saturate(0.85),
|
|
53
|
+
noir: () => `<feColorMatrix type="matrix" values="${mat([0.3, 0.6, 0.1, 0, 0, 0.3, 0.6, 0.1, 0, 0, 0.3, 0.6, 0.1, 0, 0, 0, 0, 0, 1, 0])}"/>`
|
|
54
|
+
+ tone([[0, 0], [0.18, 0.05], [0.45, 0.4], [0.75, 0.85], [1, 1]]),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Preset names. */
|
|
58
|
+
export const LOOK_NAMES = Object.freeze(Object.keys(LOOKS));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `look`: a preset grade (`name`), `amount` of the way (default 1).
|
|
62
|
+
*
|
|
63
|
+
* @param {import('./index.js').Effect & { kind: 'look' }} fx
|
|
64
|
+
* @returns {string}
|
|
65
|
+
* @throws {TypeError} On an unknown name.
|
|
66
|
+
*/
|
|
67
|
+
export function look(fx) {
|
|
68
|
+
const name = fx.name ?? 'fade';
|
|
69
|
+
if (!Object.hasOwn(LOOKS, name)) throw new TypeError(`Unknown look "${name}" (${LOOK_NAMES.join(', ')})`);
|
|
70
|
+
const a = fx.amount ?? 1;
|
|
71
|
+
const body = LOOKS[name]();
|
|
72
|
+
if (a >= 1) return body;
|
|
73
|
+
return `${body}<feOffset dx="0" dy="0" result="lk"/><feComposite in="lk" in2="SourceGraphic" operator="arithmetic" k2="${num(a, 3)}" k3="${num(1 - a, 3)}"/>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Where an effect applies: a gradient, or layers.
|
|
2
|
+
import type { Length, Layer } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/** A point as fractions of the canvas. */
|
|
5
|
+
type Point = [number, number] | { x: number; y: number };
|
|
6
|
+
|
|
7
|
+
/** Options every mask form takes. */
|
|
8
|
+
interface MaskCommon {
|
|
9
|
+
/** Swap where the effect applies and where it doesn't. */
|
|
10
|
+
invert?: boolean;
|
|
11
|
+
/** A smooth step between none and full (default true); false is linear. */
|
|
12
|
+
ease?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Where an effect applies, from none (0) to full (1). */
|
|
16
|
+
export type EffectMask =
|
|
17
|
+
| (MaskCommon & { gradient: 'linear'; from?: Point; to?: Point })
|
|
18
|
+
| (MaskCommon & { gradient: 'radial'; center?: Point; from?: Length; to?: Length; aspect?: number })
|
|
19
|
+
| (MaskCommon & { gradient: 'band'; center?: Point; angle?: number; from?: Length; to?: Length })
|
|
20
|
+
| (MaskCommon & { layers: Layer[] });
|
|
21
|
+
|
|
22
|
+
/** A mask image placed over a filter's region. */
|
|
23
|
+
export interface MaskImage {
|
|
24
|
+
href: string;
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The gradient forms. */
|
|
32
|
+
export const MASK_GRADIENTS: readonly ('linear' | 'radial' | 'band')[];
|
|
33
|
+
/** Where a resolved layer mask is kept on a mask (its markup, and an image where one could be drawn). */
|
|
34
|
+
export const RESOLVED: unique symbol;
|
|
35
|
+
/** Check a mask, throwing a readable error. */
|
|
36
|
+
export function checkMask(spec: unknown, where?: string): void;
|
|
37
|
+
/** A gradient mask as a function of a point in the layer's units, 0–1. */
|
|
38
|
+
export function maskField(spec: EffectMask, ctx: { width: number; height: number; canvas: { width: number; height: number } }): (x: number, y: number) => number;
|
|
39
|
+
/** A mask on a grid over a box of the layer, a byte a cell; undefined where a layer mask can't be drawn. */
|
|
40
|
+
export function maskGrid(spec: EffectMask, ctx: object, box: { x: number; y: number; width: number; height: number }, cols: number, rows: number, angle?: number): Promise<Uint8Array | undefined>;
|
|
41
|
+
/** A gradient mask on a grid (synchronous). */
|
|
42
|
+
export function gradientGrid(spec: EffectMask, ctx: object, box: { x: number; y: number; width: number; height: number }, cols: number, rows: number, angle?: number): Uint8Array;
|
|
43
|
+
/** The mask as a small greyscale image over a filter's region. */
|
|
44
|
+
export function maskImage(spec: EffectMask, ctx: object, pad: number, angle?: number): Promise<MaskImage | undefined>;
|
|
45
|
+
/** A gradient mask as an image, synchronously. */
|
|
46
|
+
export function gradientImage(spec: EffectMask, ctx: object, pad: number, angle?: number): MaskImage;
|
|
47
|
+
/** Filter primitives mixing the chain's result with its source through a mask image. */
|
|
48
|
+
export function lerpPrimitives(image: MaskImage): string;
|
|
49
|
+
/** Mix pixels before and after an effect through a mask, in place on `out`. */
|
|
50
|
+
export function lerpPixels(src: Uint8ClampedArray, out: Uint8ClampedArray, m: Uint8Array): Uint8ClampedArray;
|
|
51
|
+
/** The effect drawn over the layer through an SVG mask. */
|
|
52
|
+
export function maskOverlay(before: string, after: { defs: string; body: string }, spec: EffectMask, ctx: object, id: string): { defs: string; body: string };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mask` on any effect: where it applies, from none (0) to full (1).
|
|
3
|
+
*
|
|
4
|
+
* - `{ gradient: 'linear', from: [x, y], to: [x, y] }`: none at `from`,
|
|
5
|
+
* full at `to` (fractions of the canvas).
|
|
6
|
+
* - `{ gradient: 'radial', center, from, to, aspect }`: none inside the
|
|
7
|
+
* radius `from`, full beyond `to` (lengths): a sharp face, the rest
|
|
8
|
+
* blurred.
|
|
9
|
+
* - `{ gradient: 'band', center, angle, from, to }`: none within `from` of
|
|
10
|
+
* a line through `center` at `angle`, full beyond `to` (tilt-shift).
|
|
11
|
+
* - `{ layers: [...] }`: the luminance of layers drawn (white: full).
|
|
12
|
+
*
|
|
13
|
+
* Every form takes `invert` and `ease` (a smooth step, default true).
|
|
14
|
+
* The result is an exact mix of the layer before and after the effect:
|
|
15
|
+
* inside the filter for filter effects (the mask is a small greyscale
|
|
16
|
+
* image), per pixel for pixel effects. A layer mask that can't be drawn
|
|
17
|
+
* (a Worker) draws the effect over the layer through an SVG mask.
|
|
18
|
+
*
|
|
19
|
+
* @module effects/mask
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { attrs, num } from '../svg.js';
|
|
23
|
+
import { len } from '../geometry.js';
|
|
24
|
+
import { encodePng, pngUri } from '../png.js';
|
|
25
|
+
import { pixelsOf } from '../raster/index.js';
|
|
26
|
+
import { fraction, clamp01 } from './buffer.js';
|
|
27
|
+
|
|
28
|
+
/** The gradient forms. */
|
|
29
|
+
export const MASK_GRADIENTS = Object.freeze(['linear', 'radial', 'band']);
|
|
30
|
+
|
|
31
|
+
/** Where a resolved layer mask is kept on an effect (markup, and a raster where one could be drawn). */
|
|
32
|
+
export const RESOLVED = Symbol('covers.mask');
|
|
33
|
+
|
|
34
|
+
/** Longest side of a gradient mask image, px (it scales smoothly). */
|
|
35
|
+
const GRADIENT_PX = 256;
|
|
36
|
+
|
|
37
|
+
/** Longest side of a layer mask image, px. */
|
|
38
|
+
const LAYER_PX = 1024;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check a mask, throwing a readable error.
|
|
42
|
+
*
|
|
43
|
+
* @param {any} spec
|
|
44
|
+
* @param {string} [where='mask']
|
|
45
|
+
* @returns {void}
|
|
46
|
+
* @throws {TypeError}
|
|
47
|
+
*/
|
|
48
|
+
export function checkMask(spec, where = 'mask') {
|
|
49
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) throw new TypeError(`${where}: a mask is { gradient: 'linear' | 'radial' | 'band', … } or { layers: [...] }`);
|
|
50
|
+
const known = ['gradient', 'from', 'to', 'center', 'angle', 'aspect', 'invert', 'ease', 'layers'];
|
|
51
|
+
for (const k of Object.keys(spec)) if (!known.includes(k)) throw new TypeError(`${where}.${k}: unknown mask option (${known.join(', ')})`);
|
|
52
|
+
if (spec.layers != null) {
|
|
53
|
+
if (!Array.isArray(spec.layers)) throw new TypeError(`${where}.layers: an array of layers`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!MASK_GRADIENTS.includes(spec.gradient)) throw new TypeError(`${where}.gradient: "${spec.gradient}" (${MASK_GRADIENTS.join(', ')}), or give layers`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A gradient mask as a function of a point in the layer's units.
|
|
61
|
+
*
|
|
62
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
63
|
+
* @param {{ width: number, height: number, canvas: { width: number, height: number } }} ctx
|
|
64
|
+
* @returns {(x: number, y: number) => number} 0–1.
|
|
65
|
+
*/
|
|
66
|
+
export function maskField(spec, ctx) {
|
|
67
|
+
checkMask(spec);
|
|
68
|
+
const W = ctx.width;
|
|
69
|
+
const H = ctx.height;
|
|
70
|
+
const ease = spec.ease !== false;
|
|
71
|
+
const inv = Boolean(spec.invert);
|
|
72
|
+
const out = (/** @type {number} */ t) => {
|
|
73
|
+
let v = clamp01(t);
|
|
74
|
+
if (ease) v = v * v * (3 - 2 * v);
|
|
75
|
+
return inv ? 1 - v : v;
|
|
76
|
+
};
|
|
77
|
+
if (spec.gradient === 'linear') {
|
|
78
|
+
const [fx, fy] = fraction(/** @type {any} */ (spec.from), [0, 0.5]);
|
|
79
|
+
const [tx, ty] = fraction(/** @type {any} */ (spec.to), [1, 0.5]);
|
|
80
|
+
const x0 = fx * W;
|
|
81
|
+
const y0 = fy * H;
|
|
82
|
+
const dx = tx * W - x0;
|
|
83
|
+
const dy = ty * H - y0;
|
|
84
|
+
const k = 1 / (dx * dx + dy * dy || 1);
|
|
85
|
+
return (x, y) => out(((x - x0) * dx + (y - y0) * dy) * k);
|
|
86
|
+
}
|
|
87
|
+
const [cx, cy] = fraction(/** @type {any} */ (spec.center), [0.5, 0.5]);
|
|
88
|
+
const ox = cx * W;
|
|
89
|
+
const oy = cy * H;
|
|
90
|
+
const isBand = spec.gradient === 'band';
|
|
91
|
+
const r0 = len(/** @type {any} */ (spec.from), ctx.canvas, isBand ? '8vmin' : '12vmin');
|
|
92
|
+
const r1 = Math.max(r0 + 1e-6, len(/** @type {any} */ (spec.to), ctx.canvas, isBand ? '30vmin' : '48vmin'));
|
|
93
|
+
const k = 1 / (r1 - r0);
|
|
94
|
+
if (isBand) {
|
|
95
|
+
const a = ((spec.angle ?? 0) * Math.PI) / 180;
|
|
96
|
+
const nx = -Math.sin(a);
|
|
97
|
+
const ny = Math.cos(a);
|
|
98
|
+
return (x, y) => out((Math.abs((x - ox) * nx + (y - oy) * ny) - r0) * k);
|
|
99
|
+
}
|
|
100
|
+
const ar = 1 / (spec.aspect ?? 1);
|
|
101
|
+
return (x, y) => {
|
|
102
|
+
const dx = x - ox;
|
|
103
|
+
const dy = (y - oy) * ar;
|
|
104
|
+
return out((Math.sqrt(dx * dx + dy * dy) - r0) * k);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A mask sampled on a grid over a box of the layer, as greyscale bytes.
|
|
110
|
+
*
|
|
111
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
112
|
+
* @param {any} ctx - The layer's render context.
|
|
113
|
+
* @param {{ x: number, y: number, width: number, height: number }} box - Layer units.
|
|
114
|
+
* @param {number} cols
|
|
115
|
+
* @param {number} rows
|
|
116
|
+
* @param {number} [angle=0] - The grid is in a frame turned by this many degrees about the canvas centre (a `motion` blur's).
|
|
117
|
+
* @returns {Promise<Uint8Array | undefined>} Undefined when a layer mask can't be drawn here.
|
|
118
|
+
*/
|
|
119
|
+
export async function maskGrid(spec, ctx, box, cols, rows, angle = 0) {
|
|
120
|
+
const resolved = /** @type {any} */ (spec)[RESOLVED];
|
|
121
|
+
if (spec.layers) return resolved ? drawnMask(resolved, spec, ctx, box, cols, rows, angle) : undefined;
|
|
122
|
+
return gradientGrid(spec, ctx, box, cols, rows, angle);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A gradient mask on a grid (synchronous).
|
|
127
|
+
*
|
|
128
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
129
|
+
* @param {any} ctx
|
|
130
|
+
* @param {{ x: number, y: number, width: number, height: number }} box
|
|
131
|
+
* @param {number} cols
|
|
132
|
+
* @param {number} rows
|
|
133
|
+
* @param {number} [angle=0]
|
|
134
|
+
* @returns {Uint8Array}
|
|
135
|
+
*/
|
|
136
|
+
export function gradientGrid(spec, ctx, box, cols, rows, angle = 0) {
|
|
137
|
+
const f = maskField(spec, ctx);
|
|
138
|
+
const out = new Uint8Array(cols * rows);
|
|
139
|
+
const a = (angle * Math.PI) / 180;
|
|
140
|
+
const cos = Math.cos(a);
|
|
141
|
+
const sin = Math.sin(a);
|
|
142
|
+
const cx = ctx.width / 2;
|
|
143
|
+
const cy = ctx.height / 2;
|
|
144
|
+
const sx = box.width / cols;
|
|
145
|
+
const sy = box.height / rows;
|
|
146
|
+
for (let j = 0; j < rows; j++) {
|
|
147
|
+
for (let i = 0; i < cols; i++) {
|
|
148
|
+
let x = box.x + (i + 0.5) * sx;
|
|
149
|
+
let y = box.y + (j + 0.5) * sy;
|
|
150
|
+
if (a) {
|
|
151
|
+
const dx = x - cx;
|
|
152
|
+
const dy = y - cy;
|
|
153
|
+
x = cx + dx * cos - dy * sin;
|
|
154
|
+
y = cy + dx * sin + dy * cos;
|
|
155
|
+
}
|
|
156
|
+
out[j * cols + i] = Math.round(f(x, y) * 255);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Layers drawn as a mask: luminance times alpha, on a grid over a box.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ defs: string, body: string }} m - The mask layers' markup.
|
|
166
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
167
|
+
* @param {any} ctx
|
|
168
|
+
* @param {{ x: number, y: number, width: number, height: number }} box
|
|
169
|
+
* @param {number} cols
|
|
170
|
+
* @param {number} rows
|
|
171
|
+
* @param {number} angle
|
|
172
|
+
* @returns {Promise<Uint8Array | undefined>}
|
|
173
|
+
*/
|
|
174
|
+
async function drawnMask(m, spec, ctx, box, cols, rows, angle) {
|
|
175
|
+
const turn = angle ? `<g transform="rotate(${num(-angle)} ${num(ctx.width / 2)} ${num(ctx.height / 2)})">${m.body}</g>` : m.body;
|
|
176
|
+
const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${cols}" height="${rows}" viewBox="${num(box.x, 4)} ${num(box.y, 4)} ${num(box.width, 4)} ${num(box.height, 4)}" preserveAspectRatio="none">${m.defs ? `<defs>${m.defs}</defs>` : ''}${turn}</svg>`;
|
|
177
|
+
let px;
|
|
178
|
+
try {
|
|
179
|
+
px = await pixelsOf(doc, cols, rows, ctx.sharp);
|
|
180
|
+
} catch {
|
|
181
|
+
px = undefined;
|
|
182
|
+
}
|
|
183
|
+
if (!px) return undefined;
|
|
184
|
+
const out = new Uint8Array(cols * rows);
|
|
185
|
+
for (let i = 0, j = 0; j < out.length; i += 4, j++) {
|
|
186
|
+
const v = ((0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]) * px[i + 3]) / 255;
|
|
187
|
+
out[j] = spec.invert ? 255 - v : v;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The mask as an image for a filter: a small greyscale PNG over the
|
|
194
|
+
* filter's region (a gradient scales up smoothly), or undefined where a
|
|
195
|
+
* layer mask can't be drawn.
|
|
196
|
+
*
|
|
197
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
198
|
+
* @param {any} ctx
|
|
199
|
+
* @param {number} pad - The filter's padding, a fraction of the canvas.
|
|
200
|
+
* @param {number} [angle=0]
|
|
201
|
+
* @returns {Promise<{ href: string, x: number, y: number, width: number, height: number } | undefined>}
|
|
202
|
+
*/
|
|
203
|
+
export async function maskImage(spec, ctx, pad, angle = 0) {
|
|
204
|
+
const box = { x: -ctx.width * pad, y: -ctx.height * pad, width: ctx.width * (1 + 2 * pad), height: ctx.height * (1 + 2 * pad) };
|
|
205
|
+
const side = spec.layers ? LAYER_PX : GRADIENT_PX;
|
|
206
|
+
const k = side / Math.max(box.width, box.height);
|
|
207
|
+
const cols = Math.max(2, Math.round(box.width * k));
|
|
208
|
+
const rows = Math.max(2, Math.round(box.height * k));
|
|
209
|
+
const grid = await maskGrid(spec, ctx, box, cols, rows, angle);
|
|
210
|
+
if (!grid) return undefined;
|
|
211
|
+
return { href: pngUri(encodePng(grid, cols, rows, 1)), ...box };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The same, synchronously, for a gradient.
|
|
216
|
+
*
|
|
217
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
218
|
+
* @param {any} ctx
|
|
219
|
+
* @param {number} pad
|
|
220
|
+
* @param {number} [angle=0]
|
|
221
|
+
* @returns {{ href: string, x: number, y: number, width: number, height: number }}
|
|
222
|
+
*/
|
|
223
|
+
export function gradientImage(spec, ctx, pad, angle = 0) {
|
|
224
|
+
const box = { x: -ctx.width * pad, y: -ctx.height * pad, width: ctx.width * (1 + 2 * pad), height: ctx.height * (1 + 2 * pad) };
|
|
225
|
+
const k = GRADIENT_PX / Math.max(box.width, box.height);
|
|
226
|
+
const cols = Math.max(2, Math.round(box.width * k));
|
|
227
|
+
const rows = Math.max(2, Math.round(box.height * k));
|
|
228
|
+
return { href: pngUri(encodePng(gradientGrid(spec, ctx, box, cols, rows, angle), cols, rows, 1)), ...box };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Filter primitives that mix the chain's result with its source through a
|
|
233
|
+
* mask image: `result × m + source × (1 − m)`, premultiplied, exact.
|
|
234
|
+
*
|
|
235
|
+
* @param {{ href: string, x: number, y: number, width: number, height: number }} image
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function lerpPrimitives(image) {
|
|
239
|
+
return '<feOffset dx="0" dy="0" result="fxo"/>'
|
|
240
|
+
+ `<feImage${attrs({ href: image.href, x: image.x, y: image.y, width: image.width, height: image.height, preserveAspectRatio: 'none', result: 'fxk' })}/>`
|
|
241
|
+
+ '<feColorMatrix in="fxk" type="luminanceToAlpha" color-interpolation-filters="sRGB" result="fxm"/>'
|
|
242
|
+
+ '<feComposite in="fxo" in2="fxm" operator="in" result="fxa"/>'
|
|
243
|
+
+ '<feComposite in="SourceGraphic" in2="fxm" operator="out" result="fxb"/>'
|
|
244
|
+
+ '<feComposite in="fxa" in2="fxb" operator="arithmetic" k2="1" k3="1"/>';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Mix pixels before and after an effect through a mask, in place on `out`.
|
|
249
|
+
*
|
|
250
|
+
* @param {Uint8ClampedArray} src - Before (RGBA, unpremultiplied).
|
|
251
|
+
* @param {Uint8ClampedArray} out - After; becomes the mix.
|
|
252
|
+
* @param {Uint8Array} m - The mask, a byte a pixel.
|
|
253
|
+
* @returns {Uint8ClampedArray}
|
|
254
|
+
*/
|
|
255
|
+
export function lerpPixels(src, out, m) {
|
|
256
|
+
for (let p = 0, i = 0; p < m.length; p++, i += 4) {
|
|
257
|
+
const t = m[p];
|
|
258
|
+
if (t === 255) continue;
|
|
259
|
+
if (t === 0) { out[i] = src[i]; out[i + 1] = src[i + 1]; out[i + 2] = src[i + 2]; out[i + 3] = src[i + 3]; continue; }
|
|
260
|
+
const k = t / 255;
|
|
261
|
+
const a0 = src[i + 3];
|
|
262
|
+
const a1 = out[i + 3];
|
|
263
|
+
if (a0 === 255 && a1 === 255) {
|
|
264
|
+
out[i] = src[i] + (out[i] - src[i]) * k;
|
|
265
|
+
out[i + 1] = src[i + 1] + (out[i + 1] - src[i + 1]) * k;
|
|
266
|
+
out[i + 2] = src[i + 2] + (out[i + 2] - src[i + 2]) * k;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Premultiplied mix.
|
|
270
|
+
const w0 = (a0 / 255) * (1 - k);
|
|
271
|
+
const w1 = (a1 / 255) * k;
|
|
272
|
+
const a = w0 + w1;
|
|
273
|
+
if (a <= 0) { out[i + 3] = 0; continue; }
|
|
274
|
+
out[i] = (src[i] * w0 + out[i] * w1) / a;
|
|
275
|
+
out[i + 1] = (src[i + 1] * w0 + out[i + 1] * w1) / a;
|
|
276
|
+
out[i + 2] = (src[i + 2] * w0 + out[i + 2] * w1) / a;
|
|
277
|
+
out[i + 3] = a * 255;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The effect drawn over the layer through an SVG mask (for effects made
|
|
284
|
+
* of copies, and for layer masks where none can be drawn as pixels):
|
|
285
|
+
* exact where the effect keeps the layer opaque.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} before - The layer's body before the effect.
|
|
288
|
+
* @param {{ defs: string, body: string }} after - Its markup after (all the defs so far).
|
|
289
|
+
* @param {import('./mask.js').EffectMask} spec
|
|
290
|
+
* @param {any} ctx
|
|
291
|
+
* @param {string} id
|
|
292
|
+
* @returns {{ defs: string, body: string }}
|
|
293
|
+
*/
|
|
294
|
+
export function maskOverlay(before, after, spec, ctx, id) {
|
|
295
|
+
const W = ctx.width;
|
|
296
|
+
const H = ctx.height;
|
|
297
|
+
const pad = Math.max(W, H);
|
|
298
|
+
const region = { x: -pad, y: -pad, width: W + 2 * pad, height: H + 2 * pad };
|
|
299
|
+
let content;
|
|
300
|
+
let extra = '';
|
|
301
|
+
if (spec.layers) {
|
|
302
|
+
const m = /** @type {any} */ (spec)[RESOLVED] ?? { defs: '', body: '' };
|
|
303
|
+
extra = m.defs;
|
|
304
|
+
content = spec.invert
|
|
305
|
+
? `<rect${attrs({ ...region, fill: '#fff' })}/><g filter="url(#${id}i)">${m.body}</g>`
|
|
306
|
+
: m.body;
|
|
307
|
+
if (spec.invert) extra += `<filter${attrs({ id: `${id}i`, filterUnits: 'userSpaceOnUse', ...region, 'color-interpolation-filters': 'sRGB' })}><feColorMatrix type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"/></filter>`;
|
|
308
|
+
} else {
|
|
309
|
+
const g = gradientImage(spec, ctx, 0.25);
|
|
310
|
+
content = `<image${attrs({ x: g.x, y: g.y, width: g.width, height: g.height, preserveAspectRatio: 'none', href: g.href })}/>`;
|
|
311
|
+
}
|
|
312
|
+
const defs = `${after.defs}${extra}<mask${attrs({ id: `${id}k`, maskUnits: 'userSpaceOnUse', ...region })}>${content}</mask>`;
|
|
313
|
+
return { defs, body: `${before}<g mask="url(#${id}k)">${after.body}</g>` };
|
|
314
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The motion blur on pixels (renderImage).
|
|
2
|
+
import type { Effect } from './index.js';
|
|
3
|
+
import type { Pixels } from './buffer.js';
|
|
4
|
+
|
|
5
|
+
/** A Gaussian blur along an angle, on pixels. */
|
|
6
|
+
export function motion(img: Pixels, fx: Extract<Effect, { kind: 'motion' }>, ctx: object): Uint8ClampedArray;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `motion` on pixels: a Gaussian blur along `angle` (`length` its
|
|
3
|
+
* standard deviation) and `spread` across it. librsvg can't blur along
|
|
4
|
+
* one axis under a rotation (a turned one-axis `feGaussianBlur` comes out
|
|
5
|
+
* round), so `renderImage` draws motion blur here (`renderSvg` keeps the
|
|
6
|
+
* filter, which browsers draw correctly): the layer is
|
|
7
|
+
* resampled into a frame turned to the angle (the edge held, so a
|
|
8
|
+
* full-bleed photo doesn't fade at the edges), blurred along rows with
|
|
9
|
+
* running sums (O(1) a pixel), and resampled back.
|
|
10
|
+
*
|
|
11
|
+
* @module effects/motion
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { px, premultiplied, unpremultiply, bilinear, gaussian } from './buffer.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The blur.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('./buffer.js').Pixels} img
|
|
20
|
+
* @param {import('./index.js').Effect & { kind: 'motion' }} fx
|
|
21
|
+
* @param {any} ctx
|
|
22
|
+
* @returns {Uint8ClampedArray}
|
|
23
|
+
*/
|
|
24
|
+
export function motion(img, fx, ctx) {
|
|
25
|
+
const { width: w, height: h } = img;
|
|
26
|
+
const along = px(fx.length, ctx, img, '3vmin');
|
|
27
|
+
const across = px(fx.spread, ctx, img, 0);
|
|
28
|
+
const a = ((fx.angle ?? 0) * Math.PI) / 180;
|
|
29
|
+
const c = Math.cos(a);
|
|
30
|
+
const s = Math.sin(a);
|
|
31
|
+
const src = premultiplied(img);
|
|
32
|
+
// The turned frame: big enough to hold the layer, with room for the blur at its ends.
|
|
33
|
+
const pad = Math.ceil(3 * Math.max(along, across)) + 2;
|
|
34
|
+
const rw = Math.ceil(Math.abs(w * c) + Math.abs(h * s)) + 2 * pad;
|
|
35
|
+
const rh = Math.ceil(Math.abs(w * s) + Math.abs(h * c)) + 2 * pad;
|
|
36
|
+
const cx = (w - 1) / 2;
|
|
37
|
+
const cy = (h - 1) / 2;
|
|
38
|
+
const rcx = (rw - 1) / 2;
|
|
39
|
+
const rcy = (rh - 1) / 2;
|
|
40
|
+
// Turned frame (u along the blur, v across) → layer pixels, the edge held
|
|
41
|
+
// (a full-bleed photo stays full-bleed; a cut-out's edge is clear anyway).
|
|
42
|
+
const turnedPx = new Uint8ClampedArray(rw * rh * 4);
|
|
43
|
+
for (let v = 0, o = 0; v < rh; v++) {
|
|
44
|
+
for (let u = 0; u < rw; u++, o += 4) {
|
|
45
|
+
const du = u - rcx;
|
|
46
|
+
const dv = v - rcy;
|
|
47
|
+
bilinear(src, w, h, cx + du * c - dv * s, cy + du * s + dv * c, turnedPx, o);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
gaussian(turnedPx, rw, rh, 4, along, across);
|
|
51
|
+
const out = new Uint8ClampedArray(w * h * 4);
|
|
52
|
+
for (let y = 0, o = 0; y < h; y++) {
|
|
53
|
+
for (let x = 0; x < w; x++, o += 4) {
|
|
54
|
+
const dx = x - cx;
|
|
55
|
+
const dy = y - cy;
|
|
56
|
+
bilinear(turnedPx, rw, rh, rcx + dx * c + dy * s, rcy - dx * s + dy * c, out, o);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return img.opaque ? out : unpremultiply(out);
|
|
60
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// A four-colour process halftone.
|
|
2
|
+
import type { Effect } from './index.js';
|
|
3
|
+
import type { Pixels } from './buffer.js';
|
|
4
|
+
|
|
5
|
+
/** `newsprint`, on pixels. */
|
|
6
|
+
export function newsprint(img: Pixels, fx: Extract<Effect, { kind: 'newsprint' }>, ctx: object): Uint8ClampedArray;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `newsprint`: a four-colour process halftone, computed on pixels. The
|
|
3
|
+
* layer is separated into cyan, magenta, yellow and black (with grey
|
|
4
|
+
* component replacement), each screened at its classic angle (C 15°,
|
|
5
|
+
* M 75°, Y 0°, K 45°) as round dots that join into a checkerboard past
|
|
6
|
+
* half coverage, anti-aliased, and printed on `paper` with process inks.
|
|
7
|
+
*
|
|
8
|
+
* @module effects/newsprint
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { px } from './buffer.js';
|
|
12
|
+
import { rgb } from './util.js';
|
|
13
|
+
|
|
14
|
+
/** Process inks (sRGB approximations of SWOP on uncoated stock). */
|
|
15
|
+
const INKS = { c: '#00a3e0', m: '#e5007e', y: '#ffe600', k: '#1d1b1c' };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `newsprint`: `cell` (a length: the screen ruling), `angles`
|
|
19
|
+
* (`[c, m, y, k]`, degrees), `paper`, `gain` (dot gain, 0–0.3) and `gcr`
|
|
20
|
+
* (0–1: how much grey goes to black).
|
|
21
|
+
*
|
|
22
|
+
* @param {import('./buffer.js').Pixels} img
|
|
23
|
+
* @param {import('./index.js').Effect & { kind: 'newsprint' }} fx
|
|
24
|
+
* @param {any} ctx
|
|
25
|
+
* @returns {Uint8ClampedArray}
|
|
26
|
+
*/
|
|
27
|
+
export function newsprint(img, fx, ctx) {
|
|
28
|
+
const { data, width: w, height: h } = img;
|
|
29
|
+
const cell = Math.max(2, px(fx.cell, ctx, img, '1vmin'));
|
|
30
|
+
const angles = fx.angles ?? [15, 75, 0, 45];
|
|
31
|
+
const paper = rgb(fx.paper ?? '#f2ede1');
|
|
32
|
+
const inks = ['c', 'm', 'y', 'k'].map((k) => rgb(/** @type {any} */ (INKS)[k]));
|
|
33
|
+
const gain = fx.gain ?? 0.08;
|
|
34
|
+
const gcr = fx.gcr ?? 0.8;
|
|
35
|
+
const rot = angles.map((a) => { const t = (a * Math.PI) / 180; return [Math.cos(t) / cell, Math.sin(t) / cell]; });
|
|
36
|
+
// Anti-aliasing: a pixel's width in cell units.
|
|
37
|
+
const aa = 1 / cell;
|
|
38
|
+
const out = new Uint8ClampedArray(data.length);
|
|
39
|
+
const cov = new Float64Array(4);
|
|
40
|
+
for (let y = 0, i = 0; y < h; y++) {
|
|
41
|
+
for (let x = 0; x < w; x++, i += 4) {
|
|
42
|
+
const r = data[i] / 255;
|
|
43
|
+
const g = data[i + 1] / 255;
|
|
44
|
+
const b = data[i + 2] / 255;
|
|
45
|
+
// Separation: grey component replaced by black.
|
|
46
|
+
const k = (1 - Math.max(r, g, b)) * gcr;
|
|
47
|
+
const d = 1 - k || 1;
|
|
48
|
+
cov[0] = (1 - r - k) / d;
|
|
49
|
+
cov[1] = (1 - g - k) / d;
|
|
50
|
+
cov[2] = (1 - b - k) / d;
|
|
51
|
+
cov[3] = k;
|
|
52
|
+
let R = paper[0];
|
|
53
|
+
let G = paper[1];
|
|
54
|
+
let B = paper[2];
|
|
55
|
+
for (let n = 0; n < 4; n++) {
|
|
56
|
+
const c = Math.min(1, Math.max(0, cov[n] * (1 + gain * (1 - cov[n]) * 4)));
|
|
57
|
+
if (c <= 0.002) continue;
|
|
58
|
+
const [cs, sn] = rot[n];
|
|
59
|
+
// The pixel in this screen's cell coordinates, -1–1 across a cell.
|
|
60
|
+
const u = x * cs + y * sn;
|
|
61
|
+
const v = -x * sn + y * cs;
|
|
62
|
+
const fu = (u - Math.floor(u)) * 2 - 1;
|
|
63
|
+
const fv = (v - Math.floor(v)) * 2 - 1;
|
|
64
|
+
let ink;
|
|
65
|
+
if (c <= 0.5) {
|
|
66
|
+
// A dot of area c (of the cell's 4 square units).
|
|
67
|
+
const rad = Math.sqrt((4 * c) / Math.PI);
|
|
68
|
+
ink = Math.min(1, Math.max(0, (rad - Math.hypot(fu, fv)) / (2 * aa) + 0.5));
|
|
69
|
+
} else {
|
|
70
|
+
// Past half: paper shrinks to holes at the corners.
|
|
71
|
+
const rad = Math.sqrt((4 * (1 - c)) / Math.PI);
|
|
72
|
+
const dc = Math.hypot(1 - Math.abs(fu), 1 - Math.abs(fv));
|
|
73
|
+
ink = Math.min(1, Math.max(0, (dc - rad) / (2 * aa) + 0.5));
|
|
74
|
+
}
|
|
75
|
+
if (!ink) continue;
|
|
76
|
+
// Inks multiply, as they do on paper.
|
|
77
|
+
const t = inks[n];
|
|
78
|
+
R *= 1 - ink * (1 - t[0]);
|
|
79
|
+
G *= 1 - ink * (1 - t[1]);
|
|
80
|
+
B *= 1 - ink * (1 - t[2]);
|
|
81
|
+
}
|
|
82
|
+
out[i] = R * 255;
|
|
83
|
+
out[i + 1] = G * 255;
|
|
84
|
+
out[i + 2] = B * 255;
|
|
85
|
+
out[i + 3] = data[i + 3];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|