scrawl-canvas
Version:
Version 8.9.4 - 19 Nov 2022
1,418 lines (1,009 loc) • 187 kB
JavaScript
// # Scrawl-canvas filter engine
// All Scrawl-canvas filters-related image manipulation work happens in this engine code. Note that this functionality is entirely separate from the <canvas> element's context engine's native `filter` functionality, which allows us to add CSS/SVG-based filters to the canvas context
// + Note that prior to v8.5.0 most of this code lived in an (asynchronous) web worker. Web worker functionality has now been removed from Scrawl-canvas as it was not adding sufficient efficiency to rendering speed
import { constructors, filter, filternames, styles, stylesnames } from '../core/library.js';
import { seededRandomNumberGenerator } from '../core/random-seed.js';
import { easeEngines, isa_fn } from '../core/utilities.js';
import { makeAnimation } from './animation.js';
import { requestCell, releaseCell } from './cell.js';
import { requestCoordinate, releaseCoordinate } from './coordinate.js';
import { makeColor } from './color.js';
import { bluenoise } from './filterEngine-bluenoiseData.js';
// #### FilterEngine constructor
const FilterEngine = function () {
// ### Transactional variables
// __cache__ - an Object consisting of `key:Object` pairs where the key is the named input of a `process-image` action or the output of any action object. This object is cleared and re-initialized each time the `engine.action` function is invoked
this.cache = null;
// __actions__ - the Array of action objects that the engine needs to process - data supplied by the main thread in its message's `packetFiltersArray` attribute.
this.actions = [];
return this;
};
// #### FilterEngine prototype
let P = FilterEngine.prototype = Object.create(Object.prototype);
P.type = 'FilterEngine';
let choke = 1000;
const setFilterMemoizationChoke = function (val) {
if (val.toFixed && !isNaN(val) && val >= 200 && val <= 10000) choke = val;
};
P.action = function (packet) {
let { identifier, filters, image } = packet;
let { workstoreLastAccessed, workstore, actions, theBigActionsObject } = this;
let workstoreKeys = Object.keys(workstore),
workstoreChoke = Date.now() - choke;
for (let k = 0, kz = workstoreKeys.length, s; k < kz; k++) {
s = workstoreKeys[k];
if (workstoreLastAccessed[s] < workstoreChoke) {
delete workstore[s];
delete workstoreLastAccessed[s];
}
}
if (identifier && workstore[identifier]) {
workstoreLastAccessed[identifier] = Date.now();
return workstore[identifier];
}
actions.length = 0;
for (let f = 0, fz = filters.length; f < fz; f++) {
actions.push(...filters[f].actions)
}
let actionsLen = actions.length;
if (actionsLen) {
this.unknit(image);
for (let i = 0, actData, a; i < actionsLen; i++) {
actData = actions[i];
a = theBigActionsObject[actData.action];
if (a) a.call(this, actData);
}
if (identifier) {
workstore[identifier] = this.cache.work;
workstoreLastAccessed[identifier] = Date.now();
}
return this.cache.work;
}
return image;
}
// ### Permanent variables
// The filter engine maintains a semi-permanent storage space - the __workstore__ - for some processing objects that are computationally expensive, for instance grids, matrix reference data objects, etc. The engine also maintains a record of when each of these processing objects was last accessed and will remove objects if they have not been accessed in the last three seconds.
P.workstore = {},
P.workstoreLastAccessed = {};
// `unknit` - called at the start of each new message action chain. Creates and populates the __source__ and __work__ objects from the image data supplied in the message
P.unknit = function (image) {
this.cache = {};
let cache = this.cache;
let { width, height, data } = image;
let len = data.length;
cache.source = new ImageData(data, width, height);
cache.work = new ImageData(data, width, height);
};
// `getAlphaData` - extract alpha channel data from (usually the source) ImageData object and populate the color channels of a new ImageData object with that data
P.getAlphaData = function (image) {
let {width, height, data:iData} = image;
let len = iData.length;
let sourceAlpha = new ImageData(width, height),
aData = sourceAlpha.data;
for (let i = 0; i < len; i += 4) {
let a = iData[i + 3];
aData[i] = 0;
aData[i + 1] = 0;
aData[i + 2] = 0;
aData[i + 3] = (a > 0) ? 255 : 0;
}
return sourceAlpha;
};
// ### Functions invoked by a range of different action functions
//
// `buildImageGrid` creates an Array of Arrays which contain the indexes of each pixel in the image channel Arrays
P.buildImageGrid = function (image) {
let { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
let { width, height } = image
if (width && height) {
let name = `grid-${width}-${height}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
let grid = [],
counter = 0;
for (let y = 0; y < height; y++) {
let row = [];
for (let x = 0; x < width; x++) {
row.push(counter);
counter++;
}
grid.push(row);
}
workstore[name] = grid;
workstoreLastAccessed[name] = Date.now();
return grid;
}
return false;
};
// `getOrAddWorkstore` creates an Array which can be populated by swirl-related coordinates
P.getOrAddWorkstore = function (name) {
const { workstore, workstoreLastAccessed } = this;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
workstore[name] = [];
workstoreLastAccessed[name] = Date.now();
return workstore[name];
};
// `getRandomNumbers` creates an Array and populates it with random numbers
P.getRandomNumbers = function (seed, length, imgWidth = 0) {
let name = `random-${seed}-${length}`;
if (imgWidth) name += '-bluenoise';
const { workstore, workstoreLastAccessed } = this;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
const vals = [];
if (imgWidth) {
const bLen = bluenoise.length,
blueDims = Math.sqrt(bLen),
imgHeight = length / imgWidth,
bLines = [];
for (let i = 0; i < bLen; i += blueDims) {
let temp = bluenoise.slice(i, i + blueDims);
bLines.push(temp);
}
for (let bigRows = imgHeight; bigRows > 0; bigRows -= blueDims) {
for (let bRow = 0; bRow < blueDims; bRow++) {
let currentRow = bLines[bRow];
for (let bCol = imgWidth; bCol > 0; bCol -= blueDims) {
if (bCol < blueDims) vals.push(...currentRow.slice(0, bCol));
else vals.push(...currentRow);
}
}
}
}
else {
const engine = seededRandomNumberGenerator(seed);
for (let i = 0; i < length; i++) {
vals.push(engine.random());
}
}
workstore[name] = vals;
workstoreLastAccessed[name] = Date.now();
return workstore[name];
};
P.buildImageCoordinateLookup = function (image) {
const { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
const { width, height } = image
if (width && height) {
const name = `coords-lookup-${width}-${height}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
const lookup = []
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
lookup.push([x, y]);
}
}
workstore[name] = lookup;
workstoreLastAccessed[name] = Date.now();
return lookup;
}
return false;
};
// `buildAlphaTileSets` - creates a record of which pixels belong to which tile - used for manipulating alpha channel values. Resulting object will be cached in the store
P.buildAlphaTileSets = function (tileWidth, tileHeight, gutterWidth, gutterHeight, offsetX, offsetY, areaAlphaLevels, image) {
const { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
const { width:iWidth, height:iHeight } = image;
if (iWidth && iHeight) {
tileWidth = (tileWidth.toFixed && !isNaN(tileWidth)) ? tileWidth : 1;
tileHeight = (tileHeight.toFixed && !isNaN(tileHeight)) ? tileHeight : 1;
gutterWidth = (gutterWidth.toFixed && !isNaN(gutterWidth)) ? gutterWidth : 1;
gutterHeight = (gutterHeight.toFixed && !isNaN(gutterHeight)) ? gutterHeight : 1;
offsetX = (offsetX.toFixed && !isNaN(offsetX)) ? offsetX : 0;
offsetY = (offsetY.toFixed && !isNaN(offsetY)) ? offsetY : 0;
if (tileWidth < 1) tileWidth = 1;
if (tileHeight < 1) tileHeight = 1;
if (tileWidth + gutterWidth >= iWidth) tileWidth = iWidth - gutterWidth - 1;
if (tileHeight + gutterHeight >= iHeight) tileHeight = iHeight - gutterHeight - 1;
if (tileWidth < 1) tileWidth = 1;
if (tileHeight < 1) tileHeight = 1;
if (tileWidth + gutterWidth >= iWidth) gutterWidth = iWidth - tileWidth - 1;
if (tileHeight + gutterHeight >= iHeight) gutterHeight = iHeight - tileHeight - 1;
let aWidth = tileWidth + gutterWidth,
aHeight = tileHeight + gutterHeight;
if (offsetX < 0) offsetX = 0;
if (offsetX >= aWidth) offsetX = aWidth - 1;
if (offsetY < 0) offsetY = 0;
if (offsetY >= aHeight) offsetY = aHeight - 1;
const name = `alphatileset-${iWidth}-${iHeight}-${tileWidth}-${tileHeight}-${gutterWidth}-${gutterHeight}-${offsetX}-${offsetY}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
let tiles = [],
hold, i, iz, j, jz, x, xz, y, yz;
for (j = offsetY - aHeight, jz = iHeight; j < jz; j += aHeight) {
for (i = offsetX - aWidth, iz = iWidth; i < iz; i += aWidth) {
hold = [];
for (y = j, yz = j + tileHeight; y < yz; y++) {
if (y >= 0 && y < iHeight) {
for (let x = i, xz = i + tileWidth; x < xz; x++) {
if (x >= 0 && x < iWidth) hold.push((((y * iWidth) + x) * 4) + 3);
}
}
}
tiles.push([].concat(hold));
hold = [];
for (y = j + tileHeight, yz = j + tileHeight + gutterHeight; y < yz; y++) {
if (y >= 0 && y < iHeight) {
for (let x = i, xz = i + tileWidth; x < xz; x++) {
if (x >= 0 && x < iWidth) hold.push((((y * iWidth) + x) * 4) + 3);
}
}
}
tiles.push([].concat(hold));
hold = [];
for (y = j, yz = j + tileHeight; y < yz; y++) {
if (y >= 0 && y < iHeight) {
for (let x = i + tileWidth, xz = i + tileWidth + gutterWidth; x < xz; x++) {
if (x >= 0 && x < iWidth) hold.push((((y * iWidth) + x) * 4) + 3);
}
}
}
tiles.push([].concat(hold));
hold = [];
for (y = j + tileHeight, yz = j + tileHeight + gutterHeight; y < yz; y++) {
if (y >= 0 && y < iHeight) {
for (let x = i + tileWidth, xz = i + tileWidth + gutterWidth; x < xz; x++) {
if (x >= 0 && x < iWidth) hold.push((((y * iWidth) + x) * 4) + 3);
}
}
}
tiles.push([].concat(hold));
}
}
workstore[name] = tiles;
workstoreLastAccessed[name] = Date.now();
return tiles;
}
return false;
};
// `buildImageTileSets` - creates a record of which pixels belong to which tile - used for manipulating color channels values. Resulting object will be cached in the store
P.buildImageTileSets = function (tileWidth, tileHeight, offsetX, offsetY, image) {
const { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
const { width:iWidth, height:iHeight } = image;
if (iWidth && iHeight) {
tileWidth = (tileWidth.toFixed && !isNaN(tileWidth)) ? tileWidth : 1;
tileHeight = (tileHeight.toFixed && !isNaN(tileHeight)) ? tileHeight : 1;
offsetX = (offsetX.toFixed && !isNaN(offsetX)) ? offsetX : 0;
offsetY = (offsetY.toFixed && !isNaN(offsetY)) ? offsetY : 0;
if (tileWidth < 1) tileWidth = 1;
if (tileWidth >= iWidth) tileWidth = iWidth - 1;
if (tileHeight < 1) tileHeight = 1;
if (tileHeight >= iHeight) tileHeight = iHeight - 1;
if (offsetX < 0) offsetX = 0;
if (offsetX >= tileWidth) offsetX = tileWidth - 1;
if (offsetY < 0) offsetY = 0;
if (offsetY >= tileHeight) offsetY = tileHeight - 1;
const name = `simple-tileset-${iWidth}-${iHeight}-${tileWidth}-${tileHeight}-${offsetX}-${offsetY}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
const tiles = [];
for (let j = offsetY - tileHeight, jz = iHeight; j < jz; j += tileHeight) {
for (let i = offsetX - tileWidth, iz = iWidth; i < iz; i += tileWidth) {
let hold = [];
for (let y = j, yz = j + tileHeight; y < yz; y++) {
if (y >= 0 && y < iHeight) {
for (let x = i, xz = i + tileWidth; x < xz; x++) {
if (x >= 0 && x < iWidth) hold.push(((y * iWidth) + x) * 4);
}
}
}
if (hold.length) tiles.push(hold);
}
}
workstore[name] = tiles;
workstoreLastAccessed[name] = Date.now();
return tiles;
}
return false;
};
// `buildGeneralTileSets` - separate the available space into a set of groups (tiles) and assign pixels to each group. Each tile centers on a `point` - an x/y coordinate; the calculations assign each pixel in the image to the group whose point it is closest to. Resulting object will be cached in the store
// + Used by the `tile` filter, but separated out as the data it generates may have uses elsewhere
P.buildGeneralTileSets = function (pointVals, tileWidth, tileHeight, tileRadius, offsetX, offsetY, angle, seed, image) {
const { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
const { width:iWidth, height:iHeight } = image;
if (iWidth && iHeight) {
let tileW = 1,
tileH = 1,
tileR = 1,
offX = 0,
offY = 0,
ang = 0,
req = 'unset';
// The `pointVals` data can be supplied in a number of different formats:
// + As a String: `'rect-grid'` - the function will calculate a set of suitable points based on the source image's dimensions, and the user-defined `tileWidth`, `tileHeight`, `offsetX`, `offsetY` and `angle` arguments. This results in a rectangular grid of tiles (at most dimensions) which can be rotated to the required angle.
// + As a String: `'hex-grid'` - the function will calculate a set of suitable points based on the source image's dimensions, and the user-defined `tileHeight`, `tileRadius`, `offsetX`, `offsetY` and `angle` arguments. This results in a hexagonal grid of tiles (at most dimensions) which can be rotated to the required angle. The shape of the hexagons in the grid depend on the interplay between the `tileHeight` and `tileRadius` values.
// + As a positive integer Number - this is a request by the user for the function to semi-randomly generate a set of points to the given value, constrained to an area determined by the `tileRadius`, `offsetX`, `offsetY` and `angle` arguments. Unlike other versions, this version will only include pixels within the bounds of circle of the given radius centered on the supplied offset coordinate values. To vary the randomness of point generation, the user can supply a `seed` argument, used when initializing the pseudo-random number generator.
// + As an Array of Numbers, which represent user-defined points across the image. Pixel selection for each point is constrained by the supplied `tileRadius`, `offsetX` and `offsetY` arguments.
if (pointVals.substring) req = pointVals;
else if (Array.isArray(pointVals)) req = 'points-array';
else if (pointVals.toFixed && !isNaN(pointVals)) req = 'random-points';
if (req === 'unset') return [];
// The `tileWidth`, `tileHeight`, `tileRadius`, `offsetX` and `offsetY` arguments can be supplied as absolute Number values (in px), or as a String % value relative to the source image dimensions.
// + `tileRadius` is relative to the source image's width
if (tileWidth.substring) tileW = Math.round((parseFloat(tileWidth) / 100) * iWidth);
else if (tileWidth.toFixed && !isNaN(tileWidth)) tileW = tileWidth;
if (tileW < 1) tileW = 1;
if (tileHeight.substring) tileH = Math.round((parseFloat(tileHeight) / 100) * iHeight);
else if (tileHeight.toFixed && !isNaN(tileHeight)) tileH = tileHeight;
if (tileH < 1) tileH = 1;
if (tileRadius.substring) tileR = Math.round((parseFloat(tileRadius) / 100) * iWidth);
else if (tileRadius.toFixed && !isNaN(tileRadius)) tileR = tileRadius;
if (tileR < 1) tileR = 1;
if (offsetX.substring) offX = Math.round((parseFloat(offsetX) / 100) * iWidth);
else if (offsetX.toFixed && !isNaN(offsetX)) offX = offsetX;
if (offX < 0) offX = 0;
else if (offX >= iWidth) offX = iWidth - 1;
if (offsetY.substring) offY = Math.round((parseFloat(offsetY) / 100) * iHeight);
else if (offsetY.toFixed && !isNaN(offsetY)) offY = offsetY;
if (offY < 0) offY = 0;
else if (offY >= iHeight) offY = iHeight - 1;
// The `angle` argument is the rotation applied to the points (using the offset coordinate as the rotation point), measured in degrees.
if (angle.toFixed && !isNaN(angle)) ang = angle;
let name = `${req}-tileset-${iWidth}-${iHeight}-${tileW}-${tileH}-${tileR}-${offX}-${offY}-${ang}`;
if (req === 'points-array') name += `-${pointVals.join(',')}`;
else if (req === 'random-points') name += `-${pointVals}-${seed}`;
// To save time, previous invocations of the function store their end result - an Array of Arrays containing the index position of each pixel in the source image, assigned to each tile.
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
// Returning an empty array to the tile filter results in the filter taking no action beyond copying the input image data into the output image data.
if (req === 'rect-grid' && tileW === 1 && tileH === 1) {
workstore[name] = [];
workstoreLastAccessed[name] = Date.now();
return [];
}
const coord = requestCoordinate(),
origin = [offX, offY],
test = [0, 0];
let tiles = [],
points = [],
referencePoints = [],
neighbourPoints = [];
let h, hz, w, wz, x, xz, y, yz,
pointsName = '';
// Check to stop the hex grid breaking when user supplies an inappropriately low `tileHeight` argument value, compared to the value supplied in the `tileRadius` argument.
if (req === 'hex-grid' && tileH / tileR < 1.05) tileH = tileR * 1.05;
let i, iz, cursor, ref,
counter = 0,
halfW = Math.floor(tileW / 2),
halfH = Math.floor(tileH / 2),
doubleR = tileR * 2,
hexDown = Math.round((tileH / tileR) * tileR),
hexOffset = 0;
switch (req) {
case 'rect-grid' :
pointsName = `rect-grid-points-${iWidth}-${iHeight}-${tileW}-${tileH}-${offX}-${offY}`;
if (workstore[pointsName]) {
workstoreLastAccessed[pointsName] = Date.now();
points = workstore[pointsName];
}
else {
// Generates a set of initial points in an overlarge grid (for square tiles)
for (y = offY - (iHeight * 2) + halfH, yz = offY + (iHeight * 2) + halfH; y < yz; y += tileH) {
for (x = offX - (iWidth * 2) + halfW, xz = offX + (iWidth * 2) + halfW; x < xz; x += tileW) {
points.push(x, y);
}
}
workstoreLastAccessed[pointsName] = Date.now();
workstore[pointsName] = points;
}
break;
case 'hex-grid' :
pointsName = `hex-grid-points-${iWidth}-${iHeight}-${tileR}-${offX}-${offY}`;
if (workstore[pointsName]) {
workstoreLastAccessed[pointsName] = Date.now();
points = workstore[pointsName];
}
else {
// Generates a set of initial points in an overlarge grid (for hexagonal tiles)
counter = 0;
for (y = offY - (iHeight * 2) + tileR, yz = offY + (iHeight * 2) + tileR; y < yz; y += hexDown) {
hexOffset = (counter % 2 === 0) ? tileR : 0;
for (x = offX - (iWidth * 2) + tileR + hexOffset, xz = offX + (iWidth * 2) + tileR; x < xz; x += doubleR) {
points.push(x, y);
}
counter++;
}
workstoreLastAccessed[pointsName] = Date.now();
workstore[pointsName] = points;
}
tileW = doubleR * 2;
tileH = hexDown * 2;
break;
case 'random-points' :
pointsName = `random-points-${iWidth}-${iHeight}-${tileR}-${offX}-${offY}-${points}-${seed}`;
if (workstore[pointsName]) {
workstoreLastAccessed[pointsName] = Date.now();
points = workstore[pointsName];
}
else {
// Generates a set of initial random points withing the given constraints
const rnd = this.getRandomNumbers(seed, pointVals * 3);
let rndCursor = -1;
for (i = 0; i < pointVals; i++) {
coord.zero().add([rnd[++rndCursor], rnd[++rndCursor]]).rotate(rnd[++rndCursor] * 360).rotate(ang).scalarMultiply(tileR);
[x, y] = coord;
points.push(Math.round(x), Math.round(y));
}
}
tileW = tileR;
tileH = tileR;
break;
case 'points-array' :
pointsName = `defined-points-${iWidth}-${iHeight}-${tileR}-${pointVals}`;
if (workstore[pointsName]) {
workstoreLastAccessed[pointsName] = Date.now();
points = workstore[pointsName];
}
// User-generated points are not pre-processed. Note that the positioning of these points is relative to the offset coordinate values; users, when generating the point values, need to take this into account otherwise the end result may unexpectedly move towards (or beyond) the bottom-right part of the final image.
else points.push(...pointVals);
tileW = tileR;
tileH = tileR;
break;
}
// Go through initial set of points
counter = 0;
for (i = 0, iz = points.length; i < iz; i += 2) {
test[0] = points[i];
test[1] = points[i + 1];
coord.zero().add(test).rotate(ang).add(origin);
[x, y] = coord;
x = Math.round(x);
y = Math.round(y);
if ((x > -tileW) && (x < iWidth + tileW) && (y > -tileH) && (y < iHeight + tileH)) {
cursor = ((iWidth * 2) * (iHeight * 2)) + ((y + Math.floor(iHeight / 2)) * iWidth) + (x + Math.floor(iWidth / 2));
referencePoints[counter] = [x, y, cursor];
tiles[cursor] = [];
for (h = y - tileH, hz = y + tileH; h < hz; h++) {
for (w = x - tileW, wz = x + tileW; w < wz; w++) {
if (w >= 0 && w < iWidth && h >= 0 && h < iHeight) {
if (req === 'random-points') {
if (coord.zero().subtract(origin).add([w, h]).getMagnitude() > tileR) continue;
}
ref = (h * iWidth) + w;
if (!neighbourPoints[ref]) neighbourPoints[ref] = [];
neighbourPoints[ref].push(counter);
}
}
}
counter++;
}
}
// Sanity check, in case none of the points survived the previous manipulation
if (!referencePoints.length) return referencePoints;
// Assign pixels to tile buckets
let minref, minlen, pixel, pixelRefs, distance;
for (h = 0; h < iHeight; h++) {
for (w = 0; w < iWidth; w++) {
pixel = (h * iWidth) + w;
test[0] = w;
test[1] = h;
pixelRefs = neighbourPoints[pixel];
minref = -1;
minlen = 0;
if (pixelRefs) {
pixelRefs.forEach(r => {
[x, y, cursor] = referencePoints[r];
distance = coord.zero().add(test).subtract([x, y]).getMagnitude();
if (minref < 0 || distance < minlen) {
minref = cursor;
minlen = distance;
}
});
}
if (minref >= 0) tiles[minref].push(pixel);
}
}
releaseCoordinate(coord);
// Filter the tiles Array to remove undefined indexes, then stash the result in the workstore (for future quick-serve) and return the array.
tiles = tiles.filter(t => t != null);
workstore[name] = tiles;
workstoreLastAccessed[name] = Date.now();
return tiles;
}
return [];
};
// `buildHorizontalBlur` - creates an Array of Arrays detailing which pixels contribute to the horizontal part of each pixel's blur calculation. Resulting object will be cached in the store
P.buildHorizontalBlur = function (grid, radius) {
const { workstore, workstoreLastAccessed } = this;
if (!radius || !radius.toFixed || isNaN(radius)) radius = 0;
const gridHeight = grid.length,
gridWidth = grid[0].length;
const name = `blur-h-${gridWidth}-${gridHeight}-${radius}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
let horizontalBlur = [],
cell;
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
let cellsToProcess = [];
for (let c = x - radius, cz = x + radius + 1; c < cz; c++) {
if (c >= 0 && c < gridWidth) cellsToProcess.push(grid[y][c] * 4);
}
horizontalBlur[(y * gridWidth) + x] = cellsToProcess;
}
}
workstore[name] = horizontalBlur;
workstoreLastAccessed[name] = Date.now();
return horizontalBlur;
};
// `buildVerticalBlur` - creates an Array of Arrays detailing which pixels contribute to the vertical part of each pixel's blur calculation. Resulting object will be cached in the store
P.buildVerticalBlur = function (grid, radius) {
const { workstore, workstoreLastAccessed } = this;
if (!radius || !radius.toFixed || isNaN(radius)) radius = 0;
const gridHeight = grid.length,
gridWidth = grid[0].length;
const name = `blur-v-${gridWidth}-${gridHeight}-${radius}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
let verticalBlur = [],
cell;
for (let x = 0; x < gridWidth; x++) {
for (let y = 0; y < gridHeight; y++) {
let cellsToProcess = [];
for (let c = y - radius, cz = y + radius + 1; c < cz; c++) {
if (c >= 0 && c < gridHeight) cellsToProcess.push(grid[c][x] * 4);
}
verticalBlur[(y * gridWidth) + x] = cellsToProcess;
}
}
workstore[name] = verticalBlur;
workstoreLastAccessed[name] = Date.now();
return verticalBlur;
};
// `buildMatrixGrid` - creates an Array of Arrays detailing which pixels contribute to each pixel's matrix calculation. Resulting object will be cached in the store
P.buildMatrixGrid = function (mWidth, mHeight, mX, mY, image) {
const { cache, workstore, workstoreLastAccessed } = this;
if (!image) image = cache.source;
const { width:iWidth, height:iHeight, data } = image;
if (mWidth == null || mWidth < 1) mWidth = 1;
if (mHeight == null || mHeight < 1) mHeight = 1;
if (mX == null || mX < 0) mX = 0;
else if (mX >= mWidth) mX = mWidth - 1;
if (mY == null || mY < 0) mY = 0;
else if (mY >= mHeight) mY = mHeight - 1;
const name = `matrix-${iWidth}-${iHeight}-${mWidth}-${mHeight}-${mX}-${mY}`;
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
let dataLength = data.length,
x, xz, y, yz, i, iz, pos, cell, val,
cellsTemplate = [],
grid = [];
for (y = -mY, yz = mHeight - mY; y < yz; y++) {
for (x = -mX, xz = mWidth - mX; x < xz; x++) {
cellsTemplate.push(((y * iWidth) + x) * 4);
}
}
for (y = 0; y < iHeight; y++) {
for (x = 0; x < iWidth; x++) {
pos = ((y * iWidth) + x) * 4;
cell = [];
for (i = 0, iz = cellsTemplate.length; i < iz; i++) {
val = pos + cellsTemplate[i];
if (val < 0) val += dataLength;
else if (val >= dataLength) val -= dataLength;
cell.push(val);
}
grid.push(cell);
}
}
workstore[name] = grid;
workstoreLastAccessed[name] = Date.now();
return grid;
};
// `checkChannelLevelsParameters` - divide each channel into discrete sequences of pixels
P.checkChannelLevelsParameters = function (f) {
const doCheck = function (v, isHigh = false) {
if (v.toFixed) {
if (v < 0) return [[0, 255, 0]];
if (v > 255) return [[0, 255, 255]];
if (isNaN(v)) return (isHigh) ? [[0, 255, 255]] : [[0, 255, 0]];
return [[0, 255, v]];
}
if (v.substring) {
v = v.split(',');
}
if (Array.isArray(v)) {
if (!v.length) return v;
if (Array.isArray(v[0])) return v;
v = v.map(s => parseInt(s, 10));
v.sort((a, b) => a - b);
if (v.length == 1) return [[0, 255, v[0]]];
let res = [],
starts, ends;
for (let i = 0, iz = v.length; i < iz; i++) {
starts = 0;
ends = 255;
if (i != 0) starts = Math.ceil(v[i - 1] + ((v[i] - v[i - 1]) / 2));
if (i != iz - 1) ends = Math.floor(v[i] + ((v[i + 1] - v[i]) / 2));
res.push([starts, ends, v[i]]);
}
return res;
}
return (isHigh) ? [[0, 255, 255]] : [[0, 255, 0]];
}
f.red = doCheck(f.red);
f.green = doCheck(f.green);
f.blue = doCheck(f.blue);
f.alpha = doCheck(f.alpha, true);
};
// `cacheOutput` - insert an action function's output into the filter engine's cache
P.cacheOutput = function (name, obj, caller) {
cache[name] = obj;
};
// `getInputAndOutputLines` - determine, and return, the appropriate results object for the lineIn, lineMix and lineOut values supplied to each action function when it gets invoked
P.getInputAndOutputLines = function (requirements) {
let { cache } = this;
let lineIn = cache.work,
lineMix = false,
sourceData = cache.source,
alphaData = false;
if (requirements.lineIn === 'source-alpha' || requirements.lineMix === 'source-alpha') alphaData = this.getAlphaData(sourceData);
if (requirements.lineIn) {
if (requirements.lineIn === 'source') lineIn = sourceData;
else if (requirements.lineIn == 'source-alpha') lineIn = alphaData;
else if (cache[requirements.lineIn]) lineIn = cache[requirements.lineIn];
}
if (requirements.lineMix) {
if (requirements.lineMix == 'source') lineMix = sourceData;
else if (requirements.lineMix == 'source-alpha') lineMix = alphaData;
else if (requirements.lineMix == 'current') lineMix = cache.work;
else if (cache[requirements.lineMix]) lineMix = cache[requirements.lineMix];
}
let lineOut;
if (!requirements.lineOut || !cache[requirements.lineOut]) {
lineOut = new ImageData(lineIn.width, lineIn.height);
if (requirements.lineOut) cache[requirements.lineOut] = lineOut;
}
else lineOut = cache[requirements.lineOut];
return [lineIn, lineOut, lineMix];
};
// `getGrayscaleValue` - put here because this calculation is used in several different filters
P.getGrayscaleValue = function (r, g, b) {
return Math.floor((0.2126 * r) + (0.7152 * g) + (0.0722 * b));
};
// `processResults` - at the conclusion of each action function, combine the results of the function's manipulations back into the data supplied for manipulation, in line with the value of the action object's `opacity` attribute
P.processResults = function (store, incoming, ratio) {
let sData = store.data,
iData = incoming.data,
antiRatio;
if (ratio === 1) {
for (let i = 0, iz = sData.length; i < iz; i++) {
sData[i] = iData[i];
}
}
else if (ratio > 0) {
antiRatio = 1 - ratio;
for (let i = 0, iz = sData.length; i < iz; i++) {
sData[i] = (sData[i] * antiRatio) + (iData[i] * ratio);
}
}
};
// The filter Color object - used by various filters
P.colorEngine = makeColor({
name: `filterEngine-colorEngine-do-not-overwrite`,
});
// `getGradientData` - create an imageData object containing the 256 values from a gradient that we require for doing filters work
P.getGradientData = function (gradient) {
const { workstore, workstoreLastAccessed } = this;
const name = `gradient-data-${gradient.name}`;
if (!workstore[name] || gradient.dirtyFilterIdentifier || gradient.animateByDelta) {
const mycell = requestCell();
const {engine, element} = mycell;
element.width = 256;
element.height = 1;
gradient.palette.recalculate();
const G = engine.createLinearGradient(0, 0, 255, 0);
gradient.addStopsToGradient(G, gradient.paletteStart, gradient.paletteEnd, gradient.cyclePalette);
engine.fillStyle = G;
engine.fillRect(0, 0, 256, 1);
let data = engine.getImageData(0, 0, 256, 1).data;
releaseCell(mycell);
workstore[name] = data;
}
if (workstore[name]) {
workstoreLastAccessed[name] = Date.now();
return workstore[name];
}
return [];
};
P.transferDataUnchanged = function (oData, iData, len) {
let r, g, b, a, i;
for (i = 0; i < len; i += 4) {
r = i;
g = r + 1;
b = g + 1;
a = b + 1;
oData[r] = iData[r];
oData[g] = iData[g];
oData[b] = iData[b];
oData[a] = iData[a];
}
};
P.newspaperPatterns = [
[0, 0, 0, 0],
[0, 0, 0, 180],
[180, 0, 0, 0],
[180, 0, 0, 180],
[0, 180, 180, 180],
[180, 180, 180, 0],
[180, 180, 180, 180],
[180, 180, 180, 255],
[255, 180, 180, 180],
[255, 180, 180, 255],
[180, 255, 255, 255],
[255, 255, 255, 180],
[255, 255, 255, 255]
];
// ## Filter action functions
// Each function is held in the `theBigActionsObject` object, for convenience
P.theBigActionsObject = {
// __alpha-to-channels__ - Copies the alpha channel value over to the selected value or, alternatively, sets that channel's value to zero, or leaves the channel's value unchanged. Setting the appropriate "includeChannel" flags will copy the alpha channel value to that channel; when that flag is false, setting the appropriate "excludeChannel" flag will set that channel's value to zero.
'alpha-to-channels': function (requirements) {
let [input, output] = this.getInputAndOutputLines(requirements);
let iData = input.data,
oData = output.data,
len = iData.length,
r, g, b, a, aVal, i;
let {opacity, includeRed, includeGreen, includeBlue, excludeRed, excludeGreen, excludeBlue, lineOut} = requirements;
if (null == opacity) opacity = 1;
if (null == includeRed) includeRed = true;
if (null == includeGreen) includeGreen = true;
if (null == includeBlue) includeBlue = true;
if (null == excludeRed) excludeRed = true;
if (null == excludeGreen) excludeGreen = true;
if (null == excludeBlue) excludeBlue = true;
for (i = 0; i < len; i += 4) {
r = i;
g = r + 1;
b = g + 1;
a = b + 1;
aVal = iData[a];
if (aVal) {
oData[r] = (includeRed) ? aVal : ((excludeRed) ? 0 : iData[r]);
oData[g] = (includeGreen) ? aVal : ((excludeGreen) ? 0 : iData[g]);
oData[b] = (includeBlue) ? aVal : ((excludeBlue) ? 0 : iData[b]);
oData[a] = 255;
}
}
if (lineOut) this.processResults(output, input, 1 - opacity);
else this.processResults(this.cache.work, output, opacity);
},
// __area-alpha__ - Places a tile schema across the input, quarters each tile and then sets the alpha channels of the pixels in selected quarters of each tile to zero. Can be used to create horizontal or vertical bars, or chequerboard effects.
'area-alpha': function (requirements) {
let [input, output] = this.getInputAndOutputLines(requirements);
let iData = input.data,
oData = output.data,
len = iData.length,
r, g, b, a, i, j, jz, tVal;
let {opacity, tileWidth, tileHeight, offsetX, offsetY, gutterWidth, gutterHeight, areaAlphaLevels, lineOut } = requirements;
if (null == opacity) opacity = 1;
if (null == tileWidth) tileWidth = 1;
if (null == tileHeight) tileHeight = 1;
if (null == offsetX) offsetX = 0;
if (null == offsetY) offsetY = 0;
if (null == gutterWidth) gutterWidth = 1;
if (null == gutterHeight) gutterHeight = 1;
if (null == areaAlphaLevels) areaAlphaLevels = [255,0,0,0];
let tiles = this.buildAlphaTileSets(tileWidth, tileHeight, gutterWidth, gutterHeight, offsetX, offsetY, areaAlphaLevels);
this.transferDataUnchanged(oData, iData, len);
tiles.forEach((t, index) => {
a = areaAlphaLevels[index % 4];
for (j = 0, jz = t.length; j < jz; j++) {
tVal = t[j];
if (iData[tVal]) oData[tVal] = a;
}
});
if (lineOut) this.processResults(output, input, 1 - opacity);
else this.processResults(this.cache.work, output, opacity);
},
// __average-channels__ - Calculates an average value from each pixel's included channels and applies that value to all channels that have not been specifically excluded; excluded channels have their values set to 0.
'average-channels': function (requirements) {
let [input, output] = this.getInputAndOutputLines(requirements);
let iData = input.data,
oData = output.data,
len = iData.length,
i, avg, r, g, b, a;
let {opacity, includeRed, includeGreen, includeBlue, excludeRed, excludeGreen, excludeBlue, lineOut} = requirements;
if (null == opacity) opacity = 1;
if (null == includeRed) includeRed = true;
if (null == includeGreen) includeGreen = true;
if (null == includeBlue) includeBlue = true;
if (null == excludeRed) excludeRed = false;
if (null == excludeGreen) excludeGreen = false;
if (null == excludeBlue) excludeBlue = false;
let divisor = 0;
if (includeRed) divisor++;
if (includeGreen) divisor++;
if (includeBlue) divisor++;
for (i = 0; i < len; i += 4) {
r = i;
g = r + 1;
b = g + 1;
a = b + 1;
if (iData[a]) {
if (divisor) {
avg = 0;
if (includeRed) avg += iData[r];
if (includeGreen) avg += iData[g];
if (includeBlue) avg += iData[b];
avg = Math.floor(avg / divisor);
oData[r] = (excludeRed) ? 0 : avg;
oData[g] = (excludeGreen) ? 0 : avg;
oData[b] = (excludeBlue) ? 0 : avg;
oData[a] = iData[a];
}
else {
oData[r] = (excludeRed) ? 0 : iData[r];
oData[g] = (excludeGreen) ? 0 : iData[g];
oData[b] = (excludeBlue) ? 0 : iData[b];
oData[a] = iData[a];
}
}
else {
oData[r] = iData[r];
oData[g] = iData[g];
oData[b] = iData[b];
oData[a] = iData[a];
}
}
if (lineOut) this.processResults(output, input, 1 - opacity);
else this.processResults(this.cache.work, output, opacity);
},
// DEPRECATED! __binary__ - use the updated `threshold` filter instead, which now incorporates binary filter functionality
//
// __blend__ - Using two source images (from the "lineIn" and "lineMix" arguments), combine their color information using various separable and non-separable blend modes (as defined by the W3C Compositing and Blending Level 1 recommendations.
// + The blending method is determined by the String value supplied in the "blend" argument; permitted values are: 'color-burn', 'color-dodge', 'darken', 'difference', 'exclusion', 'hard-light', 'lighten', 'lighter', 'multiply', 'overlay', 'screen', 'soft-light', 'color', 'hue', 'luminosity', and 'saturation'.
// + Note that the source images may be of different sizes: the output (lineOut) image size will be the same as the source (NOT lineIn) image; the lineMix image can be moved relative to the lineIn image using the "offsetX" and "offsetY" arguments.
'blend': function (requirements) {
const copyPixel = function (fr, tr, data) {
let fg = fr + 1,
fb = fg + 1,
fa = fb + 1,
tg = tr + 1,
tb = tg + 1,
ta = tb + 1;
oData[tr] = data[fr];
oData[tg] = data[fg];
oData[tb] = data[fb];
oData[ta] = data[fa];
};
const getLinePositions = function (x, y) {
let ix = x,
iy = y,
mx = x - offsetX,
my = y - offsetY;
let mPos = -1,
iPos = ((iy * iWidth) + ix) * 4;
if (mx >= 0 && mx < mWidth && my >= 0 && my < mHeight) mPos = ((my * mWidth) + mx) * 4;
return [iPos, mPos];
};
const getChannelNormals = function (irn, mrn) {
let ign = irn + 1,
ibn = ign + 1,
ian = ibn + 1,
mgn = mrn + 1,
mbn = mgn + 1,
man = mbn + 1;
return [
iData[irn] / 255,
iData[ign] / 255,
iData[ibn] / 255,
iData[ian] / 255,
mData[mrn] / 255,
mData[mgn] / 255,
mData[mbn] / 255,
mData[man] / 255
];
};
const alphaCalc = (dA, mA) => (dA + (mA * (1 - dA))) * 255;
const colorEngine = this.colorEngine;
let [input, output, mix] = this.getInputAndOutputLines(requirements);
let {width:iWidth, height:iHeight, data:iData} = input;
let {width:oWidth, height:oHeight, data:oData} = output;
let {width:mWidth, height:mHeight, data:mData} = mix;
let len = iData.length;
let {opacity, blend, offsetX, offsetY, lineOut} = requirements;
if (null == opacity) opacity = 1;
if (null == blend) blend = '';
if (null == offsetX) offsetX = 0;
if (null == offsetY) offsetY = 0;
let x, y, dinR, dinG, dinB, dinA, dmixR, dmixG, dmixB, dmixA, ir, ig, ib, ia, mr, mg, mb, ma, cr, cg, cb;
switch (blend) {
case 'color-burn' :
const colorburnCalc = (din, dmix) => {
if (dmix == 1) return 255;
else if (din == 0) return 0;
return (1 - Math.min(1, ((1 - dmix) / din ))) * 255;
};
for (y = 0; y < iHeight; y++) {
for (x = 0; x < iWidth; x++) {
[ir, mr] = getLinePositions(x, y);
ia = ir + 3;
ma = mr + 3;
if (iData[ia]) {
if (mr < 0) copyPixel(ir, ir, iData);
else if (!iData[ia]) copyPixel(mr, ir, mData);
else if (!mData[ma]) copyPixel(ir, ir, iData);
else {
[dinR, dinG, dinB, dinA, dmixR, dmixG, dmixB, dmixA] = getChannelNormals(ir, mr);
ig = ir + 1;
ib = ig + 1;
oData[ir] = colorburnCalc(dinR, dmixR);
oData[ig] = colorburnCalc(dinG, dmixG);
oData[ib] = colorburnCalc(dinB, dmixB);
oData[ia] = alphaCalc(dinA, dmixA);
}
}
}
}
break;
case 'color-dodge' :
const colordodgeCalc = (din, dmix) => {
if (dmix == 0) return 0;
else if (din == 1) return 255;
return Math.min(1, (dmix / (1 - din))) * 255;
};
for (y = 0; y < iHeight; y++) {
for (x = 0; x < iWidth; x++) {
[ir, mr] = getLinePositions(x, y);
ia = ir + 3;
ma = mr + 3;
if (iData[ia]) {
if (mr < 0) copyPixel(ir, ir, iData);
else if (!iData[ia]) copyPixel(mr, ir, mData);
else if (!mData[ma]) copyPixel(ir, ir, iData);
else {
[dinR, dinG, dinB, dinA, dmixR, dmixG, dmixB, dmixA] = getChannelNormals(ir, mr);
ig = ir + 1;
ib = ig + 1;
oData[ir] = colordodgeCalc(dinR, dmixR);
oData[ig] = colordodgeCalc(dinG, dmixG);
oData[ib] = colordodgeCalc(dinB, dmixB);
oData[ia] = alphaCalc(dinA, dmixA);
}
}
}
}
break;
case 'darken' :
const darkenCalc = (din, dmix) => (din < dmix) ? din : dmix;
for (y = 0; y < iHeight; y++) {
for (x = 0; x < iWidth; x++) {
[ir, mr] = getLinePositions(x, y);
ia = ir + 3;
ma = mr + 3;
if (iData[ia]) {
if (mr < 0) copyPixel(ir, ir, iData);
else if (!iData[ia]) copyPixel(mr, ir, mData);
else if (!mData[ma]) copyPixel(ir, ir, iData);
else {
ig = ir + 1;
ib = ig + 1;
mg = mr + 1;
mb = mg + 1;
oData[ir] = darkenCalc(iD