mirror of
https://github.com/psychopy/psychojs.git
synced 2025-05-10 10:40:54 +00:00
Merge pull request #516 from lightest/CU-2j4rb2p_gif_support
Gif support
This commit is contained in:
commit
465dcec620
27
package-lock.json
generated
27
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"a11y-dialog": "^7.5.0",
|
||||
"docdash": "^1.2.0",
|
||||
"esbuild-plugin-glsl": "^1.0.5",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"howler": "^2.2.1",
|
||||
"log4javascript": "github:Ritzlgrmft/log4javascript",
|
||||
"pako": "^1.0.10",
|
||||
@ -2090,6 +2091,14 @@
|
||||
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"dependencies": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
@ -2208,6 +2217,11 @@
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="
|
||||
},
|
||||
"node_modules/js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -5140,6 +5154,14 @@
|
||||
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
|
||||
"dev": true
|
||||
},
|
||||
"gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"requires": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
@ -5234,6 +5256,11 @@
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="
|
||||
},
|
||||
"js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
@ -34,6 +34,7 @@
|
||||
"a11y-dialog": "^7.5.0",
|
||||
"docdash": "^1.2.0",
|
||||
"esbuild-plugin-glsl": "^1.0.5",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"howler": "^2.2.1",
|
||||
"log4javascript": "github:Ritzlgrmft/log4javascript",
|
||||
"pako": "^1.0.10",
|
||||
|
@ -314,6 +314,46 @@ 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.<string, *>} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a resource.
|
||||
*
|
||||
@ -662,6 +702,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.
|
||||
*
|
||||
@ -1265,7 +1318,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,
|
||||
@ -1310,7 +1363,7 @@ export class ServerManager extends PsychObject
|
||||
preloadManifest.push(/*new createjs.LoadItem().set(*/ {
|
||||
id: name,
|
||||
src: pathStatusData.path,
|
||||
crossOrigin: "Anonymous",
|
||||
crossOrigin: "Anonymous"
|
||||
} /*)*/);
|
||||
}
|
||||
}
|
||||
|
@ -66,8 +66,16 @@ psychoJS.start({
|
||||
expName: expName,
|
||||
expInfo: expInfo,
|
||||
configURL: "../config.json",
|
||||
resources: [
|
||||
// {
|
||||
resources: [
|
||||
{
|
||||
name: "cool.gif",
|
||||
path: "./test_resources/cool.gif"
|
||||
},
|
||||
{
|
||||
name: "delorean.gif",
|
||||
path: "./test_resources/delorean.gif"
|
||||
}
|
||||
// {
|
||||
// name: "007",
|
||||
// path: "007.jpg"
|
||||
// },
|
||||
@ -126,17 +134,28 @@ async function experimentInit() {
|
||||
gaborClock = new util.Clock();
|
||||
|
||||
stims.push(
|
||||
new visual.GratingStim({
|
||||
win : psychoJS.window,
|
||||
name: 'morph',
|
||||
tex: 'sin',
|
||||
mask: undefined,
|
||||
ori: 0,
|
||||
size: [256, 512],
|
||||
pos: [0, 0],
|
||||
units: "pix",
|
||||
depth: 0
|
||||
})
|
||||
// new visual.GratingStim({
|
||||
// win : psychoJS.window,
|
||||
// name: 'morph',
|
||||
// tex: 'sin',
|
||||
// mask: undefined,
|
||||
// ori: 0,
|
||||
// size: [256, 512],
|
||||
// pos: [0, 0],
|
||||
// units: "pix",
|
||||
// depth: 0
|
||||
// })
|
||||
new visual.GifStim({
|
||||
win : psychoJS.window,
|
||||
name: 'morph',
|
||||
image: "cool.gif",
|
||||
mask: undefined,
|
||||
ori: 0,
|
||||
size: [512, 512],
|
||||
pos: [0, 0],
|
||||
units: "pix",
|
||||
depth: 0
|
||||
})
|
||||
);
|
||||
|
||||
window.stims = stims;
|
||||
|
BIN
src/test_resources/cool.gif
Normal file
BIN
src/test_resources/cool.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 868 KiB |
BIN
src/test_resources/delorean.gif
Normal file
BIN
src/test_resources/delorean.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.0 MiB |
278
src/util/GifParser.js
Normal file
278
src/util/GifParser.js
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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, (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
|
||||
*
|
||||
*/
|
||||
|
||||
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, memoryBuffer, bufferOffset) => {
|
||||
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(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)
|
||||
|
||||
// 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 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, memoryBuffer, memoryOffset) => {
|
||||
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, memoryBuffer, memoryOffset)
|
||||
|
||||
// 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 framesWithData = 0;
|
||||
let out ;
|
||||
let i, j = 0;
|
||||
|
||||
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, 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++;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
441
src/visual/AnimatedGIF.js
Normal file
441
src/visual/AnimatedGIF.js
Normal file
@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
/**
|
||||
* @param frames - Data of the GIF image.
|
||||
* @param options - Options for the AnimatedGIF
|
||||
*/
|
||||
constructor(decompressedFrames, options)
|
||||
{
|
||||
// Get the options, apply defaults
|
||||
const { scaleMode, width, height, ...rest } = Object.assign({},
|
||||
AnimatedGIF.defaultOptions,
|
||||
options
|
||||
);
|
||||
|
||||
super(new PIXI.Texture(PIXI.BaseTexture.fromBuffer(new Uint8Array(width * height * 4), width, height, options)));
|
||||
this._name = options.name;
|
||||
this._useFullFrames = false;
|
||||
this._decompressedFrameData = decompressedFrames;
|
||||
this._origDims = { width, height };
|
||||
let i, j, time = 0;
|
||||
this._frameTimings = new Array(decompressedFrames.length);
|
||||
for (i = 0; i < decompressedFrames.length; i++)
|
||||
{
|
||||
this._frameTimings[i] =
|
||||
{
|
||||
start: time,
|
||||
end: time + decompressedFrames[i].delay
|
||||
};
|
||||
time += decompressedFrames[i].delay;
|
||||
}
|
||||
this.duration = this._frameTimings[decompressedFrames.length - 1].end;
|
||||
this._fullPixelData = [];
|
||||
if (options.fullFrames !== undefined && options.fullFrames.length > 0)
|
||||
{
|
||||
this._fullPixelData = options.fullFrames;
|
||||
this._useFullFrames = true;
|
||||
}
|
||||
this._playing = false;
|
||||
this._currentTime = 0;
|
||||
this._isConnectedToTicker = false;
|
||||
Object.assign(this, rest);
|
||||
|
||||
// Draw the first frame
|
||||
this.currentFrame = 0;
|
||||
this._prevRenderedFrameIdx = -1;
|
||||
if (this.autoPlay)
|
||||
{
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
static updatePixelsForOneFrame (decompressedFrameData, pixelBuffer, gifWidth)
|
||||
{
|
||||
let i = 0;
|
||||
let patchRow = 0, patchCol = 0;
|
||||
let offset = 0;
|
||||
let colorData;
|
||||
|
||||
if (decompressedFrameData.pixels.length === pixelBuffer.length / 4)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static computeFullFrames (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++)
|
||||
{
|
||||
AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelData, gifWidth);
|
||||
fullPixelData.set(pixelData, pixelData.length * i);
|
||||
}
|
||||
console.log("full frames construction time", performance.now() - t);
|
||||
return fullPixelData;
|
||||
}
|
||||
|
||||
_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(decompressedFrames[i], pixelBuffer);
|
||||
AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelBuffer, gifWidth)
|
||||
}
|
||||
// console.log("constructed frames from", prevRenderedFrameIdx, "to", desiredFrameIdx, "(", desiredFrameIdx - prevRenderedFrameIdx, ")", performance.now() - t);
|
||||
}
|
||||
|
||||
/** 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._decompressedFrameData.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 (this._prevRenderedFrameIdx > localFrame)
|
||||
{
|
||||
this._prevRenderedFrameIdx = -1;
|
||||
}
|
||||
|
||||
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._decompressedFrameData.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;
|
||||
// }
|
||||
|
||||
if (this._prevRenderedFrameIdx === this._currentFrame)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the current frame
|
||||
if (this._useFullFrames)
|
||||
{
|
||||
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._decompressedFrameData, this.texture.baseTexture.resource.data);
|
||||
}
|
||||
|
||||
this.texture.update();
|
||||
// Mark as clean
|
||||
// this.dirty = false;
|
||||
this._prevRenderedFrameIdx = this._currentFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the object using the WebGL renderer
|
||||
*
|
||||
* @param {PIXI.Renderer} renderer - The renderer
|
||||
* @private
|
||||
*/
|
||||
_render(renderer)
|
||||
{
|
||||
let t = performance.now();
|
||||
this.updateFrame();
|
||||
// console.log("t2", this._name, performance.now() - t);
|
||||
super._render(renderer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the object using the WebGL renderer
|
||||
*
|
||||
* @param {PIXI.CanvasRenderer} renderer - The renderer
|
||||
* @private
|
||||
*/
|
||||
_renderCanvas(renderer)
|
||||
{
|
||||
this.updateFrame();
|
||||
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._decompressedFrameData.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._decompressedFrameData.length;
|
||||
}
|
||||
|
||||
/** Destroy and don't use after this. */
|
||||
destroy()
|
||||
{
|
||||
this.stop();
|
||||
super.destroy(true);
|
||||
this._decompressedFrameData = null;
|
||||
this._fullPixelData = 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._decompressedFrameData], {
|
||||
autoUpdate: this._autoUpdate,
|
||||
loop: this.loop,
|
||||
autoPlay: this.autoPlay,
|
||||
scaleMode: this.texture.baseTexture.scaleMode,
|
||||
animationSpeed: this.animationSpeed,
|
||||
width: this._origDims.width,
|
||||
height: this._origDims.height,
|
||||
onComplete: this.onComplete,
|
||||
onFrameChange: this.onFrameChange,
|
||||
onLoop: this.onLoop,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { AnimatedGIF };
|
515
src/visual/GifStim.js
Normal file
515
src/visual/GifStim.js
Normal file
@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 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 { parseGIF, decompressFrames } from "gifuct-js";
|
||||
import { AnimatedGIF } from "./AnimatedGIF.js";
|
||||
import { parseGIF, decompressFrames } from "../util/GifParser.js";
|
||||
|
||||
/**
|
||||
* 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 {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.<number>} [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.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
|
||||
* @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,
|
||||
precomputeFrames,
|
||||
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 });
|
||||
|
||||
this._resource = undefined;
|
||||
|
||||
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();
|
||||
|
||||
if (this._autoLog)
|
||||
{
|
||||
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 or 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 if (typeof image === "string")
|
||||
{
|
||||
// image is a string: it should be the name of a resource, which we load
|
||||
const fullRD = this.psychoJS.serverManager.getFullResourceData(image);
|
||||
console.log("gif resource", fullRD);
|
||||
if (fullRD.cachedData === undefined)
|
||||
{
|
||||
// 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;
|
||||
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);
|
||||
console.log(`animated gif "${this._name}",`, "parse=", pt, "decompress=", dect);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._resource = fullRD.cachedData;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the size attribute.
|
||||
*
|
||||
* @param {undefined | null | number | number[]} size - the stimulus size
|
||||
* @param {boolean} [log= false] - whether of not to log
|
||||
*/
|
||||
setSize(size, log = false)
|
||||
{
|
||||
// size is either undefined, null, or a tuple of numbers:
|
||||
if (typeof size !== "undefined" && size !== null)
|
||||
{
|
||||
size = util.toNumerical(size);
|
||||
if (!Array.isArray(size))
|
||||
{
|
||||
size = [size, size];
|
||||
}
|
||||
}
|
||||
|
||||
this._setAttribute("size", size, log);
|
||||
|
||||
if (this._pixi)
|
||||
{
|
||||
const size_px = util.to_px(size, 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._resource === "undefined")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const gifOpts =
|
||||
{
|
||||
name: this._name,
|
||||
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,
|
||||
animationSpeed: this._animationSpeed
|
||||
};
|
||||
|
||||
let t = performance.now();
|
||||
this._pixi = new AnimatedGIF(this._resource.decompressedFrames, gifOpts);
|
||||
console.log(`animatedGif "${this._name}" instancing:`, performance.now() - t);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 (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)
|
||||
{
|
||||
const textureSize = [this._pixi.texture.width, this._pixi.texture.height];
|
||||
displaySize = util.to_unit(textureSize, "pix", this.win, this.units);
|
||||
}
|
||||
}
|
||||
|
||||
return displaySize;
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user