From 96903e0266d47727c9be617078169ee946a35497 Mon Sep 17 00:00:00 2001 From: RebeccaHirst <30597180+RebeccaHirst@users.noreply.github.com> Date: Thu, 6 Jan 2022 16:39:08 +0000 Subject: [PATCH 01/52] add linspace to util.js --- src/util/Util.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/util/Util.js b/src/util/Util.js index 2e01674..3d93bbf 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -358,6 +358,24 @@ export function shuffle(array, randomNumberGenerator = undefined) return array; } +/** + * linspace + * + * @name module:util.linspace + * @function + * @public + * @param {Object[]} startValue, stopValue, cardinality + * @return {Object[]} an array from startValue to stopValue with cardinality steps + */ +export function linspace(startValue, stopValue, cardinality) { + var arr = []; + var step = (stopValue - startValue) / (cardinality - 1); + for (var i = 0; i < cardinality; i++) { + arr.push(startValue + (step * i)); + } + return arr; +} + /** * Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. * From 7cf5e148a3c719aa058da6e9a278a256decf291c Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 30 Jun 2022 04:20:21 +0300 Subject: [PATCH 02/52] first approximation to gifstim; --- package.json | 1 + src/core/ServerManager.js | 6 +- src/visual/GifStim.js | 371 ++++++++++++++++++++++++++++++++++++++ src/visual/index.js | 1 + 4 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/visual/GifStim.js diff --git a/package.json b/package.json index 23a7242..297d505 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@pixi/filter-adjustment": "^4.1.3", + "@pixi/gif": "^1.1.0", "a11y-dialog": "^7.5.0", "esbuild-plugin-glsl": "^1.0.5", "howler": "^2.2.1", diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 66ddda1..93d8c7b 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -8,6 +8,7 @@ */ import { Howl } from "howler"; +// import { Loader } from "@pixi/loaders"; import { ExperimentHandler } from "../data/ExperimentHandler.js"; import { Clock, MonotonicClock } from "../util/Clock.js"; import { PsychObject } from "../util/PsychObject.js"; @@ -53,7 +54,6 @@ export class ServerManager extends PsychObject this._nbLoadedResources = 0; this._setupPreloadQueue(); - this._addAttribute("autoLog", autoLog); this._addAttribute("status", ServerManager.Status.READY); } @@ -1092,7 +1092,7 @@ export class ServerManager extends PsychObject const pathExtension = (pathParts.length > 1) ? pathParts.pop() : undefined; // preload.js with forced binary: - if (["csv", "odp", "xls", "xlsx", "json"].indexOf(extension) > -1) + if (["csv", "odp", "xls", "xlsx", "json", "gif"].indexOf(extension) > -1) { preloadManifest.push(/*new createjs.LoadItem().set(*/ { id: name, @@ -1131,7 +1131,7 @@ export class ServerManager extends PsychObject preloadManifest.push(/*new createjs.LoadItem().set(*/ { id: name, src: pathStatusData.path, - crossOrigin: "Anonymous", + crossOrigin: "Anonymous" } /*)*/); } } diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js new file mode 100644 index 0000000..745533a --- /dev/null +++ b/src/visual/GifStim.js @@ -0,0 +1,371 @@ +/** + * Gif Stimulus. + * + * @author Nikita Agafonov + * @version 2022.2.0 + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + */ + +import * as PIXI from "pixi.js-legacy"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; +import {Camera} from "../hardware"; +import { AnimatedGIF } from "@pixi/gif"; + +/** + * Gif Stimulus. + * + * @name module:visual.GifStim + * @class + * @extends VisualStim + * @mixes ColorMixin + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image + * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position + * @param {number} [options.ori= 0.0] - the orientation (in degrees) + * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {Color} [options.color= 'white'] the background color + * @param {number} [options.opacity= 1.0] - the opacity + * @param {number} [options.contrast= 1.0] - the contrast + * @param {number} [options.depth= 0] - the depth (i.e. the z order) + * @param {number} [options.texRes= 128] - the resolution of the text + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally + * @param {boolean} [options.flipVert= false] - whether or not to flip vertically + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip + * @param {boolean} [options.autoLog= false] - whether or not to log + */ +export class GifStim extends util.mix(VisualStim).with(ColorMixin) +{ + constructor({ name, win, image, mask, pos, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) + { + super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); + + this._addAttribute( + "image", + image, + ); + this._addAttribute( + "mask", + mask, + ); + this._addAttribute( + "color", + color, + "white", + this._onChange(true, false), + ); + this._addAttribute( + "contrast", + contrast, + 1.0, + this._onChange(true, false), + ); + this._addAttribute( + "texRes", + texRes, + 128, + this._onChange(true, false), + ); + this._addAttribute( + "interpolate", + interpolate, + false + ); + this._addAttribute( + "flipHoriz", + flipHoriz, + false, + this._onChange(false, false), + ); + this._addAttribute( + "flipVert", + flipVert, + false, + this._onChange(false, false), + ); + + // estimate the bounding box: + this._estimateBoundingBox(); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + } + + /** + * Setter for the image attribute. + * + * @name module:visual.GifStim#setImage + * @public + * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {boolean} [log= false] - whether of not to log + */ + setImage(image, log = false) + { + const response = { + origin: "GifStim.setImage", + context: "when setting the image of GifStim: " + this._name, + }; + + try + { + // image is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem + if (typeof image === "undefined") + { + this.psychoJS.logger.warn("setting the image of GifStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the image of GifStim: " + this._name + " as: undefined"); + } + else + { + // image is a string: it should be the name of a resource, which we load + if (typeof image === "string") + { + image = this.psychoJS.serverManager.getResource(image); + } + + if (image instanceof ArrayBuffer) + { + this.psychoJS.logger.debug(`set the image of GifStim: ${this._name} as ArrayBuffer(${image.length})`); + } + else + { + throw "the argument: " + image.toString() + ' is neither an image nor a video" }'; + } + } + + const existingImage = this.getImage(); + const hasChanged = existingImage ? existingImage.src !== image.src : true; + + this._setAttribute("image", image, log); + + if (hasChanged) + { + this._onChange(true, true)(); + } + } + catch (error) + { + throw Object.assign(response, { error }); + } + } + + /** + * Setter for the mask attribute. + * + * @name module:visual.GifStim#setMask + * @public + * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {boolean} [log= false] - whether of not to log + */ + setMask(mask, log = false) + { + const response = { + origin: "GifStim.setMask", + context: "when setting the mask of GifStim: " + this._name, + }; + + try + { + // mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem + if (typeof mask === "undefined") + { + this.psychoJS.logger.warn("setting the mask of GifStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the mask of GifStim: " + this._name + " as: undefined"); + } + else + { + // mask is a string: it should be the name of a resource, which we load + if (typeof mask === "string") + { + mask = this.psychoJS.serverManager.getResource(mask); + } + + // mask should now be an actual HTMLImageElement: we raise an error if it is not + if (!(mask instanceof HTMLImageElement)) + { + throw "the argument: " + mask.toString() + ' is not an image" }'; + } + + this.psychoJS.logger.debug("set the mask of GifStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); + } + + this._setAttribute("mask", mask, log); + + this._onChange(true, false)(); + } + catch (error) + { + throw Object.assign(response, { error }); + } + } + + /** + * Whether to interpolate (linearly) the texture in the stimulus. + * + * @name module:visual.GifStim#setInterpolate + * @public + * @param {boolean} interpolate - interpolate or not. + * @param {boolean} [log=false] - whether or not to log + */ + setInterpolate (interpolate = false, log = false) { + this._setAttribute("interpolate", interpolate, log); + if (this._pixi instanceof PIXI.Sprite) { + this._pixi.texture.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; + this._pixi.texture.baseTexture.update(); + } + } + + /** + * Estimate the bounding box. + * + * @name module:visual.GifStim#_estimateBoundingBox + * @function + * @override + * @protected + */ + _estimateBoundingBox() + { + const size = this._getDisplaySize(); + if (typeof size !== "undefined") + { + this._boundingBox = new PIXI.Rectangle( + this._pos[0] - size[0] / 2, + this._pos[1] - size[1] / 2, + size[0], + size[1], + ); + } + + // TODO take the orientation into account + } + + /** + * Update the stimulus, if necessary. + * + * @name module:visual.GifStim#_updateIfNeeded + * @private + */ + _updateIfNeeded() + { + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + + if (typeof this._pixi !== "undefined") + { + this._pixi.destroy(true); + } + this._pixi = undefined; + + // no image to draw: return immediately + if (typeof this._image === "undefined") + { + return; + } + + if (this._image instanceof ArrayBuffer) + { + const gifOpts = + { + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST + }; + this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); + } + + + // add a mask if need be: + if (typeof this._mask !== "undefined") + { + // Building new PIXI.BaseTexture each time we create a mask, to avoid PIXI's caching and use a unique resource. + this._pixi.mask = PIXI.Sprite.from(new PIXI.Texture(new PIXI.BaseTexture(this._mask))); + + // a 0.5, 0.5 anchor is required for the mask to be aligned with the image + this._pixi.mask.anchor.x = 0.5; + this._pixi.mask.anchor.y = 0.5; + + this._pixi.addChild(this._pixi.mask); + } + + // since _texture.width may not be immediately available but the rest of the code needs its value + // we arrange for repeated calls to _updateIfNeeded until we have a width: + if (this._pixi.texture.width === 0) + { + this._needUpdate = true; + this._needPixiUpdate = true; + return; + } + + // const colorFilter = new PIXI.filters.ColorMatrixFilter(); + // colorFilter.matrix[0] = 2; + // colorFilter.matrix[6] = 1; + // colorFilter.matrix[12] = 1; + // // colorFilter.alpha = 1; + // colorFilter.blendMode = PIXI.BLEND_MODES.MULTIPLY; + // console.log(colorFilter.matrix); + // this._pixi.filters = [colorFilter]; + } + + this._pixi.zIndex = -this._depth; + this._pixi.alpha = this.opacity; + + // set the scale: + const displaySize = this._getDisplaySize(); + const size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = size_px[0] / this._pixi.texture.width; + const scaleY = size_px[1] / this._pixi.texture.height; + this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; + this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + + // set the position, rotation, and anchor (image centered on pos): + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180; + this._pixi.anchor.x = 0.5; + this._pixi.anchor.y = 0.5; + + // re-estimate the bounding box, as the texture's width may now be available: + this._estimateBoundingBox(); + } + + /** + * Get the size of the display image, which is either that of the GifStim or that of the image + * it contains. + * + * @name module:visual.GifStim#_getDisplaySize + * @private + * @return {number[]} the size of the displayed image + */ + _getDisplaySize() + { + let displaySize = this.size; + + if (typeof displaySize === "undefined") + { + // use the size of the texture, if we have access to it: + if (typeof this._pixi.texture !== "undefined" && this._pixi.texture.width > 0) + { + const textureSize = [this._pixi.texture.width, this._pixi.texture.height]; + displaySize = util.to_unit(textureSize, "pix", this.win, this.units); + } + } + + return displaySize; + } +} diff --git a/src/visual/index.js b/src/visual/index.js index fb96f41..15b8416 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -2,6 +2,7 @@ export * from "./ButtonStim.js"; export * from "./Form.js"; export * from "./ImageStim.js"; export * from "./GratingStim.js"; +export * from "./GifStim.js"; export * from "./MovieStim.js"; export * from "./Polygon.js"; export * from "./Rect.js"; From 97b9fed9511cdda4bdf9abfb309807c957b54dc2 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 30 Jun 2022 04:23:35 +0300 Subject: [PATCH 03/52] removed unnesesary import; --- src/core/ServerManager.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 93d8c7b..1e33956 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -8,7 +8,6 @@ */ import { Howl } from "howler"; -// import { Loader } from "@pixi/loaders"; import { ExperimentHandler } from "../data/ExperimentHandler.js"; import { Clock, MonotonicClock } from "../util/Clock.js"; import { PsychObject } from "../util/PsychObject.js"; From 6373f38bbb38b5d4e49acdf4b8f9ce608fbe64ac Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 1 Jul 2022 14:07:03 +0300 Subject: [PATCH 04/52] saving progress; --- src/visual/GifStim.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 745533a..8e800bf 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -287,7 +287,9 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) { scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST }; + let t = performance.now(); this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); + console.log("pixi animated gif took", performance.now() - t); } @@ -356,7 +358,7 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) { let displaySize = this.size; - if (typeof displaySize === "undefined") + if (this._pixi && typeof displaySize === "undefined") { // use the size of the texture, if we have access to it: if (typeof this._pixi.texture !== "undefined" && this._pixi.texture.width > 0) From 7fa85d5d26d5f957f28dabe8493cbe451e807dc8 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 3 Jul 2022 15:45:06 +0300 Subject: [PATCH 05/52] first attempt to boost animation composition out of gif data; --- src/visual/GifStim.js | 51 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 8e800bf..ec81923 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -15,6 +15,7 @@ import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import {Camera} from "../hardware"; import { AnimatedGIF } from "@pixi/gif"; +import { parseGIF, decompressFrames } from "gifuct-js"; /** * Gif Stimulus. @@ -288,10 +289,54 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST }; let t = performance.now(); - this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); - console.log("pixi animated gif took", performance.now() - t); - } + // How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp + let gif = parseGIF(this._image); + let pt = performance.now() - t; + let t2 = performance.now(); + let frames = decompressFrames(gif, true); + let dect = performance.now() - t2; + window.parsedGif = gif; + window.frames = frames; + let i, j; + let patchRow = 0; + let patchCol = 0; + let time = 0; + let idFrames = new Array(frames.length); + let pixelData = new Uint8ClampedArray(gif.lsd.width * gif.lsd.height * 4); + let offset = 0; + let t3 = performance.now(); + for (i = 0; i < frames.length; i++) { + // offset = (gif.lsd.width * frames[i].dims.top + frames[i].dims.left) * 4; + // patchRow = 0; + // pixelData.set(frames[i].patch, offset); + for (j = 0; j < frames[i].patch.length; j += 4) { + if (frames[i].patch[j + 3] > 0) { + patchRow = (j / (frames[i].dims.width * 4)) | 0; + offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + patchCol = (j % (frames[i].dims.width * 4)); + pixelData[offset + patchCol] = frames[i].patch[j]; + pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; + pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; + pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; + } + } + idFrames[i] = { + imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), + start: time, + end: time + frames[i].delay + }; + time += frames[i].delay; + } + + let idcomposet = performance.now() - t3; + this._pixi = new AnimatedGIF(idFrames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); + console.log("animated gif, parse=", pt, "decompress=", dect, "id compose=", idcomposet, "total=", performance.now() - t); + + // t = performance.now(); + // this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); + // console.log("pixi animated gif took", performance.now() - t); + } // add a mask if need be: if (typeof this._mask !== "undefined") From caa49c2ff163d76dd1738c2cb31b29746f3e20b3 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 3 Jul 2022 22:12:05 +0300 Subject: [PATCH 06/52] Improved ImageData creation performance; Added new methods to control the playback; Proper setImage(); Updated package.json; --- package.json | 1 + src/visual/GifStim.js | 201 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 185 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 297d505..df1dd41 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@pixi/gif": "^1.1.0", "a11y-dialog": "^7.5.0", "esbuild-plugin-glsl": "^1.0.5", + "gifuct-js": "^2.1.2", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index ec81923..1ad9026 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -39,6 +39,9 @@ import { parseGIF, decompressFrames } from "gifuct-js"; * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.texRes= 128] - the resolution of the text + * @param {boolean} [options.loop= true] - whether or not to loop the animation + * @param {boolean} [options.autoPlay= true] - whether or not to autoPlay the animation + * @param {boolean} [options.animationSpeed= 1] - animation speed, works as multiplyer e.g. 1 - normal speed, 0.5 - half speed, 2 - twice as fast etc. * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally * @param {boolean} [options.flipVert= false] - whether or not to flip vertically @@ -47,7 +50,29 @@ import { parseGIF, decompressFrames } from "gifuct-js"; */ export class GifStim extends util.mix(VisualStim).with(ColorMixin) { - constructor({ name, win, image, mask, pos, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) + constructor({ + name, + win, + image, + mask, + pos, + units, + ori, + size, + color, + opacity, + contrast, + texRes, + depth, + interpolate, + loop, + autoPlay, + animationSpeed, + flipHoriz, + flipVert, + autoDraw, + autoLog + } = {}) { super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); @@ -94,6 +119,21 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(false, false), ); + this._addAttribute( + "loop", + loop, + true + ); + this._addAttribute( + "autoPlay", + autoPlay, + true + ); + this._addAttribute( + "animationSpeed", + animationSpeed, + 1 + ); // estimate the bounding box: this._estimateBoundingBox(); @@ -104,13 +144,121 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) } } + /** + * Getter for the playing property. + * + * @name module:visual.GifStim#isPlaying + * @public + */ + get isPlaying () + { + if (this._pixi) + { + return this._pixi.playing; + } + return false; + } + + /** + * Getter for the duration property. Shows animation duration time in milliseconds. + * + * @name module:visual.GifStim#duration + * @public + */ + get duration () + { + if (this._pixi) + { + return this._pixi.duration; + } + } + + /** + * Starts GIF playback. + * + * @name module:visual.GifStim#play + * @public + */ + play () + { + if (this._pixi) + { + this._pixi.play(); + } + } + + /** + * Pauses GIF playback. + * + * @name module:visual.GifStim#pause + * @public + */ + pause () + { + if (this._pixi) + { + this._pixi.stop(); + } + } + + /** + * Set wether or not to loop the animation. + * + * @name module:visual.GifStim#setLoop + * @public + * @param {boolean} [loop=true] - flag value + * @param {boolean} [log=false] - whether or not to log. + */ + setLoop (loop, log = false) + { + this._setAttribute("loop", loop, log); + if (this._pixi) + { + this._pixi.loop = loop; + } + } + + /** + * Set wether or not to autoplay the animation. + * + * @name module:visual.GifStim#setAutoPlay + * @public + * @param {boolean} [autoPlay=true] - flag value + * @param {boolean} [log=false] - whether or not to log. + */ + setAutoPlay (autoPlay, log = false) + { + this._setAttribute("autoPlay", autoPlay, log); + if (this._pixi) + { + this._pixi.autoPlay = autoPlay; + } + } + + /** + * Set animation speed of the animation. + * + * @name module:visual.GifStim#setAnimationSpeed + * @public + * @param {boolean} [animationSpeed=1] - multiplyer of the animation speed e.g. 1 - normal, 0.5 - half speed, 2 - twice as fast. + * @param {boolean} [log=false] - whether or not to log. + */ + setAnimationSpeed (animationSpeed = 1, log = false) + { + this._setAttribute("animationSpeed", animationSpeed, log); + if (this._pixi) + { + this._pixi.animationSpeed = animationSpeed; + } + } + /** * Setter for the image attribute. * * @name module:visual.GifStim#setImage * @public * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ setImage(image, log = false) { @@ -145,9 +293,7 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) } } - const existingImage = this.getImage(); - const hasChanged = existingImage ? existingImage.src !== image.src : true; - + const hasChanged = this.getImage() !== image; this._setAttribute("image", image, log); if (hasChanged) @@ -202,7 +348,6 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) } this._setAttribute("mask", mask, log); - this._onChange(true, false)(); } catch (error) @@ -219,7 +364,8 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) * @param {boolean} interpolate - interpolate or not. * @param {boolean} [log=false] - whether or not to log */ - setInterpolate (interpolate = false, log = false) { + setInterpolate (interpolate = false, log = false) + { this._setAttribute("interpolate", interpolate, log); if (this._pixi instanceof PIXI.Sprite) { this._pixi.texture.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; @@ -286,14 +432,17 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) { const gifOpts = { - scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, + loop: this._loop, + autoPlay: this._autoPlay, + animationSpeed: this._animationSpeed }; let t = performance.now(); // How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp let gif = parseGIF(this._image); let pt = performance.now() - t; let t2 = performance.now(); - let frames = decompressFrames(gif, true); + let frames = decompressFrames(gif, false); let dect = performance.now() - t2; window.parsedGif = gif; window.frames = frames; @@ -304,23 +453,41 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) let time = 0; let idFrames = new Array(frames.length); let pixelData = new Uint8ClampedArray(gif.lsd.width * gif.lsd.height * 4); + let colorData; let offset = 0; let t3 = performance.now(); for (i = 0; i < frames.length; i++) { // offset = (gif.lsd.width * frames[i].dims.top + frames[i].dims.left) * 4; // patchRow = 0; // pixelData.set(frames[i].patch, offset); - for (j = 0; j < frames[i].patch.length; j += 4) { - if (frames[i].patch[j + 3] > 0) { - patchRow = (j / (frames[i].dims.width * 4)) | 0; + + // attempt 1 (needs decompressFrames(gif, true), which is an extra step) + // for (j = 0; j < frames[i].patch.length; j += 4) { + // if (frames[i].patch[j + 3] > 0) { + // patchRow = (j / (frames[i].dims.width * 4)) | 0; + // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + // patchCol = (j % (frames[i].dims.width * 4)); + // pixelData[offset + patchCol] = frames[i].patch[j]; + // pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; + // pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; + // pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; + // } + // } + + // attempt 2 + for (j = 0; j < frames[i].pixels.length; j++) { + colorData = frames[i].colorTable[frames[i].pixels[j]]; + if (frames[i].pixels[j] !== frames[i].transparentIndex) { + patchRow = (j / (frames[i].dims.width)) | 0; offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - patchCol = (j % (frames[i].dims.width * 4)); - pixelData[offset + patchCol] = frames[i].patch[j]; - pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; - pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; - pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; + patchCol = (j % (frames[i].dims.width)) * 4; + pixelData[offset + patchCol] = colorData[0]; + pixelData[offset + patchCol + 1] = colorData[1]; + pixelData[offset + patchCol + 2] = colorData[2]; + pixelData[offset + patchCol + 3] = 255; } } + idFrames[i] = { imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), start: time, From 3659b14ab9384b33e248ea6ebe911894bd06d1e9 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 11 Jul 2022 19:03:37 +0300 Subject: [PATCH 07/52] further attempts to boost parser's performance; --- src/util/GifParser.js | 482 ++++++++++++++++++++++++++++++++++++++++++ src/visual/GifStim.js | 65 +++++- 2 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 src/util/GifParser.js diff --git a/src/util/GifParser.js b/src/util/GifParser.js new file mode 100644 index 0000000..51eef91 --- /dev/null +++ b/src/util/GifParser.js @@ -0,0 +1,482 @@ +/** + * Tool for parsing gif files and decoding it's data to frames. + * + * @author "Matt Way" (https://github.com/matt-way), Nikita Agafonov (https://github.com/lightest) + * @copyright (c) 2015 Matt Way + * @license Distributed under the terms of the MIT License + * + * @note Based on https://github.com/matt-way/gifuct-js + * + */ + +import GIF from 'js-binary-schema-parser/lib/schemas/gif' +import { parse } from 'js-binary-schema-parser' +import { buildStream } from 'js-binary-schema-parser/lib/parsers/uint8' + +/** + * Deinterlace function from https://github.com/shachaf/jsgif + */ + +export const deinterlace = (pixels, width) => { + const newPixels = new Array(pixels.length) + const rows = pixels.length / width + const cpRow = function(toRow, fromRow) { + const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width) + newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels)) + } + + // See appendix E. + const offsets = [0, 4, 2, 1] + const steps = [8, 8, 4, 2] + + var fromRow = 0 + for (var pass = 0; pass < 4; pass++) { + for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) { + cpRow(toRow, fromRow) + fromRow++ + } + } + + return newPixels +} + + +/** + * javascript port of java LZW decompression + * Original java author url: https://gist.github.com/devunwired/4479231 + */ + +export const lzw = (minCodeSize, data, pixelCount) => { + const MAX_STACK_SIZE = 4096 + const nullCode = -1 + const npix = pixelCount + var available, + clear, + code_mask, + code_size, + end_of_information, + in_code, + old_code, + bits, + code, + i, + datum, + data_size, + first, + top, + bi, + pi + + // const dstPixels = new Array(pixelCount) + // const prefix = new Array(MAX_STACK_SIZE) + // const suffix = new Array(MAX_STACK_SIZE) + // const pixelStack = new Array(MAX_STACK_SIZE + 1) + + const dstPixels = new Uint8Array(pixelCount) + const prefix = new Uint16Array(MAX_STACK_SIZE) + const suffix = new Uint16Array(MAX_STACK_SIZE) + const pixelStack = new Uint8Array(MAX_STACK_SIZE + 1) + + // Initialize GIF data stream decoder. + data_size = minCodeSize + clear = 1 << data_size + end_of_information = clear + 1 + available = clear + 2 + old_code = nullCode + code_size = data_size + 1 + code_mask = (1 << code_size) - 1 + for (code = 0; code < clear; code++) { + // prefix[code] = 0 + suffix[code] = code + } + + // Decode GIF pixel stream. + var datum, bits, count, first, top, pi, bi + datum = bits = count = first = top = pi = bi = 0 + for (i = 0; i < npix; ) { + if (top === 0) { + if (bits < code_size) { + // get the next byte + datum += data[bi] << bits + + bits += 8 + bi++ + continue + } + // Get the next code. + code = datum & code_mask + datum >>= code_size + bits -= code_size + // Interpret the code + if (code > available || code == end_of_information) { + break + } + if (code == clear) { + // Reset decoder. + code_size = data_size + 1 + code_mask = (1 << code_size) - 1 + available = clear + 2 + old_code = nullCode + continue + } + if (old_code == nullCode) { + pixelStack[top++] = suffix[code] + old_code = code + first = code + continue + } + in_code = code + if (code == available) { + pixelStack[top++] = first + code = old_code + } + while (code > clear) { + pixelStack[top++] = suffix[code] + code = prefix[code] + } + + first = suffix[code] & 0xff + pixelStack[top++] = first + + // add a new string to the table, but only if space is available + // if not, just continue with current table until a clear code is found + // (deferred clear code implementation as per GIF spec) + if (available < MAX_STACK_SIZE) { + prefix[available] = old_code + suffix[available] = first + available++ + if ((available & code_mask) === 0 && available < MAX_STACK_SIZE) { + code_size++ + code_mask += available + } + } + old_code = in_code + } + // Pop a pixel off the pixel stack. + top-- + dstPixels[pi++] = pixelStack[top] + i++ + } + + // for (i = pi; i < npix; i++) { + // dstPixels[i] = 0 // clear missing pixels + // } + + return dstPixels +} + +export const lzw_contiguous = (minCodeSize, data, pixelCount) => { + console.log("pixelCount", pixelCount); + const MAX_STACK_SIZE = 4096 + const nullCode = -1 + const npix = pixelCount + var available, + clear, + code_mask, + code_size, + end_of_information, + in_code, + old_code, + bits, + code, + i, + datum, + data_size, + first, + top, + bi, + pi + + // const dstPixels = new Array(pixelCount) + // const prefix = new Array(MAX_STACK_SIZE) + // const suffix = new Array(MAX_STACK_SIZE) + // const pixelStack = new Array(MAX_STACK_SIZE + 1) + + const dstPixels = new Uint8Array(pixelCount) + const prefix = new Uint16Array(MAX_STACK_SIZE) + const suffix = new Uint16Array(MAX_STACK_SIZE) + const pixelStack = new Uint8Array(MAX_STACK_SIZE + 1) + + // Initialize GIF data stream decoder. + data_size = minCodeSize + clear = 1 << data_size + end_of_information = clear + 1 + available = clear + 2 + old_code = nullCode + code_size = data_size + 1 + code_mask = (1 << code_size) - 1 + for (code = 0; code < clear; code++) { + // prefix[code] = 0 + suffix[code] = code + } + + // Decode GIF pixel stream. + var datum, bits, count, first, top, pi, bi + datum = bits = count = first = top = pi = bi = 0 + for (i = 0; i < npix && bi < data.length; ) { + if (top === 0) { + if (bits < code_size) { + // get the next byte + datum += data[bi] << bits + + bits += 8 + bi++ + continue + } + // if (bi === 49418) { + // console.log("bi", bi, "pi", pi, "top", top, "bits", bits, "code_size", code_size, "code_mask", code_mask); + // console.log("code", code, "next code", datum & code_mask, "old_code", old_code, "datum_shifted", datum >> code_size); + // } + // Get the next code. + code = datum & code_mask + datum >>= code_size + bits -= code_size + // Interpret the code + if (code > available || code == end_of_information) { + //end of info bi 692436 pi 2513585 top 0 bits 2 code_size 9 code_mask 511 + // console.log("end of info", "bi", bi, "pi", pi, "top", top, "bits", bits, "code_size", code_size, "code_mask", code_mask); + // console.log("datum", datum, "code", code, "next code", datum & code_mask, "available", available); + datum = bits = count = first = top = 0; + code_size = data_size + 1 + code_mask = (1 << code_size) - 1 + available = clear + 2 + old_code = nullCode + // prefix.fill(0); + // suffix.fill(0); + // pixelStack.fill(0); + // for (code = 0; code < clear; code++) { + // // prefix[code] = 0 + // suffix[code] = code + // } + continue; + break + } + if (code === clear) { + // Reset decoder. + code_size = data_size + 1 + code_mask = (1 << code_size) - 1 + available = clear + 2 + old_code = nullCode + // console.log("code is clear", "bi", bi, "pi", pi, "top", top, "bits", bits, "code_size", code_size, "code_mask", code_mask); + // console.log("next code", datum & code_mask); + continue + } + if (old_code === nullCode) { + pixelStack[top++] = suffix[code] + old_code = code + first = code + continue + } + in_code = code + if (code === available) { + pixelStack[top++] = first + code = old_code + } + while (code > clear && top <= MAX_STACK_SIZE) { + pixelStack[top++] = suffix[code] + code = prefix[code] + } + + first = suffix[code] & 0xff + pixelStack[top++] = first + + // add a new string to the table, but only if space is available + // if not, just continue with current table until a clear code is found + // (deferred clear code implementation as per GIF spec) + if (available < MAX_STACK_SIZE) { + // if (available === 258 && old_code === 258) { + // console.log("258"); + // } + prefix[available] = old_code + suffix[available] = first + available++ + if ((available & code_mask) === 0 && available < MAX_STACK_SIZE) { + code_size++ + code_mask += available + } + } + old_code = in_code + } + // Pop a pixel off the pixel stack. + top-- + dstPixels[pi++] = pixelStack[top] + i++ + } + + // for (i = pi; i < npix; i++) { + // dstPixels[i] = 0 // clear missing pixels + // } + + return dstPixels +} + +export const parseGIF = arrayBuffer => { + const byteData = new Uint8Array(arrayBuffer) + return parse(buildStream(byteData), GIF) +} + +const generatePatch = image => { + const totalPixels = image.pixels.length + const patchData = new Uint8ClampedArray(totalPixels * 4) + for (var i = 0; i < totalPixels; i++) { + const pos = i * 4 + const colorIndex = image.pixels[i] + const color = image.colorTable[colorIndex] || [0, 0, 0] + patchData[pos] = color[0] + patchData[pos + 1] = color[1] + patchData[pos + 2] = color[2] + patchData[pos + 3] = colorIndex !== image.transparentIndex ? 255 : 0 + } + + return patchData +} + +export const decompressFrame = (frame, gct, buildImagePatch) => { + if (!frame.image) { + console.warn('gif frame does not have associated image.') + return + } + + const { image } = frame + + // get the number of pixels + const totalPixels = image.descriptor.width * image.descriptor.height + // do lzw decompression + var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels) + + // deal with interlacing if necessary + if (image.descriptor.lct.interlaced) { + pixels = deinterlace(pixels, image.descriptor.width) + } + + const resultImage = { + pixels: pixels, + dims: { + top: frame.image.descriptor.top, + left: frame.image.descriptor.left, + width: frame.image.descriptor.width, + height: frame.image.descriptor.height + } + } + + // color table + if (image.descriptor.lct && image.descriptor.lct.exists) { + resultImage.colorTable = image.lct + } else { + resultImage.colorTable = gct + } + + // add per frame relevant gce information + if (frame.gce) { + resultImage.delay = (frame.gce.delay || 10) * 10 // convert to ms + resultImage.disposalType = frame.gce.extras.disposal + // transparency + if (frame.gce.extras.transparentColorGiven) { + resultImage.transparentIndex = frame.gce.transparentColorIndex + } + } + + // create canvas usable imagedata if desired + if (buildImagePatch) { + resultImage.patch = generatePatch(resultImage) + } + + return resultImage +} + +export const decompressFrames = (parsedGif, buildImagePatches) => { + // return parsedGif.frames + // .filter(f => f.image) + // .map(f => decompressFrame(f, parsedGif.gct, buildImagePatches)) + let totalPixels = 0; + let out = []; + let i, j = 0; + + // for (i = 0; i < parsedGif.frames.length; i++) { + // totalPixels += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height; + // } + + // const dstPixels = new Uint16Array(totalPixels); + // let frameStart = 0; + // let frameEnd = 0; + + for (i = 0; i < parsedGif.frames.length; i++) { + if (parsedGif.frames[i].image) { + out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches); + // out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, prefix, suffix, pixelStack, dstPixels, frameStart, frameEnd); + j++; + } + } + + return out; +} + +export const decompressFramesContiguous = (parsedGif, buildImagePatches) => { + // return parsedGif.frames + // .filter(f => f.image) + // .map(f => decompressFrame(f, parsedGif.gct, buildImagePatches)) + let totalPixels = 0; + let totalBlocks = 0; + let out = []; + let frameData = []; + let i, j = 0; + let frameStartIdx = 0; + let pixelsPerFrame = 0; + + for (i = 0, j = 0; i < parsedGif.frames.length; i++) { + if (parsedGif.frames[i].image) { + pixelsPerFrame = parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height; + totalPixels += pixelsPerFrame; + totalBlocks += parsedGif.frames[i].image.data.blocks.length; + // frameData[j] = { + // frameStart: frameStartIdx, + // frameEnd: frameStartIdx + pixelsPerFrame - 1, + // top: parsedGif.frames[i].image.descriptor.top, + // left: parsedGif.frames[i].image.descriptor.left, + // width: parsedGif.frames[i].image.descriptor.width, + // height: parsedGif.frames[i].image.descriptor.height + // }; + // frameStartIdx = frameStartIdx + pixelsPerFrame; + j++ + } + } + + const allBlocks = new Uint8Array(totalBlocks); + // const allPixels = new Uint8Array(totalPixels); + + let k; + for (i = 0, j = 0; i < parsedGif.frames.length; i++) { + if (parsedGif.frames[i].image) { + allBlocks.set(parsedGif.frames[i].image.data.blocks, j); + j += parsedGif.frames[i].image.data.blocks.length; + // if (j === 0) { + // allBlocks.set(parsedGif.frames[i].image.data.blocks, j); + // j += parsedGif.frames[i].image.data.blocks.length; + // } else { + // for (k = 1; k < parsedGif.frames[i].image.data.blocks.length; k++) { + // allBlocks[j + k - 1] = parsedGif.frames[i].image.data.blocks[k]; + // } + // j += parsedGif.frames[i].image.data.blocks.length - 1; + // } + } + } + + console.log(allBlocks); + window.allBlocks = allBlocks; + + const decompressedPixels = lzw_contiguous(parsedGif.frames[1].image.data.minCodeSize, allBlocks, totalPixels); + + // const dstPixels = new Uint16Array(totalPixels); + // let frameStart = 0; + // let frameEnd = 0; + + // for (i = 0; i < parsedGif.frames.length; i++) { + // if (parsedGif.frames[i].image) { + // out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches); + // // out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, prefix, suffix, pixelStack, dstPixels, frameStart, frameEnd); + // j++; + // } + // } + + return decompressedPixels; +} diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 1ad9026..228a6a4 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -15,7 +15,8 @@ import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import {Camera} from "../hardware"; import { AnimatedGIF } from "@pixi/gif"; -import { parseGIF, decompressFrames } from "gifuct-js"; +// import { parseGIF, decompressFrames } from "gifuct-js"; +import { parseGIF, decompressFrames, decompressFramesContiguous } from "../util/GifParser.js"; /** * Gif Stimulus. @@ -437,13 +438,18 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) autoPlay: this._autoPlay, animationSpeed: this._animationSpeed }; - let t = performance.now(); + let t0 = performance.now(); // How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp let gif = parseGIF(this._image); - let pt = performance.now() - t; + let pt = performance.now() - t0; let t2 = performance.now(); let frames = decompressFrames(gif, false); let dect = performance.now() - t2; + + let t2c = performance.now(); + // let pixels2 = decompressFramesContiguous(gif, false); + // window.pixels2 = pixels2; + let dect2 = performance.now() - t2c; window.parsedGif = gif; window.frames = frames; @@ -478,9 +484,9 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) for (j = 0; j < frames[i].pixels.length; j++) { colorData = frames[i].colorTable[frames[i].pixels[j]]; if (frames[i].pixels[j] !== frames[i].transparentIndex) { - patchRow = (j / (frames[i].dims.width)) | 0; + patchRow = (j / frames[i].dims.width) | 0; offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - patchCol = (j % (frames[i].dims.width)) * 4; + patchCol = (j % frames[i].dims.width) * 4; pixelData[offset + patchCol] = colorData[0]; pixelData[offset + patchCol + 1] = colorData[1]; pixelData[offset + patchCol + 2] = colorData[2]; @@ -496,9 +502,56 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) time += frames[i].delay; } + // let frameStartIdx = 0; + // let frameEndIdx = 0; + // let t, l, w, h; + // let ct; + // let delay; + // let k = 0; + // let m = 0; + // for (i = 0; i < parsedGif.frames.length; i++) { + // if (parsedGif.frames[i].image) { + // frameEndIdx = frameStartIdx + parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height - 1; + // t = parsedGif.frames[i].image.descriptor.top; + // l = parsedGif.frames[i].image.descriptor.left; + // w = parsedGif.frames[i].image.descriptor.width; + // h = parsedGif.frames[i].image.descriptor.height; + // // color table + // if (parsedGif.frames[i].image.descriptor.lct && parsedGif.frames[i].image.descriptor.lct.exists) { + // ct = parsedGif.frames[i].image.lct; + // } else { + // ct = parsedGif.gct; + // } + // delay = (parsedGif.frames[i].gce.delay || 10) * 10; + + // for (j = frameStartIdx, m = 0; j <= frameEndIdx; j++, m++) { + // colorData = ct[pixels2[j]]; + // if (pixels2[j] !== parsedGif.frames[i].gce.transparentColorIndex) { + // patchRow = (m / w) | 0; + // offset = (gif.lsd.width * (t + patchRow) + l) * 4; + // patchCol = (m % w) * 4; + // pixelData[offset + patchCol] = colorData[0]; + // pixelData[offset + patchCol + 1] = colorData[1]; + // pixelData[offset + patchCol + 2] = colorData[2]; + // pixelData[offset + patchCol + 3] = 255; + // } + // } + + // idFrames[k] = { + // imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), + // start: time, + // end: time + delay + // }; + // k++; + // time += delay; + // frameStartIdx = frameEndIdx + 1; + // } + // } + let idcomposet = performance.now() - t3; this._pixi = new AnimatedGIF(idFrames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); - console.log("animated gif, parse=", pt, "decompress=", dect, "id compose=", idcomposet, "total=", performance.now() - t); + console.log("animated gif, parse=", pt, "decompress=", dect, "id compose=", idcomposet, "total=", performance.now() - t0); + console.log("dect2", dect2); // t = performance.now(); // this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); From 04669d28b3d484d2f016f1372f96d69851c6b747 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 14 Jul 2022 11:08:53 +0100 Subject: [PATCH 08/52] NF: Add "arrow" as recognised named shape in ShapeStim --- src/visual/ShapeStim.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index cb74cef..3439b70 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -371,4 +371,14 @@ ShapeStim.KnownShapes = { [-0.39, 0.31], [-0.09, 0.18], ], + + arrow: [ + [0.0, 0.5], + [-0.5, 0.0], + [-1/6, 0.0], + [-1/6, -0.5], + [1/6, -0.5], + [1/6, 0.0], + [0.5, 0.0], + ], }; From 11bddceb804517e4bf431a2a276ae854301d4855 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 14 Jul 2022 11:09:12 +0100 Subject: [PATCH 09/52] ENH: Add other shapes from Python to ShapeStim --- src/visual/ShapeStim.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index 3439b70..b5925e3 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -372,6 +372,19 @@ ShapeStim.KnownShapes = { [-0.09, 0.18], ], + triangle: [ + [+0.0, 0.5], // Point + [-0.5, -0.5], // Bottom left + [+0.5, -0.5], // Bottom right + ], + + rectangle: [ + [-.5, .5], // Top left + [ .5, .5], // Top right + [ .5, -.5], // Bottom left + [-.5, -.5], // Bottom right + ], + arrow: [ [0.0, 0.5], [-0.5, 0.0], From 53e9ea2d4bf7b7498802458addbbcce062900147 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 14 Jul 2022 16:23:51 +0300 Subject: [PATCH 10/52] saving progress; --- src/util/GifParser.js | 31 ++- src/visual/AnimatedGIF.js | 554 ++++++++++++++++++++++++++++++++++++++ src/visual/GifStim.js | 80 +++--- 3 files changed, 616 insertions(+), 49 deletions(-) create mode 100644 src/visual/AnimatedGIF.js diff --git a/src/util/GifParser.js b/src/util/GifParser.js index 51eef91..060375a 100644 --- a/src/util/GifParser.js +++ b/src/util/GifParser.js @@ -46,7 +46,7 @@ export const deinterlace = (pixels, width) => { * Original java author url: https://gist.github.com/devunwired/4479231 */ -export const lzw = (minCodeSize, data, pixelCount) => { +export const lzw = (minCodeSize, data, pixelCount, memoryBuffer, bufferOffset) => { const MAX_STACK_SIZE = 4096 const nullCode = -1 const npix = pixelCount @@ -72,7 +72,7 @@ export const lzw = (minCodeSize, data, pixelCount) => { // const suffix = new Array(MAX_STACK_SIZE) // const pixelStack = new Array(MAX_STACK_SIZE + 1) - const dstPixels = new Uint8Array(pixelCount) + const dstPixels = new Uint8Array(memoryBuffer, bufferOffset, pixelCount) const prefix = new Uint16Array(MAX_STACK_SIZE) const suffix = new Uint16Array(MAX_STACK_SIZE) const pixelStack = new Uint8Array(MAX_STACK_SIZE + 1) @@ -331,7 +331,7 @@ const generatePatch = image => { return patchData } -export const decompressFrame = (frame, gct, buildImagePatch) => { +export const decompressFrame = (frame, gct, buildImagePatch, memoryBuffer, memoryOffset) => { if (!frame.image) { console.warn('gif frame does not have associated image.') return @@ -342,7 +342,7 @@ export const decompressFrame = (frame, gct, buildImagePatch) => { // get the number of pixels const totalPixels = image.descriptor.width * image.descriptor.height // do lzw decompression - var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels) + var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels, memoryBuffer, memoryOffset) // deal with interlacing if necessary if (image.descriptor.lct.interlaced) { @@ -389,20 +389,31 @@ export const decompressFrames = (parsedGif, buildImagePatches) => { // .filter(f => f.image) // .map(f => decompressFrame(f, parsedGif.gct, buildImagePatches)) let totalPixels = 0; - let out = []; + let framesWithData = 0; + let out ; let i, j = 0; - // for (i = 0; i < parsedGif.frames.length; i++) { - // totalPixels += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height; - // } + for (i = 0; i < parsedGif.frames.length; i++) { + if (parsedGif.frames[i].image) + { + totalPixels += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height; + framesWithData++; + } + } // const dstPixels = new Uint16Array(totalPixels); // let frameStart = 0; // let frameEnd = 0; + const buf = new ArrayBuffer(totalPixels); + let bufOffset = 0; + out = new Array(framesWithData); + for (i = 0; i < parsedGif.frames.length; i++) { - if (parsedGif.frames[i].image) { - out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches); + if (parsedGif.frames[i].image) + { + out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, buf, bufOffset); + bufOffset += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height; // out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, prefix, suffix, pixelStack, dstPixels, frameStart, frameEnd); j++; } diff --git a/src/visual/AnimatedGIF.js b/src/visual/AnimatedGIF.js new file mode 100644 index 0000000..2705378 --- /dev/null +++ b/src/visual/AnimatedGIF.js @@ -0,0 +1,554 @@ +// import { Sprite } from '@pixi/sprite'; +// import { Texture, Renderer } from '@pixi/core'; +// import { settings } from '@pixi/settings'; +// import { SCALE_MODES } from '@pixi/constants'; +// import { Ticker, UPDATE_PRIORITY } from '@pixi/ticker'; + +import * as PIXI from "pixi.js-legacy"; + +/** + * Represents a single frame of a GIF. Includes image and timing data. + * @memberof PIXI.gif + */ +// interface FrameObject { +// // Image data for the current frame +// imageData: ImageData; +// // The start of the current frame, in milliseconds +// start: number; +// // The end of the current frame, in milliseconds +// end: number; +// } + +/** + * Default options for all AnimatedGIF objects. + * @memberof PIXI.gif + */ +// interface AnimatedGIFOptions { +// // Whether to start playing right away +// autoPlay: boolean; +// /** +// * Scale Mode to use for the texture +// * @type {PIXI.SCALE_MODES} +// */ +// scaleMode: SCALE_MODES; +// // To enable looping +// loop: boolean; +// // Speed of the animation +// animationSpeed: number; +// // Set to `false` to manage updates yourself +// autoUpdate: boolean; +// // The completed callback, optional +// onComplete: () => void; +// // The loop callback, optional +// onLoop: () => void; +// // The frame callback, optional +// onFrameChange: (currentFrame: number) => void; +// // Fallback FPS if GIF contains no time information +// fps?: number; +// } + +/** + * Options for the AnimatedGIF constructor. + * @memberof PIXI.gif + */ +// interface AnimatedGIFSize { +// /** Width of the GIF image */ +// width: number; +// /** Height of the GIF image */ +// height: number; +// } + +/** + * Runtime object to play animated GIFs. This object is similar to an AnimatedSprite. + * It support playback (seek, play, stop) as well as animation speed and looping. + */ +class AnimatedGIF extends PIXI.Sprite +{ + /** + * Default options for all AnimatedGIF objects. + * @property {PIXI.SCALE_MODES} [scaleMode=PIXI.SCALE_MODES.LINEAR] - Scale mode to use for the texture. + * @property {boolean} [loop=true] - To enable looping. + * @property {number} [animationSpeed=1] - Speed of the animation. + * @property {boolean} [autoUpdate=true] - Set to `false` to manage updates yourself. + * @property {boolean} [autoPlay=true] - To start playing right away. + * @property {Function} [onComplete=null] - The completed callback, optional. + * @property {Function} [onLoop=null] - The loop callback, optional. + * @property {Function} [onFrameChange=null] - The frame callback, optional. + * @property {number} [fps=PIXI.Ticker.shared.FPS] - Default FPS. + */ + static defaultOptions = { + scaleMode: PIXI.SCALE_MODES.LINEAR, + fps: PIXI.Ticker.shared.FPS, + loop: true, + animationSpeed: 1, + autoPlay: true, + autoUpdate: true, + onComplete: null, + onFrameChange: null, + onLoop: null + }; + + /** + * Create an animated GIF animation from a GIF image's ArrayBuffer. The easiest way to get + * the buffer is to use the Loader. + * @example + * const loader = new PIXI.Loader(); + * loader.add('myFile', 'file.gif'); + * loader.load((loader, resources) => { + * const gif = resources.myFile.animation; + * // add to the stage... + * }); + * @param buffer - GIF image arraybuffer from loader. + * @param options - Options to use. + * @returns + */ + // static fromBuffer(buffer, options) + // { + // if (!buffer || buffer.byteLength === 0) + // { + // throw new Error('Invalid buffer'); + // } + + // // fix https://github.com/matt-way/gifuct-js/issues/30 + // const validateAndFix = (gif) => + // { + // let currentGce = null; + + // for (const frame of gif.frames) + // { + // currentGce = frame.gce ?? currentGce; + + // // fix loosing graphic control extension for same frames + // if ('image' in frame && !('gce' in frame)) + // { + // frame.gce = currentGce; + // } + // } + // }; + + // const gif = parseGIF(buffer); + + // validateAndFix(gif); + // const gifFrames = decompressFrames(gif, true); + // const frames: FrameObject[] = []; + + // // Temporary canvases required for compositing frames + // const canvas = document.createElement('canvas'); + // const context = canvas.getContext('2d'); + // const patchCanvas = document.createElement('canvas'); + // const patchContext = patchCanvas.getContext('2d'); + + // canvas.width = gif.lsd.width; + // canvas.height = gif.lsd.height; + + // let time = 0; + + // // Some GIFs have a non-zero frame delay, so we need to calculate the fallback + // const { fps } = Object.assign({}, AnimatedGIF.defaultOptions, options); + // const defaultDelay = 1000 / fps; + + // // Precompute each frame and store as ImageData + // for (let i = 0; i < gifFrames.length; i++) + // { + // // Some GIF's omit the disposalType, so let's assume clear if missing + // const { disposalType = 2, delay = defaultDelay, patch, dims: { width, height, left, top } } = gifFrames[i]; + + // patchCanvas.width = width; + // patchCanvas.height = height; + // patchContext.clearRect(0, 0, width, height); + // const patchData = patchContext.createImageData(width, height); + + // patchData.data.set(patch); + // patchContext.putImageData(patchData, 0, 0); + + // context.drawImage(patchCanvas, left, top); + // const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + + // if (disposalType === 2 || disposalType === 3) + // { + // context.clearRect(0, 0, canvas.width, canvas.height); + // } + + // frames.push({ + // start: time, + // end: time + delay, + // imageData, + // }); + // time += delay; + // } + + // // clear the canvases + // canvas.width = canvas.height = 0; + // patchCanvas.width = patchCanvas.height = 0; + // const { width, height } = gif.lsd; + + // return new AnimatedGIF(frames, { width, height, ...options }); + // } + + /** + * @param frames - Data of the GIF image. + * @param options - Options for the AnimatedGIF + */ + // constructor(frames, options) + constructor(decompressedFrames, options) + { + // Get the options, apply defaults + const { scaleMode, width, height, ...rest } = Object.assign({}, + AnimatedGIF.defaultOptions, + options + ); + + // Create the texture + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = width; + canvas.height = height; + + super(PIXI.Texture.from(canvas, { scaleMode })); + + // this.duration = frames[frames.length - 1].end; + // this._frames = frames; + this._frameData = decompressedFrames; + this._origDims = { width, height }; + this._frameTimings = new Array(decompressedFrames.length); + this._framePixels = new Array(decompressedFrames.length); + let i, j, time = 0; + let t = performance.now(); + // let i = this._currentFrame; + let patchRow = 0, patchCol = 0; + let offset = 0; + let colorData; + let pixelData = new Uint8ClampedArray(width * height * 4); + let fullPixelData = new Uint8ClampedArray(width * height * 4 * decompressedFrames.length); + for (i = 0; i < decompressedFrames.length; i++) + { + for (j = 0; j < this._frameData[i].pixels.length; j++) + { + colorData = this._frameData[i].colorTable[this._frameData[i].pixels[j]]; + if (frames[i].pixels[j] !== frames[i].transparentIndex) + { + patchRow = (j / frames[i].dims.width) | 0; + offset = (this._origDims.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + patchCol = (j % frames[i].dims.width) * 4; + pixelData[offset + patchCol] = colorData[0]; + pixelData[offset + patchCol + 1] = colorData[1]; + pixelData[offset + patchCol + 2] = colorData[2]; + pixelData[offset + patchCol + 3] = 255; + } + } + // this._framePixels[i] = new Uint8ClampedArray(pixelData); + fullPixelData.set(pixelData, pixelData.length * i); + this._frameTimings[i] = + { + start: time, + end: time + decompressedFrames[i].delay + }; + time += decompressedFrames[i].delay; + } + this._fullPixelData = fullPixelData; + this.duration = this._frameTimings[decompressedFrames.length - 1].end; + // this._frameImageData = new ImageData(new Uint8ClampedArray(width * height * 4), width, height); + this._frameImageDataCache = []; + this._context = context; + this._playing = false; + this._currentTime = 0; + this._isConnectedToTicker = false; + Object.assign(this, rest); + + // Draw the first frame + this.currentFrame = 0; + if (this.autoPlay) + { + this.play(); + } + } + + /** Stops the animation. */ + stop() + { + if (!this._playing) + { + return; + } + + this._playing = false; + if (this._autoUpdate && this._isConnectedToTicker) + { + PIXI.Ticker.shared.remove(this.update, this); + this._isConnectedToTicker = false; + } + } + + /** Plays the animation. */ + play() + { + if (this._playing) + { + return; + } + + this._playing = true; + if (this._autoUpdate && !this._isConnectedToTicker) + { + PIXI.Ticker.shared.add(this.update, this, PIXI.UPDATE_PRIORITY.HIGH); + this._isConnectedToTicker = true; + } + + // If were on the last frame and stopped, play should resume from beginning + if (!this.loop && this.currentFrame === this._frameData.length - 1) + { + this._currentTime = 0; + } + } + + /** + * Get the current progress of the animation from 0 to 1. + * @readonly + */ + get progress() + { + return this._currentTime / this.duration; + } + + /** `true` if the current animation is playing */ + get playing() + { + return this._playing; + } + + /** + * Updates the object transform for rendering. You only need to call this + * if the `autoUpdate` property is set to `false`. + * + * @param deltaTime - Time since last tick. + */ + update(deltaTime) + { + if (!this._playing) + { + return; + } + + const elapsed = this.animationSpeed * deltaTime / PIXI.settings.TARGET_FPMS; + const currentTime = this._currentTime + elapsed; + const localTime = currentTime % this.duration; + + const localFrame = this._frameTimings.findIndex((ft) => + ft.start <= localTime && ft.end > localTime); + + if (currentTime >= this.duration) + { + if (this.loop) + { + this._currentTime = localTime; + this.updateFrameIndex(localFrame); + if (typeof this.onLoop === "function") + { + this.onLoop(); + } + } + else + { + this._currentTime = this.duration; + this.updateFrameIndex(this._frameData.length - 1); + if (typeof this.onComplete === "function") + { + this.onComplete(); + } + this.stop(); + } + } + else + { + this._currentTime = localTime; + this.updateFrameIndex(localFrame); + } + } + + /** + * Redraw the current frame, is necessary for the animation to work when + */ + updateFrame() + { + if (!this.dirty) + { + return; + } + + // let t = performance.now(); + // let i = this._currentFrame; + // let j = 0; + // let patchRow = 0, patchCol = 0; + // let offset = 0; + // let colorData; + // for (j = 0; j < this._frameData[i].pixels.length; j++) { + // colorData = this._frameData[i].colorTable[this._frameData[i].pixels[j]]; + // if (frames[i].pixels[j] !== frames[i].transparentIndex) { + // patchRow = (j / frames[i].dims.width) | 0; + // offset = (this._origDims.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + // patchCol = (j % frames[i].dims.width) * 4; + // this._frameImageData.data[offset + patchCol] = colorData[0]; + // this._frameImageData.data[offset + patchCol + 1] = colorData[1]; + // this._frameImageData.data[offset + patchCol + 2] = colorData[2]; + // this._frameImageData.data[offset + patchCol + 3] = 255; + // } + // } + + // Update the current frame + // const { imageData } = this._frames[this._currentFrame]; + // this._context.putImageData(imageData, 0, 0); + let imageData = this._frameImageDataCache[this._currentFrame]; + if (imageData === undefined) + { + let t = performance.now(); + let frameLen = this._origDims.width * this._origDims.height * 4; + imageData = new ImageData(new Uint8ClampedArray(this._fullPixelData.buffer, frameLen * this._currentFrame, frameLen), this._origDims.width, this._origDims.height); + this._frameImageDataCache[this._currentFrame] = imageData; + console.log("frame id construction took", performance.now() - t); + } + this._context.putImageData(imageData, 0, 0); + + // Workaround hack for Safari & iOS + // which fails to upload canvas after putImageData + // See: https://bugs.webkit.org/show_bug.cgi?id=229986 + this._context.fillStyle = "transparent"; + this._context.fillRect(0, 0, 0, 1); + + this.texture.update(); + + // Mark as clean + this.dirty = false; + } + + /** + * Renders the object using the WebGL renderer + * + * @param {PIXI.Renderer} renderer - The renderer + * @private + */ + _render(renderer) + { + this.updateFrame(); + super._render(renderer); + } + + /** + * Renders the object using the WebGL renderer + * + * @param {PIXI.CanvasRenderer} renderer - The renderer + * @private + */ + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + _renderCanvas(renderer) + { + this.updateFrame(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + super._renderCanvas(renderer); + } + + /** + * Whether to use PIXI.Ticker.shared to auto update animation time. + * @default true + */ + get autoUpdate() + { + return this._autoUpdate; + } + + set autoUpdate(value) + { + if (value !== this._autoUpdate) + { + this._autoUpdate = value; + + if (!this._autoUpdate && this._isConnectedToTicker) + { + PIXI.Ticker.shared.remove(this.update, this); + this._isConnectedToTicker = false; + } + else if (this._autoUpdate && !this._isConnectedToTicker && this._playing) + { + PIXI.Ticker.shared.add(this.update, this); + this._isConnectedToTicker = true; + } + } + } + + /** Set the current frame number */ + get currentFrame() + { + return this._currentFrame; + } + + set currentFrame(value) + { + this.updateFrameIndex(value); + this._currentTime = this._frameTimings[value].start; + } + + /** Internally handle updating the frame index */ + updateFrameIndex(value) + { + if (value < 0 || value >= this._frameData.length) + { + throw new Error(`Frame index out of range, expecting 0 to ${this.totalFrames}, got ${value}`); + } + if (this._currentFrame !== value) + { + this._currentFrame = value; + this.dirty = true; + if (typeof this.onFrameChange === "function") + { + this.onFrameChange(value); + } + } + } + + /** + * Get the total number of frame in the GIF. + */ + get totalFrames() + { + return this._frameData.length; + } + + /** Destroy and don't use after this. */ + destroy() + { + this.stop(); + super.destroy(true); + this._context = null; + this._frameData = null; + this.onComplete = null; + this.onFrameChange = null; + this.onLoop = null; + } + + /** + * Cloning the animation is a useful way to create a duplicate animation. + * This maintains all the properties of the original animation but allows + * you to control playback independent of the original animation. + * If you want to create a simple copy, and not control independently, + * then you can simply create a new Sprite, e.g. `const sprite = new Sprite(animation.texture)`. + */ + clone() + { + return new AnimatedGIF([...this._frameData], { + autoUpdate: this._autoUpdate, + loop: this.loop, + autoPlay: this.autoPlay, + scaleMode: this.texture.baseTexture.scaleMode, + animationSpeed: this.animationSpeed, + width: this._context.canvas.width, + height: this._context.canvas.height, + onComplete: this.onComplete, + onFrameChange: this.onFrameChange, + onLoop: this.onLoop, + }); + } +} + +export { AnimatedGIF }; +// export type { AnimatedGIFOptions }; diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 228a6a4..1cabe85 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -14,8 +14,8 @@ import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import {Camera} from "../hardware"; -import { AnimatedGIF } from "@pixi/gif"; // import { parseGIF, decompressFrames } from "gifuct-js"; +import { AnimatedGIF } from "./AnimatedGIF.js"; import { parseGIF, decompressFrames, decompressFramesContiguous } from "../util/GifParser.js"; /** @@ -462,45 +462,45 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) let colorData; let offset = 0; let t3 = performance.now(); - for (i = 0; i < frames.length; i++) { - // offset = (gif.lsd.width * frames[i].dims.top + frames[i].dims.left) * 4; - // patchRow = 0; - // pixelData.set(frames[i].patch, offset); + // for (i = 0; i < frames.length; i++) { + // // offset = (gif.lsd.width * frames[i].dims.top + frames[i].dims.left) * 4; + // // patchRow = 0; + // // pixelData.set(frames[i].patch, offset); - // attempt 1 (needs decompressFrames(gif, true), which is an extra step) - // for (j = 0; j < frames[i].patch.length; j += 4) { - // if (frames[i].patch[j + 3] > 0) { - // patchRow = (j / (frames[i].dims.width * 4)) | 0; - // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - // patchCol = (j % (frames[i].dims.width * 4)); - // pixelData[offset + patchCol] = frames[i].patch[j]; - // pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; - // pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; - // pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; - // } - // } + // // attempt 1 (needs decompressFrames(gif, true), which is an extra step) + // // for (j = 0; j < frames[i].patch.length; j += 4) { + // // if (frames[i].patch[j + 3] > 0) { + // // patchRow = (j / (frames[i].dims.width * 4)) | 0; + // // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + // // patchCol = (j % (frames[i].dims.width * 4)); + // // pixelData[offset + patchCol] = frames[i].patch[j]; + // // pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; + // // pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; + // // pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; + // // } + // // } - // attempt 2 - for (j = 0; j < frames[i].pixels.length; j++) { - colorData = frames[i].colorTable[frames[i].pixels[j]]; - if (frames[i].pixels[j] !== frames[i].transparentIndex) { - patchRow = (j / frames[i].dims.width) | 0; - offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - patchCol = (j % frames[i].dims.width) * 4; - pixelData[offset + patchCol] = colorData[0]; - pixelData[offset + patchCol + 1] = colorData[1]; - pixelData[offset + patchCol + 2] = colorData[2]; - pixelData[offset + patchCol + 3] = 255; - } - } + // // attempt 2 + // for (j = 0; j < frames[i].pixels.length; j++) { + // colorData = frames[i].colorTable[frames[i].pixels[j]]; + // if (frames[i].pixels[j] !== frames[i].transparentIndex) { + // patchRow = (j / frames[i].dims.width) | 0; + // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; + // patchCol = (j % frames[i].dims.width) * 4; + // pixelData[offset + patchCol] = colorData[0]; + // pixelData[offset + patchCol + 1] = colorData[1]; + // pixelData[offset + patchCol + 2] = colorData[2]; + // pixelData[offset + patchCol + 3] = 255; + // } + // } - idFrames[i] = { - imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), - start: time, - end: time + frames[i].delay - }; - time += frames[i].delay; - } + // idFrames[i] = { + // imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), + // start: time, + // end: time + frames[i].delay + // }; + // time += frames[i].delay; + // } // let frameStartIdx = 0; // let frameEndIdx = 0; @@ -549,8 +549,10 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) // } let idcomposet = performance.now() - t3; - this._pixi = new AnimatedGIF(idFrames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); - console.log("animated gif, parse=", pt, "decompress=", dect, "id compose=", idcomposet, "total=", performance.now() - t0); + // this._pixi = new AnimatedGIF(idFrames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); + let t4 = performance.now(); + this._pixi = new AnimatedGIF(frames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); + console.log("animated gif, parse=", pt, "decompress=", dect, "animationGif inst=", performance.now() - t4, "total=", performance.now() - t0); console.log("dect2", dect2); // t = performance.now(); From 405bbbd37c2dccb5bc495b0e82de5906ab21bbb3 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sat, 16 Jul 2022 04:27:21 +0300 Subject: [PATCH 11/52] update that wins fast load time, capacity to have variable animation speed, and small memory footprint; --- src/core/ServerManager.js | 52 ++++++ src/util/GifParser.js | 2 +- src/visual/AnimatedGIF.js | 348 ++++++++++++-------------------------- src/visual/GifStim.js | 186 +++++--------------- 4 files changed, 201 insertions(+), 387 deletions(-) diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 1e33956..e0cdc95 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -324,6 +324,45 @@ export class ServerManager extends PsychObject return pathStatusData.data; } + /**************************************************************************** + * Get full data of a resource. + * + * @name module:core.ServerManager#getFullResourceData + * @function + * @public + * @param {string} name - name of the requested resource + * @param {boolean} [errorIfNotDownloaded = false] whether or not to throw an exception if the + * resource status is not DOWNLOADED + * @return {Object} full available data for resource, or undefined if the resource has been registered + * but not downloaded yet. + * @throws {Object.} exception if no resource with that name has previously been registered + */ + getFullResourceData (name, errorIfNotDownloaded = false) + { + const response = { + origin: "ServerManager.getResource", + context: "when getting the value of resource: " + name, + }; + + const pathStatusData = this._resources.get(name); + + if (typeof pathStatusData === "undefined") + { + // throw { ...response, error: 'unknown resource' }; + throw Object.assign(response, { error: "unknown resource" }); + } + + if (errorIfNotDownloaded && pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED) + { + throw Object.assign(response, { + error: name + " is not available for use (yet), its current status is: " + + util.toString(pathStatusData.status), + }); + } + + return pathStatusData; + } + /**************************************************************************** * Get the status of a single resource or the reduced status of an array of resources. * @@ -604,6 +643,19 @@ export class ServerManager extends PsychObject } } + cacheResourceData (name, dataToCache) + { + const pathStatusData = this._resources.get(name); + + if (typeof pathStatusData === "undefined") + { + // throw { ...response, error: 'unknown resource' }; + throw Object.assign(response, { error: "unknown resource" }); + } + + pathStatusData.cachedData = dataToCache; + } + /**************************************************************************** * Block the experiment until the specified resources have been downloaded. * diff --git a/src/util/GifParser.js b/src/util/GifParser.js index 060375a..7b64159 100644 --- a/src/util/GifParser.js +++ b/src/util/GifParser.js @@ -2,7 +2,7 @@ * Tool for parsing gif files and decoding it's data to frames. * * @author "Matt Way" (https://github.com/matt-way), Nikita Agafonov (https://github.com/lightest) - * @copyright (c) 2015 Matt Way + * @copyright (c) 2015 Matt Way, (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License * * @note Based on https://github.com/matt-way/gifuct-js diff --git a/src/visual/AnimatedGIF.js b/src/visual/AnimatedGIF.js index 2705378..6b18196 100644 --- a/src/visual/AnimatedGIF.js +++ b/src/visual/AnimatedGIF.js @@ -1,63 +1,16 @@ -// import { Sprite } from '@pixi/sprite'; -// import { Texture, Renderer } from '@pixi/core'; -// import { settings } from '@pixi/settings'; -// import { SCALE_MODES } from '@pixi/constants'; -// import { Ticker, UPDATE_PRIORITY } from '@pixi/ticker'; +/** + * Animated gif sprite. + * + * @author Nikita Agafonov (https://github.com/lightest), Matt Karl (https://github.com/bigtimebuddy) + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * + * @note Based on https://github.com/pixijs/gif and heavily modified. + * + */ import * as PIXI from "pixi.js-legacy"; -/** - * Represents a single frame of a GIF. Includes image and timing data. - * @memberof PIXI.gif - */ -// interface FrameObject { -// // Image data for the current frame -// imageData: ImageData; -// // The start of the current frame, in milliseconds -// start: number; -// // The end of the current frame, in milliseconds -// end: number; -// } - -/** - * Default options for all AnimatedGIF objects. - * @memberof PIXI.gif - */ -// interface AnimatedGIFOptions { -// // Whether to start playing right away -// autoPlay: boolean; -// /** -// * Scale Mode to use for the texture -// * @type {PIXI.SCALE_MODES} -// */ -// scaleMode: SCALE_MODES; -// // To enable looping -// loop: boolean; -// // Speed of the animation -// animationSpeed: number; -// // Set to `false` to manage updates yourself -// autoUpdate: boolean; -// // The completed callback, optional -// onComplete: () => void; -// // The loop callback, optional -// onLoop: () => void; -// // The frame callback, optional -// onFrameChange: (currentFrame: number) => void; -// // Fallback FPS if GIF contains no time information -// fps?: number; -// } - -/** - * Options for the AnimatedGIF constructor. - * @memberof PIXI.gif - */ -// interface AnimatedGIFSize { -// /** Width of the GIF image */ -// width: number; -// /** Height of the GIF image */ -// height: number; -// } - /** * Runtime object to play animated GIFs. This object is similar to an AnimatedSprite. * It support playback (seek, play, stop) as well as animation speed and looping. @@ -88,108 +41,10 @@ class AnimatedGIF extends PIXI.Sprite onLoop: null }; - /** - * Create an animated GIF animation from a GIF image's ArrayBuffer. The easiest way to get - * the buffer is to use the Loader. - * @example - * const loader = new PIXI.Loader(); - * loader.add('myFile', 'file.gif'); - * loader.load((loader, resources) => { - * const gif = resources.myFile.animation; - * // add to the stage... - * }); - * @param buffer - GIF image arraybuffer from loader. - * @param options - Options to use. - * @returns - */ - // static fromBuffer(buffer, options) - // { - // if (!buffer || buffer.byteLength === 0) - // { - // throw new Error('Invalid buffer'); - // } - - // // fix https://github.com/matt-way/gifuct-js/issues/30 - // const validateAndFix = (gif) => - // { - // let currentGce = null; - - // for (const frame of gif.frames) - // { - // currentGce = frame.gce ?? currentGce; - - // // fix loosing graphic control extension for same frames - // if ('image' in frame && !('gce' in frame)) - // { - // frame.gce = currentGce; - // } - // } - // }; - - // const gif = parseGIF(buffer); - - // validateAndFix(gif); - // const gifFrames = decompressFrames(gif, true); - // const frames: FrameObject[] = []; - - // // Temporary canvases required for compositing frames - // const canvas = document.createElement('canvas'); - // const context = canvas.getContext('2d'); - // const patchCanvas = document.createElement('canvas'); - // const patchContext = patchCanvas.getContext('2d'); - - // canvas.width = gif.lsd.width; - // canvas.height = gif.lsd.height; - - // let time = 0; - - // // Some GIFs have a non-zero frame delay, so we need to calculate the fallback - // const { fps } = Object.assign({}, AnimatedGIF.defaultOptions, options); - // const defaultDelay = 1000 / fps; - - // // Precompute each frame and store as ImageData - // for (let i = 0; i < gifFrames.length; i++) - // { - // // Some GIF's omit the disposalType, so let's assume clear if missing - // const { disposalType = 2, delay = defaultDelay, patch, dims: { width, height, left, top } } = gifFrames[i]; - - // patchCanvas.width = width; - // patchCanvas.height = height; - // patchContext.clearRect(0, 0, width, height); - // const patchData = patchContext.createImageData(width, height); - - // patchData.data.set(patch); - // patchContext.putImageData(patchData, 0, 0); - - // context.drawImage(patchCanvas, left, top); - // const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - - // if (disposalType === 2 || disposalType === 3) - // { - // context.clearRect(0, 0, canvas.width, canvas.height); - // } - - // frames.push({ - // start: time, - // end: time + delay, - // imageData, - // }); - // time += delay; - // } - - // // clear the canvases - // canvas.width = canvas.height = 0; - // patchCanvas.width = patchCanvas.height = 0; - // const { width, height } = gif.lsd; - - // return new AnimatedGIF(frames, { width, height, ...options }); - // } - /** * @param frames - Data of the GIF image. * @param options - Options for the AnimatedGIF */ - // constructor(frames, options) constructor(decompressedFrames, options) { // Get the options, apply defaults @@ -198,47 +53,15 @@ class AnimatedGIF extends PIXI.Sprite options ); - // Create the texture - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); + super(new PIXI.Texture(PIXI.BaseTexture.fromBuffer(new Uint8Array(width * height * 4), width, height, options))); - canvas.width = width; - canvas.height = height; - - super(PIXI.Texture.from(canvas, { scaleMode })); - - // this.duration = frames[frames.length - 1].end; - // this._frames = frames; - this._frameData = decompressedFrames; + this._useFullFrames = options.generateFullFrames; + this._decompressedFrameData = decompressedFrames; this._origDims = { width, height }; - this._frameTimings = new Array(decompressedFrames.length); - this._framePixels = new Array(decompressedFrames.length); let i, j, time = 0; - let t = performance.now(); - // let i = this._currentFrame; - let patchRow = 0, patchCol = 0; - let offset = 0; - let colorData; - let pixelData = new Uint8ClampedArray(width * height * 4); - let fullPixelData = new Uint8ClampedArray(width * height * 4 * decompressedFrames.length); + this._frameTimings = new Array(decompressedFrames.length); for (i = 0; i < decompressedFrames.length; i++) { - for (j = 0; j < this._frameData[i].pixels.length; j++) - { - colorData = this._frameData[i].colorTable[this._frameData[i].pixels[j]]; - if (frames[i].pixels[j] !== frames[i].transparentIndex) - { - patchRow = (j / frames[i].dims.width) | 0; - offset = (this._origDims.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - patchCol = (j % frames[i].dims.width) * 4; - pixelData[offset + patchCol] = colorData[0]; - pixelData[offset + patchCol + 1] = colorData[1]; - pixelData[offset + patchCol + 2] = colorData[2]; - pixelData[offset + patchCol + 3] = 255; - } - } - // this._framePixels[i] = new Uint8ClampedArray(pixelData); - fullPixelData.set(pixelData, pixelData.length * i); this._frameTimings[i] = { start: time, @@ -246,11 +69,12 @@ class AnimatedGIF extends PIXI.Sprite }; time += decompressedFrames[i].delay; } - this._fullPixelData = fullPixelData; this.duration = this._frameTimings[decompressedFrames.length - 1].end; - // this._frameImageData = new ImageData(new Uint8ClampedArray(width * height * 4), width, height); - this._frameImageDataCache = []; - this._context = context; + this._fullPixelData = []; + if (this._useFullFrames) + { + this._fullPixelData = this._constructFullFrames(decompressedFrames, width, height); + } this._playing = false; this._currentTime = 0; this._isConnectedToTicker = false; @@ -258,12 +82,75 @@ class AnimatedGIF extends PIXI.Sprite // Draw the first frame this.currentFrame = 0; + this._prevRenderedFrameIdx = -1; if (this.autoPlay) { this.play(); } } + _updatePixelsForOneFrame (decompressedFrameData, pixelBuffer) + { + let i = 0; + let patchRow = 0, patchCol = 0; + let offset = 0; + let colorData; + for (i = 0; i < decompressedFrameData.pixels.length; i++) { + colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]]; + if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) { + patchRow = (i / decompressedFrameData.dims.width) | 0; + offset = (this._origDims.width * (decompressedFrameData.dims.top + patchRow) + decompressedFrameData.dims.left) * 4; + patchCol = (i % decompressedFrameData.dims.width) * 4; + pixelBuffer[offset + patchCol] = colorData[0]; + pixelBuffer[offset + patchCol + 1] = colorData[1]; + pixelBuffer[offset + patchCol + 2] = colorData[2]; + pixelBuffer[offset + patchCol + 3] = 255; + } + } + } + + _constructFullFrames (decompressedFrames, gifWidth, gifHeight) + { + let t = performance.now(); + let i, j; + let patchRow = 0, patchCol = 0; + let offset = 0; + let colorData; + let pixelData = new Uint8Array(gifWidth * gifHeight * 4); + let fullPixelData = new Uint8Array(gifWidth * gifHeight * 4 * decompressedFrames.length); + for (i = 0; i < decompressedFrames.length; i++) + { + for (j = 0; j < decompressedFrames[i].pixels.length; j++) + { + colorData = decompressedFrames[i].colorTable[decompressedFrames[i].pixels[j]]; + if (decompressedFrames[i].pixels[j] !== decompressedFrames[i].transparentIndex) + { + patchRow = (j / decompressedFrames[i].dims.width) | 0; + offset = (gifWidth * (decompressedFrames[i].dims.top + patchRow) + decompressedFrames[i].dims.left) * 4; + patchCol = (j % decompressedFrames[i].dims.width) * 4; + pixelData[offset + patchCol] = colorData[0]; + pixelData[offset + patchCol + 1] = colorData[1]; + pixelData[offset + patchCol + 2] = colorData[2]; + pixelData[offset + patchCol + 3] = 255; + } + } + fullPixelData.set(pixelData, pixelData.length * i); + } + // console.log("full frames construction time", performance.now() - t); + return fullPixelData; + } + + _constructNthFullFrame (desiredFrameIdx, prevRenderedFrameIdx, pixelBuffer) + { + let t = performance.now(); + let i; + for (i = prevRenderedFrameIdx + 1; i <= desiredFrameIdx; i++) + { + this._updatePixelsForOneFrame(this._decompressedFrameData[i], pixelBuffer); + } + // console.log("constructed frames from", prevRenderedFrameIdx, "to", desiredFrameIdx, "(", desiredFrameIdx - prevRenderedFrameIdx, ")", performance.now() - t); + } + /** Stops the animation. */ stop() { @@ -296,7 +183,7 @@ class AnimatedGIF extends PIXI.Sprite } // If were on the last frame and stopped, play should resume from beginning - if (!this.loop && this.currentFrame === this._frameData.length - 1) + if (!this.loop && this.currentFrame === this._decompressedFrameData.length - 1) { this._currentTime = 0; } @@ -337,6 +224,11 @@ class AnimatedGIF extends PIXI.Sprite const localFrame = this._frameTimings.findIndex((ft) => ft.start <= localTime && ft.end > localTime); + if (this._prevRenderedFrameIdx > localFrame) + { + this._prevRenderedFrameIdx = -1; + } + if (currentTime >= this.duration) { if (this.loop) @@ -351,7 +243,7 @@ class AnimatedGIF extends PIXI.Sprite else { this._currentTime = this.duration; - this.updateFrameIndex(this._frameData.length - 1); + this.updateFrameIndex(this._decompressedFrameData.length - 1); if (typeof this.onComplete === "function") { this.onComplete(); @@ -376,49 +268,25 @@ class AnimatedGIF extends PIXI.Sprite return; } - // let t = performance.now(); - // let i = this._currentFrame; - // let j = 0; - // let patchRow = 0, patchCol = 0; - // let offset = 0; - // let colorData; - // for (j = 0; j < this._frameData[i].pixels.length; j++) { - // colorData = this._frameData[i].colorTable[this._frameData[i].pixels[j]]; - // if (frames[i].pixels[j] !== frames[i].transparentIndex) { - // patchRow = (j / frames[i].dims.width) | 0; - // offset = (this._origDims.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - // patchCol = (j % frames[i].dims.width) * 4; - // this._frameImageData.data[offset + patchCol] = colorData[0]; - // this._frameImageData.data[offset + patchCol + 1] = colorData[1]; - // this._frameImageData.data[offset + patchCol + 2] = colorData[2]; - // this._frameImageData.data[offset + patchCol + 3] = 255; - // } - // } - // Update the current frame - // const { imageData } = this._frames[this._currentFrame]; - // this._context.putImageData(imageData, 0, 0); - let imageData = this._frameImageDataCache[this._currentFrame]; - if (imageData === undefined) + if (this._useFullFrames) { - let t = performance.now(); - let frameLen = this._origDims.width * this._origDims.height * 4; - imageData = new ImageData(new Uint8ClampedArray(this._fullPixelData.buffer, frameLen * this._currentFrame, frameLen), this._origDims.width, this._origDims.height); - this._frameImageDataCache[this._currentFrame] = imageData; - console.log("frame id construction took", performance.now() - t); + this.texture.baseTexture.resource.data = new Uint8Array + ( + this._fullPixelData.buffer, this._currentFrame * this._origDims.width * this._origDims.height * 4, + this._origDims.width * this._origDims.height * 4 + ); + } + else + { + // this._updatePixelsForOneFrame(this._decompressedFrameData[this._currentFrame], this.texture.baseTexture.resource.data); + this._constructNthFullFrame(this._currentFrame, this._prevRenderedFrameIdx, this.texture.baseTexture.resource.data); } - this._context.putImageData(imageData, 0, 0); - - // Workaround hack for Safari & iOS - // which fails to upload canvas after putImageData - // See: https://bugs.webkit.org/show_bug.cgi?id=229986 - this._context.fillStyle = "transparent"; - this._context.fillRect(0, 0, 0, 1); this.texture.update(); - // Mark as clean this.dirty = false; + this._prevRenderedFrameIdx = this._currentFrame; } /** @@ -491,7 +359,7 @@ class AnimatedGIF extends PIXI.Sprite /** Internally handle updating the frame index */ updateFrameIndex(value) { - if (value < 0 || value >= this._frameData.length) + if (value < 0 || value >= this._decompressedFrameData.length) { throw new Error(`Frame index out of range, expecting 0 to ${this.totalFrames}, got ${value}`); } @@ -511,7 +379,7 @@ class AnimatedGIF extends PIXI.Sprite */ get totalFrames() { - return this._frameData.length; + return this._decompressedFrameData.length; } /** Destroy and don't use after this. */ @@ -519,8 +387,7 @@ class AnimatedGIF extends PIXI.Sprite { this.stop(); super.destroy(true); - this._context = null; - this._frameData = null; + this._decompressedFrameData = null; this.onComplete = null; this.onFrameChange = null; this.onLoop = null; @@ -535,14 +402,14 @@ class AnimatedGIF extends PIXI.Sprite */ clone() { - return new AnimatedGIF([...this._frameData], { + return new AnimatedGIF([...this._decompressedFrameData], { autoUpdate: this._autoUpdate, loop: this.loop, autoPlay: this.autoPlay, scaleMode: this.texture.baseTexture.scaleMode, animationSpeed: this.animationSpeed, - width: this._context.canvas.width, - height: this._context.canvas.height, + width: this._origDims.width, + height: this._origDims.height, onComplete: this.onComplete, onFrameChange: this.onFrameChange, onLoop: this.onLoop, @@ -551,4 +418,3 @@ class AnimatedGIF extends PIXI.Sprite } export { AnimatedGIF }; -// export type { AnimatedGIFOptions }; diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 1cabe85..1f01b72 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -77,6 +77,8 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) { super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); + this._resource = undefined; + this._addAttribute( "image", image, @@ -276,30 +278,39 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the image of GifStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the image of GifStim: " + this._name + " as: undefined"); } - else + else if (typeof image === "string") { // image is a string: it should be the name of a resource, which we load - if (typeof image === "string") + const fullRD = this.psychoJS.serverManager.getFullResourceData(image); + console.log("gif resource", fullRD); + if (fullRD.cachedData === undefined) { - image = this.psychoJS.serverManager.getResource(image); - } - - if (image instanceof ArrayBuffer) - { - this.psychoJS.logger.debug(`set the image of GifStim: ${this._name} as ArrayBuffer(${image.length})`); + // How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp + let t0 = performance.now(); + let parsedGif = parseGIF(fullRD.data); + let pt = performance.now() - t0; + let t2 = performance.now(); + let decompressedFrames = decompressFrames(parsedGif, false); + let dect = performance.now() - t2; + this._resource = { parsedGif, decompressedFrames }; + this.psychoJS.serverManager.cacheResourceData(image, this._resource); + // let t2c = performance.now(); + // let pixels2 = decompressFramesContiguous(gif, false); + // window.pixels2 = pixels2; + // let dect2 = performance.now() - t2c; + console.log(`animated gif "${this._name}",`, "parse=", pt, "decompress=", dect); } else { - throw "the argument: " + image.toString() + ' is neither an image nor a video" }'; + this._resource = fullRD.cachedData; } - } - const hasChanged = this.getImage() !== image; - this._setAttribute("image", image, log); - - if (hasChanged) - { - this._onChange(true, true)(); + // this.psychoJS.logger.debug(`set resource of GifStim: ${this._name} as ArrayBuffer(${this._resource.length})`); + const hasChanged = this._setAttribute("image", image, log); + if (hasChanged) + { + this._onChange(true, true)(); + } } } catch (error) @@ -424,141 +435,26 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) this._pixi = undefined; // no image to draw: return immediately - if (typeof this._image === "undefined") + if (typeof this._resource === "undefined") { return; } - if (this._image instanceof ArrayBuffer) + const gifOpts = { - const gifOpts = - { - scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, - loop: this._loop, - autoPlay: this._autoPlay, - animationSpeed: this._animationSpeed - }; - let t0 = performance.now(); - // How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp - let gif = parseGIF(this._image); - let pt = performance.now() - t0; - let t2 = performance.now(); - let frames = decompressFrames(gif, false); - let dect = performance.now() - t2; + generateFullFrames: false, + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, + loop: this._loop, + autoPlay: this._autoPlay, + animationSpeed: this._animationSpeed + }; - let t2c = performance.now(); - // let pixels2 = decompressFramesContiguous(gif, false); - // window.pixels2 = pixels2; - let dect2 = performance.now() - t2c; - window.parsedGif = gif; - window.frames = frames; - - let i, j; - let patchRow = 0; - let patchCol = 0; - let time = 0; - let idFrames = new Array(frames.length); - let pixelData = new Uint8ClampedArray(gif.lsd.width * gif.lsd.height * 4); - let colorData; - let offset = 0; - let t3 = performance.now(); - // for (i = 0; i < frames.length; i++) { - // // offset = (gif.lsd.width * frames[i].dims.top + frames[i].dims.left) * 4; - // // patchRow = 0; - // // pixelData.set(frames[i].patch, offset); - - // // attempt 1 (needs decompressFrames(gif, true), which is an extra step) - // // for (j = 0; j < frames[i].patch.length; j += 4) { - // // if (frames[i].patch[j + 3] > 0) { - // // patchRow = (j / (frames[i].dims.width * 4)) | 0; - // // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - // // patchCol = (j % (frames[i].dims.width * 4)); - // // pixelData[offset + patchCol] = frames[i].patch[j]; - // // pixelData[offset + patchCol + 1] = frames[i].patch[j + 1]; - // // pixelData[offset + patchCol + 2] = frames[i].patch[j + 2]; - // // pixelData[offset + patchCol + 3] = frames[i].patch[j + 3]; - // // } - // // } - - // // attempt 2 - // for (j = 0; j < frames[i].pixels.length; j++) { - // colorData = frames[i].colorTable[frames[i].pixels[j]]; - // if (frames[i].pixels[j] !== frames[i].transparentIndex) { - // patchRow = (j / frames[i].dims.width) | 0; - // offset = (gif.lsd.width * (frames[i].dims.top + patchRow) + frames[i].dims.left) * 4; - // patchCol = (j % frames[i].dims.width) * 4; - // pixelData[offset + patchCol] = colorData[0]; - // pixelData[offset + patchCol + 1] = colorData[1]; - // pixelData[offset + patchCol + 2] = colorData[2]; - // pixelData[offset + patchCol + 3] = 255; - // } - // } - - // idFrames[i] = { - // imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), - // start: time, - // end: time + frames[i].delay - // }; - // time += frames[i].delay; - // } - - // let frameStartIdx = 0; - // let frameEndIdx = 0; - // let t, l, w, h; - // let ct; - // let delay; - // let k = 0; - // let m = 0; - // for (i = 0; i < parsedGif.frames.length; i++) { - // if (parsedGif.frames[i].image) { - // frameEndIdx = frameStartIdx + parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height - 1; - // t = parsedGif.frames[i].image.descriptor.top; - // l = parsedGif.frames[i].image.descriptor.left; - // w = parsedGif.frames[i].image.descriptor.width; - // h = parsedGif.frames[i].image.descriptor.height; - // // color table - // if (parsedGif.frames[i].image.descriptor.lct && parsedGif.frames[i].image.descriptor.lct.exists) { - // ct = parsedGif.frames[i].image.lct; - // } else { - // ct = parsedGif.gct; - // } - // delay = (parsedGif.frames[i].gce.delay || 10) * 10; - - // for (j = frameStartIdx, m = 0; j <= frameEndIdx; j++, m++) { - // colorData = ct[pixels2[j]]; - // if (pixels2[j] !== parsedGif.frames[i].gce.transparentColorIndex) { - // patchRow = (m / w) | 0; - // offset = (gif.lsd.width * (t + patchRow) + l) * 4; - // patchCol = (m % w) * 4; - // pixelData[offset + patchCol] = colorData[0]; - // pixelData[offset + patchCol + 1] = colorData[1]; - // pixelData[offset + patchCol + 2] = colorData[2]; - // pixelData[offset + patchCol + 3] = 255; - // } - // } - - // idFrames[k] = { - // imageData: new ImageData(new Uint8ClampedArray(pixelData), gif.lsd.width, gif.lsd.height), - // start: time, - // end: time + delay - // }; - // k++; - // time += delay; - // frameStartIdx = frameEndIdx + 1; - // } - // } - - let idcomposet = performance.now() - t3; - // this._pixi = new AnimatedGIF(idFrames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); - let t4 = performance.now(); - this._pixi = new AnimatedGIF(frames, { width: gif.lsd.width, height: gif.lsd.height, ...gifOpts }); - console.log("animated gif, parse=", pt, "decompress=", dect, "animationGif inst=", performance.now() - t4, "total=", performance.now() - t0); - console.log("dect2", dect2); - - // t = performance.now(); - // this._pixi = AnimatedGIF.fromBuffer(this._image, gifOpts); - // console.log("pixi animated gif took", performance.now() - t); - } + let t = performance.now(); + this._pixi = new AnimatedGIF( + this._resource.decompressedFrames, + { width: this._resource.parsedGif.lsd.width, height: this._resource.parsedGif.lsd.height, ...gifOpts } + ); + console.log(`animatedGif "${this._name}" instancing:`, performance.now() - t); // add a mask if need be: if (typeof this._mask !== "undefined") From e150fcb60715b940191735c17a71cd38a626c590 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 17 Jul 2022 01:45:37 +0300 Subject: [PATCH 12/52] performance improvement for frame construction of non-optimized gifs; --- src/visual/AnimatedGIF.js | 63 ++++++++++++++++++++++++++++++--------- src/visual/GifStim.js | 1 + 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/visual/AnimatedGIF.js b/src/visual/AnimatedGIF.js index 6b18196..0753b0b 100644 --- a/src/visual/AnimatedGIF.js +++ b/src/visual/AnimatedGIF.js @@ -54,7 +54,7 @@ class AnimatedGIF extends PIXI.Sprite ); super(new PIXI.Texture(PIXI.BaseTexture.fromBuffer(new Uint8Array(width * height * 4), width, height, options))); - + this._name = options.name; this._useFullFrames = options.generateFullFrames; this._decompressedFrameData = decompressedFrames; this._origDims = { width, height }; @@ -95,18 +95,46 @@ class AnimatedGIF extends PIXI.Sprite let patchRow = 0, patchCol = 0; let offset = 0; let colorData; - for (i = 0; i < decompressedFrameData.pixels.length; i++) { - colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]]; - if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) { - patchRow = (i / decompressedFrameData.dims.width) | 0; - offset = (this._origDims.width * (decompressedFrameData.dims.top + patchRow) + decompressedFrameData.dims.left) * 4; - patchCol = (i % decompressedFrameData.dims.width) * 4; - pixelBuffer[offset + patchCol] = colorData[0]; - pixelBuffer[offset + patchCol + 1] = colorData[1]; - pixelBuffer[offset + patchCol + 2] = colorData[2]; - pixelBuffer[offset + patchCol + 3] = 255; + // saving to variable instead of referencing object in the loop wins up to 5ms! + // (at the moment of development observed on Win10, Chrome 103.0.5060.114 (Official Build) (64-bit)) + const gifWidth = this._origDims.width; + + if (decompressedFrameData.pixels.length === pixelBuffer.length / 4) + // if (false) + { + // Not all GIF files are perfectly optimized + // and instead of having tiny patch of pixels that actually changed from previous frame + // they would have a full next frame. + // Knowing that, we can go faster by skipping math needed to determine where to put new pixels + // and just place them 1 to 1 over existing frame (probably internal browser optimizations also kick in). + // For large amounts of gifs running simultaniously this results in 58+FPS vs 15-25+FPS for "else" case. + for (i = 0; i < decompressedFrameData.pixels.length; i++) { + if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) { + colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]]; + offset = i * 4; + pixelBuffer[offset] = colorData[0]; + pixelBuffer[offset + 1] = colorData[1]; + pixelBuffer[offset + 2] = colorData[2]; + pixelBuffer[offset + 3] = 255; + } } } + else + { + for (i = 0; i < decompressedFrameData.pixels.length; i++) { + if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) { + colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]]; + patchRow = (i / decompressedFrameData.dims.width) | 0; + patchCol = i % decompressedFrameData.dims.width; + offset = (gifWidth * (decompressedFrameData.dims.top + patchRow) + decompressedFrameData.dims.left + patchCol) * 4; + pixelBuffer[offset] = colorData[0]; + pixelBuffer[offset + 1] = colorData[1]; + pixelBuffer[offset + 2] = colorData[2]; + pixelBuffer[offset + 3] = 255; + } + } + } + } _constructFullFrames (decompressedFrames, gifWidth, gifHeight) @@ -263,7 +291,12 @@ class AnimatedGIF extends PIXI.Sprite */ updateFrame() { - if (!this.dirty) + // if (!this.dirty) + // { + // return; + // } + + if (this._prevRenderedFrameIdx === this._currentFrame) { return; } @@ -285,7 +318,7 @@ class AnimatedGIF extends PIXI.Sprite this.texture.update(); // Mark as clean - this.dirty = false; + // this.dirty = false; this._prevRenderedFrameIdx = this._currentFrame; } @@ -297,7 +330,9 @@ class AnimatedGIF extends PIXI.Sprite */ _render(renderer) { + let t = performance.now(); this.updateFrame(); + // console.log("t2", this._name, performance.now() - t); super._render(renderer); } @@ -366,7 +401,7 @@ class AnimatedGIF extends PIXI.Sprite if (this._currentFrame !== value) { this._currentFrame = value; - this.dirty = true; + // this.dirty = true; if (typeof this.onFrameChange === "function") { this.onFrameChange(value); diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index 1f01b72..fd465f5 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -442,6 +442,7 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) const gifOpts = { + name: this._name, generateFullFrames: false, scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, loop: this._loop, From 0bbbc09a02eca3f3cb4f1fc65bf4d4b9c1515c27 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 19 Jul 2022 01:09:29 +0300 Subject: [PATCH 13/52] added precomputeFrames attribute to GifStim; --- src/visual/AnimatedGIF.js | 45 +++++++------------- src/visual/GifStim.js | 90 +++++++++++---------------------------- 2 files changed, 42 insertions(+), 93 deletions(-) diff --git a/src/visual/AnimatedGIF.js b/src/visual/AnimatedGIF.js index 0753b0b..6b1a46f 100644 --- a/src/visual/AnimatedGIF.js +++ b/src/visual/AnimatedGIF.js @@ -55,7 +55,7 @@ class AnimatedGIF extends PIXI.Sprite super(new PIXI.Texture(PIXI.BaseTexture.fromBuffer(new Uint8Array(width * height * 4), width, height, options))); this._name = options.name; - this._useFullFrames = options.generateFullFrames; + this._useFullFrames = false; this._decompressedFrameData = decompressedFrames; this._origDims = { width, height }; let i, j, time = 0; @@ -71,9 +71,10 @@ class AnimatedGIF extends PIXI.Sprite } this.duration = this._frameTimings[decompressedFrames.length - 1].end; this._fullPixelData = []; - if (this._useFullFrames) + if (options.fullFrames !== undefined && options.fullFrames.length > 0) { - this._fullPixelData = this._constructFullFrames(decompressedFrames, width, height); + this._fullPixelData = options.fullFrames; + this._useFullFrames = true; } this._playing = false; this._currentTime = 0; @@ -89,15 +90,12 @@ class AnimatedGIF extends PIXI.Sprite } } - _updatePixelsForOneFrame (decompressedFrameData, pixelBuffer) + static updatePixelsForOneFrame (decompressedFrameData, pixelBuffer, gifWidth) { let i = 0; let patchRow = 0, patchCol = 0; let offset = 0; let colorData; - // saving to variable instead of referencing object in the loop wins up to 5ms! - // (at the moment of development observed on Win10, Chrome 103.0.5060.114 (Official Build) (64-bit)) - const gifWidth = this._origDims.width; if (decompressedFrameData.pixels.length === pixelBuffer.length / 4) // if (false) @@ -137,7 +135,7 @@ class AnimatedGIF extends PIXI.Sprite } - _constructFullFrames (decompressedFrames, gifWidth, gifHeight) + static computeFullFrames (decompressedFrames, gifWidth, gifHeight) { let t = performance.now(); let i, j; @@ -148,33 +146,24 @@ class AnimatedGIF extends PIXI.Sprite let fullPixelData = new Uint8Array(gifWidth * gifHeight * 4 * decompressedFrames.length); for (i = 0; i < decompressedFrames.length; i++) { - for (j = 0; j < decompressedFrames[i].pixels.length; j++) - { - colorData = decompressedFrames[i].colorTable[decompressedFrames[i].pixels[j]]; - if (decompressedFrames[i].pixels[j] !== decompressedFrames[i].transparentIndex) - { - patchRow = (j / decompressedFrames[i].dims.width) | 0; - offset = (gifWidth * (decompressedFrames[i].dims.top + patchRow) + decompressedFrames[i].dims.left) * 4; - patchCol = (j % decompressedFrames[i].dims.width) * 4; - pixelData[offset + patchCol] = colorData[0]; - pixelData[offset + patchCol + 1] = colorData[1]; - pixelData[offset + patchCol + 2] = colorData[2]; - pixelData[offset + patchCol + 3] = 255; - } - } + AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelData, gifWidth); fullPixelData.set(pixelData, pixelData.length * i); } - // console.log("full frames construction time", performance.now() - t); + console.log("full frames construction time", performance.now() - t); return fullPixelData; } - _constructNthFullFrame (desiredFrameIdx, prevRenderedFrameIdx, pixelBuffer) + _constructNthFullFrame (desiredFrameIdx, prevRenderedFrameIdx, decompressedFrames, pixelBuffer) { let t = performance.now(); + // saving to variable instead of referencing object in the loop wins up to 5ms! + // (at the moment of development observed on Win10, Chrome 103.0.5060.114 (Official Build) (64-bit)) + const gifWidth = this._origDims.width; let i; for (i = prevRenderedFrameIdx + 1; i <= desiredFrameIdx; i++) { - this._updatePixelsForOneFrame(this._decompressedFrameData[i], pixelBuffer); + // this._updatePixelsForOneFrame(decompressedFrames[i], pixelBuffer); + AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelBuffer, gifWidth) } // console.log("constructed frames from", prevRenderedFrameIdx, "to", desiredFrameIdx, "(", desiredFrameIdx - prevRenderedFrameIdx, ")", performance.now() - t); } @@ -313,7 +302,7 @@ class AnimatedGIF extends PIXI.Sprite else { // this._updatePixelsForOneFrame(this._decompressedFrameData[this._currentFrame], this.texture.baseTexture.resource.data); - this._constructNthFullFrame(this._currentFrame, this._prevRenderedFrameIdx, this.texture.baseTexture.resource.data); + this._constructNthFullFrame(this._currentFrame, this._prevRenderedFrameIdx, this._decompressedFrameData, this.texture.baseTexture.resource.data); } this.texture.update(); @@ -342,12 +331,9 @@ class AnimatedGIF extends PIXI.Sprite * @param {PIXI.CanvasRenderer} renderer - The renderer * @private */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types _renderCanvas(renderer) { this.updateFrame(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore super._renderCanvas(renderer); } @@ -423,6 +409,7 @@ class AnimatedGIF extends PIXI.Sprite this.stop(); super.destroy(true); this._decompressedFrameData = null; + this._fullPixelData = null; this.onComplete = null; this.onFrameChange = null; this.onLoop = null; diff --git a/src/visual/GifStim.js b/src/visual/GifStim.js index fd465f5..710ee0c 100644 --- a/src/visual/GifStim.js +++ b/src/visual/GifStim.js @@ -28,8 +28,11 @@ import { parseGIF, decompressFrames, decompressFramesContiguous } from "../util/ * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window + * @param {boolean} options.precomputeFrames - compute full frames of the GIF and store them. Setting this to true will take the load off the CPU * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * but GIF will take longer to load and occupy more memory space. In case when there's not enough CPU peformance (e.g. due to large amount of GIFs + * playing simultaneously or heavy load elsewhere in experiment) and you don't care much about app memory usage, use this flag to easily gain more performance. * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position @@ -56,6 +59,7 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) win, image, mask, + precomputeFrames, pos, units, ori, @@ -79,64 +83,18 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) this._resource = undefined; - this._addAttribute( - "image", - image, - ); - this._addAttribute( - "mask", - mask, - ); - this._addAttribute( - "color", - color, - "white", - this._onChange(true, false), - ); - this._addAttribute( - "contrast", - contrast, - 1.0, - this._onChange(true, false), - ); - this._addAttribute( - "texRes", - texRes, - 128, - this._onChange(true, false), - ); - this._addAttribute( - "interpolate", - interpolate, - false - ); - this._addAttribute( - "flipHoriz", - flipHoriz, - false, - this._onChange(false, false), - ); - this._addAttribute( - "flipVert", - flipVert, - false, - this._onChange(false, false), - ); - this._addAttribute( - "loop", - loop, - true - ); - this._addAttribute( - "autoPlay", - autoPlay, - true - ); - this._addAttribute( - "animationSpeed", - animationSpeed, - 1 - ); + this._addAttribute("precomputeFrames", precomputeFrames, false); + this._addAttribute("image", image); + this._addAttribute("mask", mask); + this._addAttribute("color", color, "white", this._onChange(true, false)); + this._addAttribute("contrast", contrast, 1.0, this._onChange(true, false)); + this._addAttribute("texRes", texRes, 128, this._onChange(true, false)); + this._addAttribute("interpolate", interpolate, false); + this._addAttribute("flipHoriz", flipHoriz, false, this._onChange(false, false)); + this._addAttribute("flipVert", flipVert, false, this._onChange(false, false)); + this._addAttribute("loop", loop, true); + this._addAttribute("autoPlay", autoPlay, true); + this._addAttribute("animationSpeed", animationSpeed, 1); // estimate the bounding box: this._estimateBoundingBox(); @@ -292,7 +250,12 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) let t2 = performance.now(); let decompressedFrames = decompressFrames(parsedGif, false); let dect = performance.now() - t2; - this._resource = { parsedGif, decompressedFrames }; + let fullFrames; + if (this._precomputeFrames) + { + fullFrames = AnimatedGIF.computeFullFrames(decompressedFrames, parsedGif.lsd.width, parsedGif.lsd.height); + } + this._resource = { parsedGif, decompressedFrames, fullFrames }; this.psychoJS.serverManager.cacheResourceData(image, this._resource); // let t2c = performance.now(); // let pixels2 = decompressFramesContiguous(gif, false); @@ -443,7 +406,9 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) const gifOpts = { name: this._name, - generateFullFrames: false, + width: this._resource.parsedGif.lsd.width, + height: this._resource.parsedGif.lsd.height, + fullFrames: this._resource.fullFrames, scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, loop: this._loop, autoPlay: this._autoPlay, @@ -451,10 +416,7 @@ export class GifStim extends util.mix(VisualStim).with(ColorMixin) }; let t = performance.now(); - this._pixi = new AnimatedGIF( - this._resource.decompressedFrames, - { width: this._resource.parsedGif.lsd.width, height: this._resource.parsedGif.lsd.height, ...gifOpts } - ); + this._pixi = new AnimatedGIF(this._resource.decompressedFrames, gifOpts); console.log(`animatedGif "${this._name}" instancing:`, performance.now() - t); // add a mask if need be: From ee8acd67104b6968ddba228c2bfdd5faa6606465 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 19 Jul 2022 02:10:20 +0300 Subject: [PATCH 14/52] removed pixi-gif dependency; --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index df1dd41..99aa25a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ }, "dependencies": { "@pixi/filter-adjustment": "^4.1.3", - "@pixi/gif": "^1.1.0", "a11y-dialog": "^7.5.0", "esbuild-plugin-glsl": "^1.0.5", "gifuct-js": "^2.1.2", From 2dbfc9c43c4a8e0c65c3273181f1b0671a064cb2 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 24 Oct 2022 13:19:44 +0100 Subject: [PATCH 15/52] xlsx upgrade due to a string parsing bug; --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7a9b92c..6477532 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", - "xlsx": "^0.17.0" + "xlsx": "^0.18.5" }, "devDependencies": { "csslint": "^1.0.5", From c4c7cd8747374f85d60bb1de44d040f06544d59e Mon Sep 17 00:00:00 2001 From: tpronk Date: Fri, 9 Dec 2022 16:19:30 +0000 Subject: [PATCH 16/52] Update node version to 16 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 7d6001b..1c2ccdd 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '12' + node-version: '16' # START: install psychojs_testing - name: Checkout psychojs_testing From 4e3d11ff6c894b6bb03bd81460f13eb8aae54d6e Mon Sep 17 00:00:00 2001 From: tpronk Date: Mon, 12 Dec 2022 16:17:50 +0000 Subject: [PATCH 17/52] BF: Downgrade node version to 14 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 1c2ccdd..68bc285 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '16' + node-version: '14' # START: install psychojs_testing - name: Checkout psychojs_testing From 49512063a73aa784f5d3bf255f3d1255c26cd3ed Mon Sep 17 00:00:00 2001 From: tpronk Date: Mon, 12 Dec 2022 16:27:36 +0000 Subject: [PATCH 18/52] Upgrade to node 15 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 68bc285..8d25449 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '14' + node-version: '15' # START: install psychojs_testing - name: Checkout psychojs_testing From 941b4425b980f430de569cc65948f1e6c6a8a3e4 Mon Sep 17 00:00:00 2001 From: tpronk Date: Tue, 13 Dec 2022 19:49:13 +0000 Subject: [PATCH 19/52] Upgrade to node 15 --- .github/workflows/Automated Test (full).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (full).yml b/.github/workflows/Automated Test (full).yml index cc81b62..3fc879a 100644 --- a/.github/workflows/Automated Test (full).yml +++ b/.github/workflows/Automated Test (full).yml @@ -53,7 +53,7 @@ jobs: - name: Setup node uses: actions/setup-node@v2 with: - node-version: '14' + node-version: '15' - name: Cache modules psychojs_testing uses: actions/cache@v2 env: From da9b892ead2abffd3bff12411d75a13cbe419d65 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Fri, 13 Jan 2023 14:46:07 +0000 Subject: [PATCH 20/52] ENH: Alias "star" and "star7" --- src/visual/ShapeStim.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index b5925e3..dad2807 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -395,3 +395,5 @@ ShapeStim.KnownShapes = { [0.5, 0.0], ], }; +// Alias some names for convenience +ShapeStim.KnownShapes['star'] = ShapeStim.KnownShapes['star7'] From a074ed34f0cfff8fa44d239a118e8ec740e1b4a9 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 26 Jan 2023 09:45:31 +0000 Subject: [PATCH 21/52] blurfilter for image stim v0; --- src/visual/ImageStim.js | 64 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/visual/ImageStim.js b/src/visual/ImageStim.js index f043579..8d73be3 100644 --- a/src/visual/ImageStim.js +++ b/src/visual/ImageStim.js @@ -47,10 +47,34 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ - constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) + constructor({ + name, + win, + image, + mask, + pos, + anchor, + units, + ori, + size, + color, + opacity, + contrast, + texRes, + depth, + interpolate, + flipHoriz, + flipVert, + autoDraw, + autoLog, + blurVal + } = {}) { super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); + // Holds an instance of PIXI blur filter. Used if blur value is passed. + this._blurFilter = undefined; + this._addAttribute( "image", image, @@ -94,6 +118,11 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(false, false), ); + this._addAttribute( + "blurVal", + blurVal, + 0 + ); // estimate the bounding box: this._estimateBoundingBox(); @@ -234,6 +263,33 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) } } + setBlurVal (blurVal = 0, log = false) + { + this._setAttribute("blurVal", blurVal, log); + if (this._pixi instanceof PIXI.Sprite) + { + if (this._blurFilter === undefined) + { + this._blurFilter = new PIXI.filters.BlurFilter(); + this._blurFilter.blur = blurVal; + } + else + { + this._blurFilter.blur = blurVal; + } + + // this._pixi might get destroyed and recreated again with no filters. + if (this._pixi.filters instanceof Array && this._pixi.filters.indexOf(this._blurFilter) === -1) + { + this._pixi.filters.push(this._blurFilter); + } + else + { + this._pixi.filters = [this._blurFilter]; + } + } + } + /** * Estimate the bounding box. * @@ -276,6 +332,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) if (typeof this._pixi !== "undefined") { + this._pixi.filters = null; this._pixi.destroy(true); } this._pixi = undefined; @@ -359,6 +416,11 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); this._pixi.rotation = -this.ori * Math.PI / 180; + if (this._blurVal > 0) + { + this.setBlurVal(this._blurVal); + } + // re-estimate the bounding box, as the texture's width may now be available: this._estimateBoundingBox(); } From c2be1a04ec74cb95ed54022e1f4a73aa7aa6c984 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 26 Jan 2023 12:47:28 +0000 Subject: [PATCH 22/52] FF: Allow TextBox to accept "placeholder" as an input --- src/visual/TextBox.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 3930cbf..4d8e2bc 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -65,6 +65,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) opacity, depth, text, + placeholder, font, letterHeight, bold, @@ -98,7 +99,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) ); this._addAttribute( "placeholder", - text, + placeholder, "", this._onChange(true, true), ); From 9a294f8367dc027bbcd88ea37f76efefb8f24e2e Mon Sep 17 00:00:00 2001 From: lgtst Date: Sat, 28 Jan 2023 15:42:00 +0000 Subject: [PATCH 23/52] particle system v01. --- src/visual/ParticleSystem.js | 238 +++++++++++++++++++++++++++++++++++ src/visual/index.js | 1 + 2 files changed, 239 insertions(+) create mode 100644 src/visual/ParticleSystem.js diff --git a/src/visual/ParticleSystem.js b/src/visual/ParticleSystem.js new file mode 100644 index 0000000..c6c3074 --- /dev/null +++ b/src/visual/ParticleSystem.js @@ -0,0 +1,238 @@ +/** + * Grating Stimulus. + * + * @author Nikita Agafonov + * @version 2022.3.0 + * @copyright (c) 2020-2023 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + */ + +import * as PIXI from "pixi.js-legacy"; + +const DEFAULT_POOL_SIZE = 1024; +const DEFAULT_PARTICLE_WIDTH = 10; +const DEFAULT_PARTICLE_HEIGHT = 10; +const DEFAULT_PARTICLE_LIFETIME = 3; // ms +const DEFAULT_PARTICLE_COLOR = 0xffffff; +const DEFAULT_PARTICLES_PER_SEC = 60; +const DEFAULT_PARTICLE_ACCELERATION = 2500; + +class Particle +{ + constructor (cfg) + { + this.x = 0; + this.y = 0; + this.ax = 0; + this.ay = 0; + this.vx = 0; + this.vy = 0; + this.lifeTime = 0; + this.widthChange = 0; + this.heightChange = 0; + this.sprite = undefined; + this.inUse = false; + + if (cfg.particleImage !== undefined) + { + this.sprite = PIXI.Sprite.from(PIXI.Texture.from(cfg.particleImage)); + } + else + { + this.sprite = new PIXI.Sprite(PIXI.Texture.WHITE); + this.sprite.tint = cfg.particleColor || DEFAULT_PARTICLE_COLOR; + } + + // TODO: Should we instead incorporate that in position calculation? + // Consider: accurate spawn position of the particle confined by spawnArea. + this.sprite.anchor.set(0.5); + + this.width = cfg.width || DEFAULT_PARTICLE_WIDTH; + this.height = cfg.height || DEFAULT_PARTICLE_HEIGHT; + } + + set width (w) + { + this._width = w; + this.sprite.width = w; + } + + get width () + { + return this._width; + } + + set height (h) + { + this._height = h; + this.sprite.height = h; + } + + get height () + { + return this._height; + } + + update (dt) + { + const dt2 = dt ** 2; + + // Update position with current velocity. + this.x = this.x + this.vx * dt + this.ax * dt2 * .5; + this.y = this.y + this.vy * dt + this.ay * dt2 * .5; + + // Update velocity with current acceleration. + this.vx = this.ax * dt; + this.vy = this.ay * dt; + + this.sprite.x = this.x; + this.sprite.y = this.y; + + if (this.width > 0) + { + this.width = Math.max(0, this.width + this.widthChange); + } + + if (this.height > 0) + { + this.height = Math.max(0, this.height + this.heightChange); + } + this.lifeTime -= dt; + + if (this.width <= 0 && this.height <= 0) + { + this.lifeTime = 0; + } + + if (this.lifeTime <= 0) + { + this.inUse = false; + } + } +} + +export class ParticleSystem +{ + constructor (cfg = {}) + { + this.x = 0; + this.y = 0; + this._cfg = cfg; + this._particlesPerSec = cfg.particlesPerSec || DEFAULT_PARTICLES_PER_SEC; + this._spawnCoolDown = 0; + this._parentObj = undefined; + this._particlePool = new Array(DEFAULT_POOL_SIZE); + + if (cfg.parentObject !== undefined) + { + this._parentObj = cfg.parentObject; + } + + this._fillParticlePool(cfg); + } + + _fillParticlePool (cfg) + { + let i; + for (i = 0; i < this._particlePool.length; i++) + { + this._particlePool[i] = new Particle(cfg); + } + } + + _setupParticle (p) + { + let spawnAreaWidth = this._cfg.spawnAreaWidth || 0; + let spawnAreaHeight = this._cfg.spawnAreaHeight || 0; + + if (this._parentObj !== undefined && this._cfg.useParentSizeAsSpawnArea) + { + spawnAreaWidth = this._parentObj.width; + spawnAreaHeight = this._parentObj.height; + } + + const spawnOffsetX = Math.random() * spawnAreaWidth - spawnAreaWidth * .5; + const spawnOffsetY = Math.random() * spawnAreaHeight - spawnAreaHeight * .5; + const x = this.x + spawnOffsetX; + const y = this.y + spawnOffsetY; + + p.x = x; + p.y = y; + + p.ax = this._cfg.initialAx || Math.random() * DEFAULT_PARTICLE_ACCELERATION * 2.0 - DEFAULT_PARTICLE_ACCELERATION; + p.ay = this._cfg.initialAy || Math.random() * DEFAULT_PARTICLE_ACCELERATION * 2.0 - DEFAULT_PARTICLE_ACCELERATION; + p.vx = this._cfg.initialVx || 0; + p.vy = this._cfg.initialVy || 0; + p.lifeTime = this._cfg.lifeTime || DEFAULT_PARTICLE_LIFETIME; + p.width = this._cfg.width || DEFAULT_PARTICLE_WIDTH; + p.height = this._cfg.height || DEFAULT_PARTICLE_HEIGHT; + p.widthChange = this._cfg.widthChange || 0; + p.heightChange = this._cfg.heightChange || 0; + + if (this._cfg.particleColor !== undefined) + { + p.sprite.tint = this._cfg.particleColor; + } + else + { + p.sprite.tint = 0xffffff; + } + } + + _spawnParticles (n = 0) + { + let i; + for (i = 0; i < this._particlePool.length && n > 0; i++) + { + if (this._particlePool[i].inUse === false) + { + this._particlePool[i].inUse = true; + n--; + + this._setupParticle(this._particlePool[i]); + this._cfg.container.addChild(this._particlePool[i].sprite); + } + } + } + + update (dt) + { + // Sync with parent object if it exists. + if (this._parentObj !== undefined) + { + this.x = this._parentObj.x; + this.y = this._parentObj.y; + } + + if (this._spawnCoolDown <= 0) + { + this._spawnCoolDown = 1 / this._particlesPerSec; + + // Assuming that we have at least 60FPS. + const frameTime = Math.min(dt, 1 / 60); + const particlesPerFrame = Math.ceil(frameTime / this._spawnCoolDown); + + // TODO: figure out how to calc amount of particles when it's more than 1 per frame. + this._spawnParticles(particlesPerFrame); + } + else + { + this._spawnCoolDown -= dt; + } + + let i; + for (i = 0; i < this._particlePool.length; i++) + { + if (this._particlePool[i].inUse) + { + this._particlePool[i].update(dt); + } + + // Check if particle should be removed. + if (this._particlePool[i].lifeTime <= 0 && this._particlePool[i].sprite.parent) + { + this._cfg.container.removeChild(this._particlePool[i].sprite); + } + } + } +} diff --git a/src/visual/index.js b/src/visual/index.js index 8c604fa..9fd2574 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -13,3 +13,4 @@ export * from "./TextStim.js"; export * from "./VisualStim.js"; export * from "./FaceDetector.js"; export * from "./Survey.js"; +export * from "./ParticleSystem.js"; From 33967660ab8feefadf93f7d166a4ecfb315344b2 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 31 Jan 2023 07:21:38 +0000 Subject: [PATCH 24/52] particle emitter update. Now supports proper initial settings; particles orient themselves in the direction of velocity. --- .../{ParticleSystem.js => ParticleEmitter.js} | 145 ++++++++++++++---- src/visual/index.js | 2 +- 2 files changed, 120 insertions(+), 27 deletions(-) rename src/visual/{ParticleSystem.js => ParticleEmitter.js} (59%) diff --git a/src/visual/ParticleSystem.js b/src/visual/ParticleEmitter.js similarity index 59% rename from src/visual/ParticleSystem.js rename to src/visual/ParticleEmitter.js index c6c3074..ae7fb23 100644 --- a/src/visual/ParticleSystem.js +++ b/src/visual/ParticleEmitter.js @@ -12,10 +12,10 @@ import * as PIXI from "pixi.js-legacy"; const DEFAULT_POOL_SIZE = 1024; const DEFAULT_PARTICLE_WIDTH = 10; const DEFAULT_PARTICLE_HEIGHT = 10; -const DEFAULT_PARTICLE_LIFETIME = 3; // ms +const DEFAULT_PARTICLE_LIFETIME = 3; // Seconds. const DEFAULT_PARTICLE_COLOR = 0xffffff; const DEFAULT_PARTICLES_PER_SEC = 60; -const DEFAULT_PARTICLE_ACCELERATION = 2500; +const DEFAULT_PARTICLE_V = 100; class Particle { @@ -47,8 +47,8 @@ class Particle // Consider: accurate spawn position of the particle confined by spawnArea. this.sprite.anchor.set(0.5); - this.width = cfg.width || DEFAULT_PARTICLE_WIDTH; - this.height = cfg.height || DEFAULT_PARTICLE_HEIGHT; + this.width = cfg.particleWidth || DEFAULT_PARTICLE_WIDTH; + this.height = cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT; } set width (w) @@ -75,15 +75,17 @@ class Particle update (dt) { - const dt2 = dt ** 2; + const dt2 = dt * dt; - // Update position with current velocity. + // Update velocity with current acceleration. + this.vx += this.ax * dt; + this.vy += this.ay * dt; + + // Update position with current velocity and acceleration. this.x = this.x + this.vx * dt + this.ax * dt2 * .5; this.y = this.y + this.vy * dt + this.ay * dt2 * .5; - // Update velocity with current acceleration. - this.vx = this.ax * dt; - this.vy = this.ay * dt; + this.sprite.rotation = Math.atan2(this.vy, this.vx); this.sprite.x = this.x; this.sprite.y = this.y; @@ -111,7 +113,7 @@ class Particle } } -export class ParticleSystem +export class ParticleEmitter { constructor (cfg = {}) { @@ -122,12 +124,7 @@ export class ParticleSystem this._spawnCoolDown = 0; this._parentObj = undefined; this._particlePool = new Array(DEFAULT_POOL_SIZE); - - if (cfg.parentObject !== undefined) - { - this._parentObj = cfg.parentObject; - } - + this.setParentObject(cfg.parentObject); this._fillParticlePool(cfg); } @@ -159,15 +156,50 @@ export class ParticleSystem p.x = x; p.y = y; - p.ax = this._cfg.initialAx || Math.random() * DEFAULT_PARTICLE_ACCELERATION * 2.0 - DEFAULT_PARTICLE_ACCELERATION; - p.ay = this._cfg.initialAy || Math.random() * DEFAULT_PARTICLE_ACCELERATION * 2.0 - DEFAULT_PARTICLE_ACCELERATION; - p.vx = this._cfg.initialVx || 0; - p.vy = this._cfg.initialVy || 0; + p.ax = 0; + p.ay = 0; + + if (Number.isFinite(this._cfg.initialVx)) + { + p.vx = this._cfg.initialVx; + } + else if (this._cfg.initialVx instanceof Array && this._cfg.initialVx.length >= 2) + { + p.vx = Math.random() * (this._cfg.initialVx[1] - this._cfg.initialVx[0]) + this._cfg.initialVx[0]; + } + else + { + p.vx = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5; + } + + if (Number.isFinite(this._cfg.initialVy)) + { + p.vy = this._cfg.initialVy; + } + else if (this._cfg.initialVy instanceof Array && this._cfg.initialVy.length >= 2) + { + p.vy = Math.random() * (this._cfg.initialVy[1] - this._cfg.initialVy[0]) + this._cfg.initialVy[0]; + } + else + { + p.vy = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5; + } + p.lifeTime = this._cfg.lifeTime || DEFAULT_PARTICLE_LIFETIME; - p.width = this._cfg.width || DEFAULT_PARTICLE_WIDTH; - p.height = this._cfg.height || DEFAULT_PARTICLE_HEIGHT; - p.widthChange = this._cfg.widthChange || 0; - p.heightChange = this._cfg.heightChange || 0; + p.width = this._cfg.particleWidth || DEFAULT_PARTICLE_WIDTH; + p.height = this._cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT; + p.widthChange = this._cfg.particleWidthChange || 0; + p.heightChange = this._cfg.particleHeightChange || 0; + + // TODO: run proper checks here. + if (this._cfg.particleImage) + { + p.sprite.texture = PIXI.Texture.from(this._cfg.particleImage); + } + else + { + p.sprite.texture = PIXI.Texture.WHITE; + } if (this._cfg.particleColor !== undefined) { @@ -195,8 +227,58 @@ export class ParticleSystem } } + _getResultingExternalForce () + { + let externalForce = [0, 0]; + if (this._cfg.externalForces instanceof Array) + { + let i; + for (i = 0; i < this._cfg.externalForces.length; i++) + { + externalForce[0] += this._cfg.externalForces[i][0]; + externalForce[1] += this._cfg.externalForces[i][1]; + } + } + + return externalForce; + } + + setParentObject (po) + { + this._parentObj = po; + } + + /** + * @desc: Adds external force which acts on a particle + * @param: f - Array with two elements, first is x component, second is y component. + * It's a vector of length L which sets the direction and the margnitude of the force. + * */ + addExternalForce (f) + { + this._cfg.externalForces.push(f); + } + + removeExternalForce (f) + { + const i = this._cfg.externalForces.indexOf(f); + if (i !== -1) + { + this._cfg.externalForces.splice(i, 1); + } + } + + removeExternalForceByIdx (idx) + { + if (this._cfg.externalForces[idx] !== undefined) + { + this._cfg.externalForces.splice(idx, 1); + } + } + update (dt) { + let externalForce; + // Sync with parent object if it exists. if (this._parentObj !== undefined) { @@ -204,6 +286,16 @@ export class ParticleSystem this.y = this._parentObj.y; } + if (Number.isFinite(this._cfg.positionOffsetX)) + { + this.x += this._cfg.positionOffsetX; + } + + if (Number.isFinite(this._cfg.positionOffsetY)) + { + this.y += this._cfg.positionOffsetY; + } + if (this._spawnCoolDown <= 0) { this._spawnCoolDown = 1 / this._particlesPerSec; @@ -211,8 +303,6 @@ export class ParticleSystem // Assuming that we have at least 60FPS. const frameTime = Math.min(dt, 1 / 60); const particlesPerFrame = Math.ceil(frameTime / this._spawnCoolDown); - - // TODO: figure out how to calc amount of particles when it's more than 1 per frame. this._spawnParticles(particlesPerFrame); } else @@ -225,6 +315,9 @@ export class ParticleSystem { if (this._particlePool[i].inUse) { + externalForce = this._getResultingExternalForce(); + this._particlePool[i].ax = externalForce[0]; + this._particlePool[i].ay = externalForce[1]; this._particlePool[i].update(dt); } diff --git a/src/visual/index.js b/src/visual/index.js index 9fd2574..cbbf0a5 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -13,4 +13,4 @@ export * from "./TextStim.js"; export * from "./VisualStim.js"; export * from "./FaceDetector.js"; export * from "./Survey.js"; -export * from "./ParticleSystem.js"; +export * from "./ParticleEmitter.js"; From 30c937b2138084bc8c996bd7b826aedaef2bcb26 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 13 Feb 2023 19:40:22 +0000 Subject: [PATCH 25/52] progress bar component prototype. --- src/visual/Progress.js | 133 +++++++++++++++++++++++++++++++++++++++++ src/visual/index.js | 1 + 2 files changed, 134 insertions(+) create mode 100644 src/visual/Progress.js diff --git a/src/visual/Progress.js b/src/visual/Progress.js new file mode 100644 index 0000000..279d4d4 --- /dev/null +++ b/src/visual/Progress.js @@ -0,0 +1,133 @@ +import * as PIXI from "pixi.js-legacy"; +import * as util from "../util/Util.js"; +import { Color } from "../util/Color.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import { VisualStim } from "./VisualStim.js"; + +export class Progress extends VisualStim +{ + constructor ( + { + name, + win, + units, + ori, + opacity, + depth, + pos, + anchor = "left", + size = [300, 30], + clipMask, + autoDraw, + autoLog, + progress = 1, + type, + fillColor, + fillTexture + }) + { + super({ + name, + win, + units, + ori, + opacity, + depth, + pos, + anchor, + size, + clipMask, + autoDraw, + autoLog + }); + + this._addAttribute("progress", progress, 0); + this._addAttribute("type", type, PROGRESS_TYPES.BAR); + this._addAttribute("fillColor", fillColor, "lightgreen"); + this._addAttribute("fillTexture", fillTexture, PIXI.Texture.WHITE); + } + + setProgress (progress = 0, log = false) + { + this._setAttribute("progress", Math.min(1.0, Math.max(0.0, progress)), log); + if (this._pixi !== undefined) + { + this._pixi.clear(); + const size_px = util.to_px(this.size, this.units, this.win); + const pos_px = util.to_px(this.pos, this.units, this.win); + const progressWidth = size_px[0] * this._progress; + if (this._fillTexture) + { + let t = PIXI.Texture.WHITE; + if (typeof this._fillTexture === "string") + { + t = PIXI.Texture.from(this._fillTexture); + t.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; + } + this._pixi.beginTextureFill({ + texture: t + }); + } + else + { + this._pixi.beginFill(new Color(this._fillColor).int, this._opacity); + } + if (this._type === PROGRESS_TYPES.BAR) + { + this._pixi.drawRect(pos_px[0], pos_px[1], progressWidth, size_px[1]); + } + // TODO: check out beginTextureFill(). Perhaps it will allow to use images as filling for progress. + this._pixi.endFill(); + + // TODO: is there a better way to ensure anchor works? + this.anchor = this._anchor; + } + } + + /** + * Update the stimulus, if necessary. + * + * @protected + */ + _updateIfNeeded() + { + // TODO: figure out what is the error with estimateBoundBox on resize? + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + + if (typeof this._pixi !== "undefined") + { + this._pixi.destroy(true); + } + this._pixi = new PIXI.Graphics(); + // TODO: Should we do this? + // this._pixi.lineStyle(this._lineWidth, this._lineColor.int, this._opacity, 0.5); + + // TODO: Should just .setProgress() be called? + this.setProgress(this._progress); + + this._pixi.scale.y = -1; + this._pixi.zIndex = -this._depth; + this.anchor = this._anchor; + } + + // set polygon position and rotation: + // TODO: what's the difference bw to_px and to_pixiPoint? + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180.0; + } +} + +export const PROGRESS_TYPES = +{ + BAR: 0, + CIRCLE: 1 +} diff --git a/src/visual/index.js b/src/visual/index.js index 8c604fa..07c75b0 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -13,3 +13,4 @@ export * from "./TextStim.js"; export * from "./VisualStim.js"; export * from "./FaceDetector.js"; export * from "./Survey.js"; +export * from "./Progress.js"; From bff887b79311d569e340f233c1c53959a32b44ed Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 24 Mar 2023 13:20:34 +0000 Subject: [PATCH 26/52] src/visual --- src/visual/Survey.js | 58 +++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/visual/Survey.js b/src/visual/Survey.js index b573e16..d761224 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -82,6 +82,10 @@ export class Survey extends VisualStim { super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog }); + // Storing all existing signaturePad questions to properly handle their resize. + // Unfortunately signaturepad question type can't handle resizing properly by itself. + this._signaturePads = []; + // whether the user is done with the survey, independently of whether the survey is completed: this.isFinished = false; @@ -968,8 +972,6 @@ export class Survey extends VisualStim this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`); } - this._detachResizeObservers(); - this._surveyRunningPromiseResolve(completionCode); } @@ -1017,6 +1019,10 @@ export class Survey extends VisualStim this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); this._surveyModel.isInitialized = true; this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); + this._surveyModel.onQuestionRemoved.add(() => + { + console.log("question removed") + }) } const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; @@ -1137,34 +1143,58 @@ export class Survey extends VisualStim this._lastPageSwitchHandledIdx = -1; } - _handleSignaturePadResize(entries) + _getQuestionByNameIncludingInDesign(questionName = "") { - for (let i = 0; i < entries.length; i++) + const allQuestions = this._surveyModel.getAllQuestions(false, true); + for (const question of allQuestions) { - // const signatureCanvas = entries[i].target.querySelector("canvas"); - const question = this._surveyModel.getQuestionByName(entries[i].target.dataset.name); - question.signatureWidth = Math.min(question.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize); + if (question.name === questionName) + { + return question; + } + } + } + + _handleWindowResize(e) + { + if (this._surveyModel) + { + for (let i = this._signaturePads.length - 1; i >= 0; i--) + { + // As of writing this (24.03.2023). SurveyJS doesn't have a proper event + // for question being removed from nested locations, such as dynamic panel. + // However, surveyJS will set .signaturePad property to null once the question is removed. + // Utilising this knowledge to sync our lists. + if (this._signaturePads[ i ].question.signaturePad) + { + this._signaturePads[ i ].question.signatureWidth = Math.min( + this._signaturePads[i].question.maxSignatureWidth, + this._signaturePads[ i ].htmlElement.getBoundingClientRect().width + ); + } + else + { + // Signature pad was removed. Syncing list. + this._signaturePads.splice(i, 1); + } + } } } _addEventListeners() { - this._signaturePadRO = new ResizeObserver(this._handleSignaturePadResize.bind(this)); + window.addEventListener("resize", (e) => this._handleWindowResize(e)); } _handleAfterQuestionRender (sender, options) { if (options.question.getType() === "signaturepad") { - this._signaturePadRO.observe(options.htmlElement); + this._signaturePads.push(options); + options.question.signatureWidth = Math.min(options.question.maxSignatureWidth, options.htmlElement.getBoundingClientRect().width); } } - _detachResizeObservers() - { - this._signaturePadRO.disconnect(); - } - /** * Init the SurveyJS.io library and various extensions, setup the theme. * From 5f32881be273542d8dab1bf15c7347ff1d9c1c91 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 24 Mar 2023 13:23:50 +0000 Subject: [PATCH 27/52] removed dead code. --- src/visual/Survey.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/visual/Survey.js b/src/visual/Survey.js index d761224..57bf41f 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -1019,10 +1019,6 @@ export class Survey extends VisualStim this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); this._surveyModel.isInitialized = true; this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); - this._surveyModel.onQuestionRemoved.add(() => - { - console.log("question removed") - }) } const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; @@ -1143,18 +1139,6 @@ export class Survey extends VisualStim this._lastPageSwitchHandledIdx = -1; } - _getQuestionByNameIncludingInDesign(questionName = "") - { - const allQuestions = this._surveyModel.getAllQuestions(false, true); - for (const question of allQuestions) - { - if (question.name === questionName) - { - return question; - } - } - } - _handleWindowResize(e) { if (this._surveyModel) From 122250527c1cb0040e4b1c62c2ca0ed456db221c Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 19 Jul 2023 09:55:49 +0200 Subject: [PATCH 28/52] log in constructor, additional comment --- src/visual/Progress.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/visual/Progress.js b/src/visual/Progress.js index 279d4d4..3963a54 100644 --- a/src/visual/Progress.js +++ b/src/visual/Progress.js @@ -45,8 +45,16 @@ export class Progress extends VisualStim this._addAttribute("type", type, PROGRESS_TYPES.BAR); this._addAttribute("fillColor", fillColor, "lightgreen"); this._addAttribute("fillTexture", fillTexture, PIXI.Texture.WHITE); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } } + /** + * Setter for the progress attribute. + */ setProgress (progress = 0, log = false) { this._setAttribute("progress", Math.min(1.0, Math.max(0.0, progress)), log); From 5dba92ab37c03d5ce52274ca1625d352c1061fc5 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 19 Jul 2023 16:07:43 +0200 Subject: [PATCH 29/52] ENH: various enhancements to the dialog box, resource manager, saving of data, capture of keys, scheduler --- package-lock.json | 815 ++++++++++++++++++++- package.json | 3 +- src/core/EventManager.js | 8 +- src/core/GUI.js | 79 +- src/core/Keyboard.js | 12 +- src/core/PsychoJS.js | 3 +- src/core/ServerManager.js | 2 +- src/core/Window.js | 14 +- src/data/ExperimentHandler.js | 14 + src/index.css | 60 +- src/util/Scheduler.js | 11 + src/util/Util.js | 46 ++ src/visual/ButtonStim.js | 10 +- src/visual/ImageStim.js | 81 +- src/visual/TextBox.js | 63 +- src/visual/survey/widgets/MaxDiffMatrix.js | 21 +- 16 files changed, 1148 insertions(+), 94 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb6071b..eae98bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "license": "MIT", "dependencies": { "@pixi/filter-adjustment": "^4.1.3", @@ -16,6 +16,7 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", @@ -288,6 +289,15 @@ "@pixi/text": "6.0.4" } }, + "node_modules/@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "dependencies": { + "colord": "^2.9.3" + } + }, "node_modules/@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -331,6 +341,12 @@ "@pixi/utils": "6.0.4" } }, + "node_modules/@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "node_modules/@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -639,11 +655,23 @@ "url": "^0.11.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "node_modules/@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "node_modules/a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -869,6 +897,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -957,9 +991,9 @@ } }, "node_modules/earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -1954,6 +1988,409 @@ "node": ">=8" } }, + "node_modules/pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "dependencies": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X", + "@pixi/filter-alpha": "^7.0.0-X", + "@pixi/filter-blur": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "dependencies": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "dependencies": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "node_modules/pixi-filters/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + }, "node_modules/pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", @@ -2682,6 +3119,15 @@ "@pixi/text": "6.0.4" } }, + "@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "requires": { + "colord": "^2.9.3" + } + }, "@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -2721,6 +3167,12 @@ "@pixi/utils": "6.0.4" } }, + "@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -3026,11 +3478,23 @@ "url": "^0.11.0" } }, + "@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -3207,6 +3671,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3268,9 +3738,9 @@ "dev": true }, "earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "emoji-regex": { "version": "8.0.0", @@ -4044,6 +4514,335 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "requires": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + }, + "dependencies": { + "@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "requires": {} + }, + "@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "requires": {} + }, + "@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "requires": {} + }, + "@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "requires": {} + }, + "@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "requires": {} + }, + "@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "requires": {} + }, + "@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "requires": {} + }, + "@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "requires": {} + }, + "@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "requires": {} + }, + "@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "requires": {} + }, + "@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "requires": {} + }, + "@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "requires": {} + }, + "@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "requires": {} + }, + "@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "requires": {} + }, + "@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "requires": {} + }, + "@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "requires": {} + }, + "@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "requires": {} + }, + "@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "requires": {} + }, + "@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "requires": {} + }, + "@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "requires": {} + }, + "@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "requires": {} + }, + "@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "requires": {} + }, + "@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "requires": {} + }, + "@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "requires": {} + }, + "@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "requires": {} + }, + "@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "requires": {} + }, + "@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "requires": {} + }, + "@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "requires": {} + }, + "@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "requires": {} + }, + "@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "requires": {} + }, + "@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "requires": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "requires": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + } + } + }, "pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", diff --git a/package.json b/package.json index 8c7e2cb..0e13461 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.3.1", + "version": "2023.2.1", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", @@ -34,6 +34,7 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", diff --git a/src/core/EventManager.js b/src/core/EventManager.js index c9f8255..245b3a7 100644 --- a/src/core/EventManager.js +++ b/src/core/EventManager.js @@ -302,7 +302,13 @@ export class EventManager { const timestamp = MonotonicClock.getReferenceTime(); - let code = event.code; + // Note: we are using event.key since we are interested in the input character rather than + // the physical key position on the keyboard, i.e. we need to take into account the keyboard + // layout + // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding + // event.code's lack of suitability + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): if (typeof code === "undefined") diff --git a/src/core/GUI.js b/src/core/GUI.js index 90bd3d1..e3cc571 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -50,6 +50,9 @@ export class GUI { this._psychoJS = psychoJS; + // info fields excluded from the GUI: + this._excludedInfo = {}; + // gui listens to RESOURCE events from the server manager: psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) => { @@ -87,9 +90,6 @@ export class GUI requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick }) { - // get info from URL: - const infoFromUrl = util.getUrlParameters(); - this._progressBarMax = 0; this._allResourcesDownloaded = false; this._requiredKeys = []; @@ -113,6 +113,19 @@ export class GUI self._dialogComponent.tStart = t; self._dialogComponent.status = PsychoJS.Status.STARTED; + // prepare the info fields excluded from the GUI, including those from the URL: + const excludedInfo = {}; + for (let key in self._excludedInfo) + { + excludedInfo[key.trim().toLowerCase()] = self._excludedInfo[key]; + } + const infoFromUrl = util.getUrlParameters(); + infoFromUrl.forEach((value, key) => + { + excludedInfo[key.trim().toLowerCase()] = value; + }); + + // if the experiment is licensed, and running on the license rather than on credit, // we use the license logo: if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER @@ -130,7 +143,13 @@ export class GUI markup += "
"; // alert title and close button: - markup += `

