'use strict'; const assign = require('object-assign'); const webworkify = require('webworkify'); const MathLib = require('./lib/mathlib'); const Pool = require('./lib/pool'); const utils = require('./lib/utils'); const worker = require('./lib/worker'); const createStages = require('./lib/stepper'); const createRegions = require('./lib/tiler'); // Deduplicate pools & limiters with the same configs // when user creates multiple pica instances. const singletones = {}; let NEED_SAFARI_FIX = false; try { if (typeof navigator !== 'undefined' && navigator.userAgent) { NEED_SAFARI_FIX = navigator.userAgent.indexOf('Safari') >= 0; } } catch (e) {} let concurrency = 1; if (typeof navigator !== 'undefined') { concurrency = Math.min(navigator.hardwareConcurrency || 1, 4); } const DEFAULT_PICA_OPTS = { tile: 1024, concurrency, features: [ 'js', 'wasm', 'ww' ], idle: 2000, createCanvas: function (width, height) { let tmpCanvas = document.createElement('canvas'); tmpCanvas.width = width; tmpCanvas.height = height; return tmpCanvas; } }; const DEFAULT_RESIZE_OPTS = { quality: 3, alpha: false, unsharpAmount: 0, unsharpRadius: 0.0, unsharpThreshold: 0 }; let CAN_NEW_IMAGE_DATA = false; let CAN_CREATE_IMAGE_BITMAP = false; let CAN_USE_CANVAS_GET_IMAGE_DATA = false; let CAN_USE_OFFSCREEN_CANVAS = false; let CAN_USE_CIB_REGION_FOR_IMAGE = false; function workerFabric() { return { value: webworkify(worker), destroy: function () { this.value.terminate(); if (typeof window !== 'undefined') { let url = window.URL || window.webkitURL || window.mozURL || window.msURL; if (url && url.revokeObjectURL && this.value.objectURL) { url.revokeObjectURL(this.value.objectURL); } } } }; } //////////////////////////////////////////////////////////////////////////////// // API methods function Pica(options) { if (!(this instanceof Pica)) return new Pica(options); this.options = assign({}, DEFAULT_PICA_OPTS, options || {}); let limiter_key = `lk_${this.options.concurrency}`; // Share limiters to avoid multiple parallel workers when user creates // multiple pica instances. this.__limit = singletones[limiter_key] || utils.limiter(this.options.concurrency); if (!singletones[limiter_key]) singletones[limiter_key] = this.__limit; // List of supported features, according to options & browser/node.js this.features = { js: false, // pure JS implementation, can be disabled for testing wasm: false, // webassembly implementation for heavy functions cib: false, // resize via createImageBitmap (only FF at this moment) ww: false // webworkers }; this.__workersPool = null; // Store requested features for webworkers this.__requested_features = []; this.__mathlib = null; } Pica.prototype.init = function () { if (this.__initPromise) return this.__initPromise; // Test if we can create ImageData without canvas and memory copy if (typeof ImageData !== 'undefined' && typeof Uint8ClampedArray !== 'undefined') { try { /* eslint-disable no-new */ new ImageData(new Uint8ClampedArray(400), 10, 10); CAN_NEW_IMAGE_DATA = true; } catch (__) {} } // ImageBitmap can be effective in 2 places: // // 1. Threaded jpeg unpack (basic) // 2. Built-in resize (blocked due problem in chrome, see issue #89) // // For basic use we also need ImageBitmap wo support .close() method, // see https://developer.mozilla.org/ru/docs/Web/API/ImageBitmap if (typeof ImageBitmap !== 'undefined') { if (ImageBitmap.prototype && ImageBitmap.prototype.close) { CAN_CREATE_IMAGE_BITMAP = true; } else { this.debug('ImageBitmap does not support .close(), disabled'); } } let features = this.options.features.slice(); if (features.indexOf('all') >= 0) { features = [ 'cib', 'wasm', 'js', 'ww' ]; } this.__requested_features = features; this.__mathlib = new MathLib(features); // Check WebWorker support if requested if (features.indexOf('ww') >= 0) { if ((typeof window !== 'undefined') && ('Worker' in window)) { // IE <= 11 don't allow to create webworkers from string. We should check it. // https://connect.microsoft.com/IE/feedback/details/801810/web-workers-from-blob-urls-in-ie-10-and-11 try { let wkr = require('webworkify')(function () {}); wkr.terminate(); this.features.ww = true; // pool uniqueness depends on pool config + webworker config let wpool_key = `wp_${JSON.stringify(this.options)}`; if (singletones[wpool_key]) { this.__workersPool = singletones[wpool_key]; } else { this.__workersPool = new Pool(workerFabric, this.options.idle); singletones[wpool_key] = this.__workersPool; } } catch (__) {} } } let initMath = this.__mathlib.init().then(mathlib => { // Copy detected features assign(this.features, mathlib.features); }); let checkCibResize; if (!CAN_CREATE_IMAGE_BITMAP) { checkCibResize = Promise.resolve(false); } else { checkCibResize = utils.cib_support(this.options.createCanvas).then(status => { if (this.features.cib && features.indexOf('cib') < 0) { this.debug('createImageBitmap() resize supported, but disabled by config'); return; } if (features.indexOf('cib') >= 0) this.features.cib = status; }); } CAN_USE_CANVAS_GET_IMAGE_DATA = utils.can_use_canvas(this.options.createCanvas); let checkOffscreenCanvas; if (CAN_CREATE_IMAGE_BITMAP && CAN_NEW_IMAGE_DATA && features.indexOf('ww') !== -1) { checkOffscreenCanvas = utils.worker_offscreen_canvas_support(); } else { checkOffscreenCanvas = Promise.resolve(false); } checkOffscreenCanvas = checkOffscreenCanvas.then( result => { CAN_USE_OFFSCREEN_CANVAS = result; } ); // we use createImageBitmap to crop image data and pass it to workers, // so need to check whether function works correctly; // https://bugs.chromium.org/p/chromium/issues/detail?id=1220671 let checkCibRegion = utils.cib_can_use_region().then( result => { CAN_USE_CIB_REGION_FOR_IMAGE = result; } ); // Init math lib. That's async because can load some this.__initPromise = Promise.all([ initMath, checkCibResize, checkOffscreenCanvas, checkCibRegion ]).then(() => this); return this.__initPromise; }; // Call resizer in webworker or locally, depending on config Pica.prototype.__invokeResize = function (tileOpts, opts) { // Share cache between calls: // // - wasm instance // - wasm memory object // opts.__mathCache = opts.__mathCache || {}; return Promise.resolve().then(() => { if (!this.features.ww) { // not possible to have ImageBitmap here if user disabled WW return { data: this.__mathlib.resizeAndUnsharp(tileOpts, opts.__mathCache) }; } return new Promise((resolve, reject) => { let w = this.__workersPool.acquire(); if (opts.cancelToken) opts.cancelToken.catch(err => reject(err)); w.value.onmessage = ev => { w.release(); if (ev.data.err) reject(ev.data.err); else resolve(ev.data); }; let transfer = []; if (tileOpts.src) transfer.push(tileOpts.src.buffer); if (tileOpts.srcBitmap) transfer.push(tileOpts.srcBitmap); w.value.postMessage({ opts: tileOpts, features: this.__requested_features, preload: { wasm_nodule: this.__mathlib.__ } }, transfer); }); }); }; // this function can return promise if createImageBitmap is used Pica.prototype.__extractTileData = function (tile, from, opts, stageEnv, extractTo) { if (this.features.ww && CAN_USE_OFFSCREEN_CANVAS && // createImageBitmap doesn't work for images (Image, ImageBitmap) with Exif orientation in Chrome, // can use canvas because canvas doesn't have orientation; // see https://bugs.chromium.org/p/chromium/issues/detail?id=1220671 (utils.isCanvas(from) || CAN_USE_CIB_REGION_FOR_IMAGE)) { this.debug('Create tile for OffscreenCanvas'); return createImageBitmap(stageEnv.srcImageBitmap || from, tile.x, tile.y, tile.width, tile.height) .then(bitmap => { extractTo.srcBitmap = bitmap; return extractTo; }); } // Extract tile RGBA buffer, depending on input type if (utils.isCanvas(from)) { if (!stageEnv.srcCtx) stageEnv.srcCtx = from.getContext('2d', { alpha: Boolean(opts.alpha) }); // If input is Canvas - extract region data directly this.debug('Get tile pixel data'); extractTo.src = stageEnv.srcCtx.getImageData(tile.x, tile.y, tile.width, tile.height).data; return extractTo; } // If input is Image or decoded to ImageBitmap, // draw region to temporary canvas and extract data from it // // Note! Attempt to reuse this canvas causes significant slowdown in chrome // this.debug('Draw tile imageBitmap/image to temporary canvas'); let tmpCanvas = this.options.createCanvas(tile.width, tile.height); let tmpCtx = tmpCanvas.getContext('2d', { alpha: Boolean(opts.alpha) }); tmpCtx.globalCompositeOperation = 'copy'; tmpCtx.drawImage(stageEnv.srcImageBitmap || from, tile.x, tile.y, tile.width, tile.height, 0, 0, tile.width, tile.height); this.debug('Get tile pixel data'); extractTo.src = tmpCtx.getImageData(0, 0, tile.width, tile.height).data; // Safari 12 workaround // https://github.com/nodeca/pica/issues/199 tmpCanvas.width = tmpCanvas.height = 0; return extractTo; }; Pica.prototype.__landTileData = function (tile, result, stageEnv) { let toImageData; this.debug('Convert raw rgba tile result to ImageData'); if (result.bitmap) { stageEnv.toCtx.drawImage(result.bitmap, tile.toX, tile.toY); return null; } if (CAN_NEW_IMAGE_DATA) { // this branch is for modern browsers // If `new ImageData()` & Uint8ClampedArray suported toImageData = new ImageData(new Uint8ClampedArray(result.data), tile.toWidth, tile.toHeight); } else { // fallback for `node-canvas` and old browsers // (IE11 has ImageData but does not support `new ImageData()`) toImageData = stageEnv.toCtx.createImageData(tile.toWidth, tile.toHeight); if (toImageData.data.set) { toImageData.data.set(result.data); } else { // IE9 don't have `.set()` for (let i = toImageData.data.length - 1; i >= 0; i--) { toImageData.data[i] = result.data[i]; } } } this.debug('Draw tile'); if (NEED_SAFARI_FIX) { // Safari draws thin white stripes between tiles without this fix stageEnv.toCtx.putImageData(toImageData, tile.toX, tile.toY, tile.toInnerX - tile.toX, tile.toInnerY - tile.toY, tile.toInnerWidth + 1e-5, tile.toInnerHeight + 1e-5); } else { stageEnv.toCtx.putImageData(toImageData, tile.toX, tile.toY, tile.toInnerX - tile.toX, tile.toInnerY - tile.toY, tile.toInnerWidth, tile.toInnerHeight); } return null; }; Pica.prototype.__tileAndResize = function (from, to, opts) { let stageEnv = { srcCtx: null, srcImageBitmap: null, isImageBitmapReused: false, toCtx: null }; const processTile = (tile => this.__limit(() => { if (opts.canceled) return opts.cancelToken; let tileOpts = { width: tile.width, height: tile.height, toWidth: tile.toWidth, toHeight: tile.toHeight, scaleX: tile.scaleX, scaleY: tile.scaleY, offsetX: tile.offsetX, offsetY: tile.offsetY, quality: opts.quality, alpha: opts.alpha, unsharpAmount: opts.unsharpAmount, unsharpRadius: opts.unsharpRadius, unsharpThreshold: opts.unsharpThreshold }; this.debug('Invoke resize math'); return Promise.resolve(tileOpts) .then(tileOpts => this.__extractTileData(tile, from, opts, stageEnv, tileOpts)) .then(tileOpts => { this.debug('Invoke resize math'); return this.__invokeResize(tileOpts, opts); }) .then(result => { if (opts.canceled) return opts.cancelToken; stageEnv.srcImageData = null; return this.__landTileData(tile, result, stageEnv); }); })); // Need to normalize data source first. It can be canvas or image. // If image - try to decode in background if possible return Promise.resolve().then(() => { stageEnv.toCtx = to.getContext('2d', { alpha: Boolean(opts.alpha) }); if (utils.isCanvas(from)) return null; if (utils.isImageBitmap(from)) { stageEnv.srcImageBitmap = from; stageEnv.isImageBitmapReused = true; return null; } if (utils.isImage(from)) { // try do decode image in background for faster next operations; // if we're using offscreen canvas, cib is called per tile, so not needed here if (!CAN_CREATE_IMAGE_BITMAP) return null; this.debug('Decode image via createImageBitmap'); return createImageBitmap(from) .then(imageBitmap => { stageEnv.srcImageBitmap = imageBitmap; }) // Suppress error to use fallback, if method fails // https://github.com/nodeca/pica/issues/190 /* eslint-disable no-unused-vars */ .catch(e => null); } throw new Error('Pica: ".from" should be Image, Canvas or ImageBitmap'); }) .then(() => { if (opts.canceled) return opts.cancelToken; this.debug('Calculate tiles'); // // Here we are with "normalized" source, // follow to tiling // let regions = createRegions({ width: opts.width, height: opts.height, srcTileSize: this.options.tile, toWidth: opts.toWidth, toHeight: opts.toHeight, destTileBorder: opts.__destTileBorder }); let jobs = regions.map(tile => processTile(tile)); function cleanup(stageEnv) { if (stageEnv.srcImageBitmap) { if (!stageEnv.isImageBitmapReused) stageEnv.srcImageBitmap.close(); stageEnv.srcImageBitmap = null; } } this.debug('Process tiles'); return Promise.all(jobs).then( () => { this.debug('Finished!'); cleanup(stageEnv); return to; }, err => { cleanup(stageEnv); throw err; } ); }); }; Pica.prototype.__processStages = function (stages, from, to, opts) { if (opts.canceled) return opts.cancelToken; let [ toWidth, toHeight ] = stages.shift(); let isLastStage = (stages.length === 0); opts = assign({}, opts, { toWidth, toHeight, // only use user-defined quality for the last stage, // use simpler (Hamming) filter for the first stages where // scale factor is large enough (more than 2-3) quality: isLastStage ? opts.quality : Math.min(1, opts.quality) }); let tmpCanvas; if (!isLastStage) { // create temporary canvas tmpCanvas = this.options.createCanvas(toWidth, toHeight); } return this.__tileAndResize(from, (isLastStage ? to : tmpCanvas), opts) .then(() => { if (isLastStage) return to; opts.width = toWidth; opts.height = toHeight; return this.__processStages(stages, tmpCanvas, to, opts); }) .then(res => { if (tmpCanvas) { // Safari 12 workaround // https://github.com/nodeca/pica/issues/199 tmpCanvas.width = tmpCanvas.height = 0; } return res; }); }; Pica.prototype.__resizeViaCreateImageBitmap = function (from, to, opts) { let toCtx = to.getContext('2d', { alpha: Boolean(opts.alpha) }); this.debug('Resize via createImageBitmap()'); return createImageBitmap(from, { resizeWidth: opts.toWidth, resizeHeight: opts.toHeight, resizeQuality: utils.cib_quality_name(opts.quality) }) .then(imageBitmap => { if (opts.canceled) return opts.cancelToken; // if no unsharp - draw directly to output canvas if (!opts.unsharpAmount) { toCtx.drawImage(imageBitmap, 0, 0); imageBitmap.close(); toCtx = null; this.debug('Finished!'); return to; } this.debug('Unsharp result'); let tmpCanvas = this.options.createCanvas(opts.toWidth, opts.toHeight); let tmpCtx = tmpCanvas.getContext('2d', { alpha: Boolean(opts.alpha) }); tmpCtx.drawImage(imageBitmap, 0, 0); imageBitmap.close(); let iData = tmpCtx.getImageData(0, 0, opts.toWidth, opts.toHeight); this.__mathlib.unsharp_mask( iData.data, opts.toWidth, opts.toHeight, opts.unsharpAmount, opts.unsharpRadius, opts.unsharpThreshold ); toCtx.putImageData(iData, 0, 0); // Safari 12 workaround // https://github.com/nodeca/pica/issues/199 tmpCanvas.width = tmpCanvas.height = 0; iData = tmpCtx = tmpCanvas = toCtx = null; this.debug('Finished!'); return to; }); }; Pica.prototype.resize = function (from, to, options) { this.debug('Start resize...'); let opts = assign({}, DEFAULT_RESIZE_OPTS); if (!isNaN(options)) { opts = assign(opts, { quality: options }); } else if (options) { opts = assign(opts, options); } opts.toWidth = to.width; opts.toHeight = to.height; opts.width = from.naturalWidth || from.width; opts.height = from.naturalHeight || from.height; // Prevent stepper from infinite loop if (to.width === 0 || to.height === 0) { return Promise.reject(new Error(`Invalid output size: ${to.width}x${to.height}`)); } if (opts.unsharpRadius > 2) opts.unsharpRadius = 2; opts.canceled = false; if (opts.cancelToken) { // Wrap cancelToken to avoid successive resolve & set flag opts.cancelToken = opts.cancelToken.then( data => { opts.canceled = true; throw data; }, err => { opts.canceled = true; throw err; } ); } let DEST_TILE_BORDER = 3; // Max possible filter window size opts.__destTileBorder = Math.ceil(Math.max(DEST_TILE_BORDER, 2.5 * opts.unsharpRadius|0)); return this.init().then(() => { if (opts.canceled) return opts.cancelToken; // if createImageBitmap supports resize, just do it and return if (this.features.cib) { return this.__resizeViaCreateImageBitmap(from, to, opts); } if (!CAN_USE_CANVAS_GET_IMAGE_DATA) { let err = new Error('Pica: cannot use getImageData on canvas, ' + "make sure fingerprinting protection isn't enabled"); err.code = 'ERR_GET_IMAGE_DATA'; throw err; } // // No easy way, let's resize manually via arrays // let stages = createStages( opts.width, opts.height, opts.toWidth, opts.toHeight, this.options.tile, opts.__destTileBorder ); return this.__processStages(stages, from, to, opts); }); }; // RGBA buffer resize // Pica.prototype.resizeBuffer = function (options) { const opts = assign({}, DEFAULT_RESIZE_OPTS, options); return this.init() .then(() => this.__mathlib.resizeAndUnsharp(opts)); }; Pica.prototype.toBlob = function (canvas, mimeType, quality) { mimeType = mimeType || 'image/png'; return new Promise(resolve => { if (canvas.toBlob) { canvas.toBlob(blob => resolve(blob), mimeType, quality); return; } if (canvas.convertToBlob) { resolve(canvas.convertToBlob({ type: mimeType, quality })); return; } // Fallback for old browsers const asString = atob(canvas.toDataURL(mimeType, quality).split(',')[1]); const len = asString.length; const asBuffer = new Uint8Array(len); for (let i = 0; i < len; i++) { asBuffer[i] = asString.charCodeAt(i); } resolve(new Blob([ asBuffer ], { type: mimeType })); }); }; Pica.prototype.debug = function () {}; module.exports = Pica;