${title}

`; + markup += "
"; + markup += `

${title}

`; + markup += ""; + markup += "
"; + + // everything above the buttons is in a scrollable container: + markup += "
"; // logo, if need be: if (typeof logoUrl === "string") @@ -139,14 +158,16 @@ export class GUI } // add a combobox or text areas for each entry in the dictionary: + let atLeastOneIncludedKey = false; Object.keys(dictionary).forEach((key, keyIdx) => { const value = dictionary[key]; const keyId = "form-input-" + keyIdx; // only create an input if the key is not in the URL: - let inUrl = false; const cleanedDictKey = key.trim().toLowerCase(); + const isIncluded = !(cleanedDictKey in excludedInfo); + /*let inUrl = false; infoFromUrl.forEach((urlValue, urlKey) => { const cleanedUrlKey = urlKey.trim().toLowerCase(); @@ -155,10 +176,13 @@ export class GUI inUrl = true; // break; } - }); + });*/ - if (!inUrl) + if (isIncluded) + // if (!inUrl) { + atLeastOneIncludedKey = true; + markup += ``; // if the field is required: @@ -185,7 +209,7 @@ export class GUI markup += ""; } - // otherwise we use a single string input: + // otherwise we use a single string input: //if (typeof value === 'string') else { @@ -199,17 +223,27 @@ export class GUI markup += "

Fields marked with an asterisk (*) are required.

"; } + markup += "
"; // scrollable-container + + // separator, if need be: + if (atLeastOneIncludedKey) + { + markup += "
"; + } + // progress bar: - markup += `
${self._progressMessage}
`; + markup += `
${self._progressMessage}
`; markup += "
"; // buttons: markup += "
"; + markup += "
"; markup += ""; if (self._requireParticipantClick) { markup += ""; } + markup += "
"; // button-group markup += "
"; @@ -346,14 +380,18 @@ export class GUI { const error = this._userFriendlyError(errorCode); markup += `

${error.title}

`; + markup += "
"; markup += `

${error.text}

`; + markup += "
"; } else { markup += `

Error

`; + markup += "
"; markup += `

Unfortunately we encountered the following error:

`; markup += stackCode; markup += "

Try to run the experiment again. If the error persists, contact the experiment designer.

"; + markup += "
"; } } @@ -361,27 +399,36 @@ export class GUI else if (typeof warning !== "undefined") { markup += `

Warning

`; + markup += "
"; markup += `

${warning}

`; + markup += "
"; } // we are displaying a message: else if (typeof message !== "undefined") { - markup += `

Message

`; + markup += "

Message

"; + markup += "
"; markup += `

${message}

`; + markup += "
"; } if (showOK || showCancel) { markup += "
"; } - if (showCancel) + if (showCancel || showOK) { - markup += ""; - } - if (showOK) - { - markup += ""; + markup += "
"; + if (showCancel) + { + markup += ""; + } + if (showOK) + { + markup += ""; + } + markup += "
"; // button-group } markup += ""; diff --git a/src/core/Keyboard.js b/src/core/Keyboard.js index dd2427d..56df760 100644 --- a/src/core/Keyboard.js +++ b/src/core/Keyboard.js @@ -354,7 +354,13 @@ export class Keyboard extends PsychObject */ self._previousKeydownKey = event.key; - let code = event.code; + // Note: we are using event.key since we are interested in the input character rather than + // the physical key position on the keyboard, i.e. we need to take into account the keyboard + // layout + // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding + // event.code's lack of suitability + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): if (typeof code === "undefined") @@ -394,7 +400,9 @@ export class Keyboard extends PsychObject self._previousKeydownKey = undefined; - let code = event.code; + // Note: see above for explanation regarding the use of event.key in lieu of event.code + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft Edge: if (typeof code === "undefined") diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 21d9f35..c8ca9d1 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -530,6 +530,7 @@ export class PsychoJS const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" }; this._experiment.experimentEnded = true; + this._experiment.isCompleted = isCompleted; this.status = PsychoJS.Status.STOPPED; const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER); @@ -601,7 +602,7 @@ export class PsychoJS if (showOK) { - let text = "Thank you for your patience.

"; + let text = "Thank you for your patience."; text += (typeof message !== "undefined") ? message : "Goodbye!"; this._gui.dialog({ message: text, diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 2415c50..d522f6f 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -1293,7 +1293,7 @@ export class ServerManager extends PsychObject } // font files: - else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1) + else if (["ttf", "otf", "woff", "woff2","eot"].indexOf(pathExtension) > -1) { fontResources.push(name); } diff --git a/src/core/Window.js b/src/core/Window.js index cb6acbe..16761a0 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -13,6 +13,7 @@ import { MonotonicClock } from "../util/Clock.js"; import { Color } from "../util/Color.js"; import { PsychObject } from "../util/PsychObject.js"; import { Logger } from "./Logger.js"; +import { hasTouchScreen } from "../util/Util.js"; /** *

Window displays the various stimuli of the experiment.

@@ -181,7 +182,7 @@ export class Window extends PsychObject { // gets updated frame by frame const lastDelta = this.psychoJS.scheduler._lastDelta; - const fps = lastDelta === 0 ? 60.0 : 1000 / lastDelta; + const fps = (lastDelta === 0) ? 60.0 : (1000.0 / lastDelta); return fps; } @@ -493,6 +494,17 @@ export class Window extends PsychObject // update the renderer size and the Window's stimuli whenever the browser's size or orientation change: this._resizeCallback = (e) => { + // if the user device is a mobile phone or tablet (we use the presence of a touch screen as a + // proxy), we need to detect whether the change in size is due to the appearance of a virtual keyboard + // in which case we do not want to resize the canvas. This is rather tricky and so we resort to + // the below trick. It would be better to use the VirtualKeyboard API, but it is not widely + // available just yet, as of 2023-06. + const keyboardHeight = 300; + if (hasTouchScreen() && (window.screen.height - window.visualViewport.height) > keyboardHeight) + { + return; + } + Window._resizePixiRenderer(this, e); this._backgroundSprite.width = this._size[0]; this._backgroundSprite.height = this._size[1]; diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 7a30578..97692b5 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -276,6 +276,7 @@ export class ExperimentHandler extends PsychObject } let data = this._trialsData; + // if the experiment data have to be cleared, we first make a copy of them: if (clear) { @@ -351,6 +352,19 @@ export class ExperimentHandler extends PsychObject } } + /** + * Get the results of the experiment as a .csv string, ready to be uploaded or stored. + * + * @return {string} a .csv representation of the experiment results. + */ + getResultAsCsv() + { + // note: we use the XLSX library as it automatically deals with header, takes care of quotes, + // newlines, etc. + const worksheet = XLSX.utils.json_to_sheet(this._trialsData); + return "\ufeff" + XLSX.utils.sheet_to_csv(worksheet); + } + /** * Get the attribute names and values for the current trial of a given loop. *

Only info relating to the trial execution are returned.

diff --git a/src/index.css b/src/index.css index 301aaa1..8194d84 100644 --- a/src/index.css +++ b/src/index.css @@ -26,13 +26,12 @@ body { /* Project and resource dialogs */ - .dialog-container label, .dialog-container input, .dialog-container select { - box-sizing: border-box; - display: block; - padding-bottom: 0.5em; + box-sizing: border-box; + display: block; + padding-bottom: 0.5em; } .dialog-container input.text, @@ -40,6 +39,13 @@ body { margin-bottom: 1em; padding: 0.5em; width: 100%; + + height: 34px; + border: 1px solid #767676; + border-radius: 2px; + background: #ffffff; + color: #333; + font-size: 14px; } .dialog-container fieldset { @@ -71,12 +77,19 @@ body { } .dialog-content { + display: flex; + flex-direction: column; + row-gap: 0; + margin: auto; z-index: 2; position: relative; width: 500px; max-width: 88vw; + /*max-height: 90vh;*/ + max-height: 93%; + padding: 0.5em; border-radius: 2px; @@ -88,11 +101,24 @@ body { box-shadow: 1px 1px 3px #555555; } +.dialog-content .scrollable-container { + height: 100%; + padding: 0 0.5em; + + overflow-x: hidden; + overflow-y: auto; +} + +.dialog-content hr { + width: 100%; +} + .dialog-title { padding: 0.5em; margin-bottom: 1em; - background-color: #009900; + background-color: #00dd00; + /*background-color: #009900;*/ border-radius: 2px; } @@ -111,6 +137,11 @@ body { } .dialog-close { + display: flex; + justify-content: center; + align-items: center; + line-height: 1.1em; + position: absolute; top: 0.7em; right: 0.7em; @@ -153,7 +184,7 @@ body { .dialog-button { padding: 0.5em 1em 0.5em 1em; - margin: 0.5em 0.5em 0.5em 0; + /*margin: 0.5em 0.5em 0.5em 0;*/ border: 1px solid #555555; border-radius: 2px; @@ -176,6 +207,14 @@ body { border: 1px solid #000000; } +.dialog-button-group { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + column-gap: 0.5em; +} + .disabled { border: 1px solid #AAAAAA; color: #AAAAAA; @@ -186,10 +225,15 @@ body { } .logo { - display: block; + display: flex; + flex: 0 1 auto; + height: 100%; + width: auto; + + /*display: block; margin: 0 auto 1em; max-height: 20vh; - max-width: 100%; + max-width: 100%;*/ } a, diff --git a/src/util/Scheduler.js b/src/util/Scheduler.js index bad709c..4bbaf6e 100644 --- a/src/util/Scheduler.js +++ b/src/util/Scheduler.js @@ -117,9 +117,12 @@ export class Scheduler * Start this scheduler. * *

Note: tasks are run after each animation frame.

+ * + * @return {Promise} a promise resolved when the scheduler stops, e.g. when the experiments finishes */ start() { + let shedulerResolve; const self = this; const update = async (timestamp) => { @@ -127,6 +130,7 @@ export class Scheduler if (self._stopAtNextUpdate) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -137,6 +141,7 @@ export class Scheduler if (state === Scheduler.Event.QUIT) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -155,6 +160,12 @@ export class Scheduler // start the animation: requestAnimationFrame(update); + + // return a promise resolved when the scheduler is stopped: + return new Promise((resolve, _) => + { + shedulerResolve = resolve; + }); } /** diff --git a/src/util/Util.js b/src/util/Util.js index 0845207..72b8514 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -629,6 +629,11 @@ export function toString(object) return object.toString(); } + if (typeof object === "function") + { + return ``; + } + try { const symbolReplacer = (key, value) => @@ -1455,6 +1460,47 @@ export function loadCss(cssId, cssPath) } } +/** + * Whether the user device has a touchscreen, e.g. it is a mobile phone or tablet. + * + * @return {boolean} true if the user device has a touchscreen. + * @note the code below is directly adapted from MDN + */ +export function hasTouchScreen() +{ + let hasTouchScreen = false; + + if ("maxTouchPoints" in navigator) + { + hasTouchScreen = navigator.maxTouchPoints > 0; + } + else if ("msMaxTouchPoints" in navigator) + { + hasTouchScreen = navigator.msMaxTouchPoints > 0; + } + else + { + const mQ = matchMedia?.("(pointer:coarse)"); + if (mQ?.media === "(pointer:coarse)") + { + hasTouchScreen = !!mQ.matches; + } + else if ("orientation" in window) + { + hasTouchScreen = true; + } + else + { + const UA = navigator.userAgent; + hasTouchScreen = + /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || + /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA); + } + } + + return hasTouchScreen; +} + /** * Enum that stores possible text directions. * Note that Arabic is the same as RTL but added here to support PsychoPy's diff --git a/src/visual/ButtonStim.js b/src/visual/ButtonStim.js index c007b51..5b4d34f 100644 --- a/src/visual/ButtonStim.js +++ b/src/visual/ButtonStim.js @@ -9,6 +9,7 @@ import { Mouse } from "../core/Mouse.js"; import { TextBox } from "./TextBox.js"; +import * as util from "../util/Util"; /** *

ButtonStim visual stimulus.

@@ -32,6 +33,7 @@ export class ButtonStim extends TextBox * @param {Color} [options.borderColor= Color("white")] the border color * @param {Color} [options.borderWidth= 0] the border width * @param {number} [options.opacity= 1.0] - the opacity + * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.letterHeight= undefined] - the height of the text * @param {boolean} [options.bold= true] - whether or not the text is bold * @param {boolean} [options.italic= false] - whether or not the text is italic @@ -54,11 +56,14 @@ export class ButtonStim extends TextBox borderColor, borderWidth = 0, opacity, + depth, letterHeight, bold = true, italic, autoDraw, autoLog, + boxFn, + multiline } = {}, ) { @@ -77,12 +82,15 @@ export class ButtonStim extends TextBox borderColor, borderWidth, opacity, + depth, letterHeight, + multiline, bold, italic, alignment: "center", autoDraw, autoLog, + boxFn }); this.psychoJS.logger.debug("create a new Button with name: ", name); @@ -112,7 +120,7 @@ export class ButtonStim extends TextBox if (this._autoLog) { - this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`); } } diff --git a/src/visual/ImageStim.js b/src/visual/ImageStim.js index f043579..1b3da06 100644 --- a/src/visual/ImageStim.js +++ b/src/visual/ImageStim.js @@ -47,7 +47,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ - constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) + constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, aspectRatio, autoDraw, autoLog } = {}) { super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); @@ -94,6 +94,12 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(false, false), ); + this._addAttribute( + "aspectRatio", + aspectRatio, + ImageStim.AspectRatioStrategy.VARIABLE, + this._onChange(true, true), + ); // estimate the bounding box: this._estimateBoundingBox(); @@ -309,7 +315,18 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) this._texture = new PIXI.Texture(new PIXI.BaseTexture(this._image, texOpts)); } - this._pixi = PIXI.Sprite.from(this._texture); + if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + const [width_px, _] = util.to_px([this.size[0], 0], this.units, this.win); + this._pixi = PIXI.TilingSprite.from(this._texture, 1, 1); + this._pixi.width = width_px; + this._pixi.height = this._texture.height; + } + else + { + this._pixi = PIXI.Sprite.from(this._texture); + } + // add a mask if need be: if (typeof this._mask !== "undefined") @@ -349,8 +366,24 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._texture.width; - const scaleY = size_px[1] / this._texture.height; + let scaleX = size_px[0] / this._texture.width; + let scaleY = size_px[1] / this._texture.height; + if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH) + { + scaleY = scaleX; + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT) + { + scaleX = scaleY; + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + scaleX = 1.0; + scaleY = 1.0; + } + + // note: this calls VisualStim.setAnchor, which properly sets the PixiJS anchor + // from the PsychoPy text format this.anchor = this._anchor; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; @@ -383,7 +416,47 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) displaySize = util.to_unit(textureSize, "pix", this.win, this.units); } } + else + { + if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[0], displaySize[0] * this._texture.height / this._texture.width]; + } + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[1] * this._texture.width / this._texture.height, displaySize[1]]; + } + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[0], this._texture.height]; + } + } + } return displaySize; } } + +/** + * ImageStim Aspect Ratio Strategy. + * + * @enum {Symbol} + * @readonly + */ +ImageStim.AspectRatioStrategy = { + FIT_TO_WIDTH: Symbol.for("FIT_TO_WIDTH"), + HORIZONTAL_TILING: Symbol.for("HORIZONTAL_TILING"), + FIT_TO_HEIGHT: Symbol.for("FIT_TO_HEIGHT"), + VARIABLE: Symbol.for("VARIABLE"), +}; diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 4d8e2bc..ab06378 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -86,7 +86,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) clipMask, autoDraw, autoLog, - fitToContent + fitToContent, + boxFn } = {}, ) { @@ -202,12 +203,14 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) // and setSize called from super class would not have a proper effect this.setSize(size); + this._addAttribute("boxFn", boxFn, null); + // estimate the bounding box: this._estimateBoundingBox(); if (this._autoLog) { - this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`); } } @@ -481,6 +484,26 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) alignmentStyles = ["center", "center"]; } + let box; + if (this._boxFn !== null) + { + box = this._boxFn; + } + else + { + // note: box style properties eventually become PIXI.Graphics settings, so same syntax applies + box = { + fill: new Color(this._fillColor).int, + alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, + rounded: 5, + stroke: { + color: new Color(this._borderColor).int, + width: borderWidth_px, + alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 + } + }; + } + return { // input style properties eventually become CSS, so same syntax applies input: { @@ -504,41 +527,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) overflow: "hidden", pointerEvents: "none" }, - // box style properties eventually become PIXI.Graphics settings, so same syntax applies - box: { - fill: new Color(this._fillColor).int, - alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px, - alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 - }, - /*default: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }, - focused: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }, - disabled: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }*/ - }, + box }; } diff --git a/src/visual/survey/widgets/MaxDiffMatrix.js b/src/visual/survey/widgets/MaxDiffMatrix.js index d9958c5..a50c784 100644 --- a/src/visual/survey/widgets/MaxDiffMatrix.js +++ b/src/visual/survey/widgets/MaxDiffMatrix.js @@ -95,18 +95,11 @@ class MaxDiffMatrix question.setCssRoot(rootClass); question.cssClasses.mainRoot = rootClass; } - let html; - let headerCells = ""; - let subHeaderCells = ""; - let bodyCells = ""; - let bodyHTML = ""; - let cellGenerator; - let i, j; // Relying on a fact that there's always 2 columns. // This is correct according current Qualtrics design for MaxDiff matrices. // Header generation - headerCells = + let headerCells = `${question.columns[0].text} @@ -114,9 +107,10 @@ class MaxDiffMatrix ${question.columns[1].text}`; // Body generation - for (i = 0; i < question.rows.length; i++) + let bodyHTML = ""; + for (let i = 0; i < question.rows.length; i++) { - bodyCells = + const bodyCells = `