From a59bfcab11bb30165120ed7b10e7d2e738e53e34 Mon Sep 17 00:00:00 2001 From: jdescottes Date: Sat, 12 Apr 2014 18:40:25 +0200 Subject: [PATCH] Feature : import sprites from GIF --- .../controller/settings/ImportController.js | 59 +- src/js/lib/gif/libgif.js | 665 ++++++++++++++++++ src/js/utils/CanvasUtils.js | 7 + src/piskel-script-list.js | 1 + 4 files changed, 718 insertions(+), 14 deletions(-) create mode 100644 src/js/lib/gif/libgif.js diff --git a/src/js/controller/settings/ImportController.js b/src/js/controller/settings/ImportController.js index 0186adfe..7ad4d9e1 100644 --- a/src/js/controller/settings/ImportController.js +++ b/src/js/controller/settings/ImportController.js @@ -112,6 +112,11 @@ ns.ImportController.prototype.onImageLoaded_ = function (evt) { var w = this.importedImage_.width, h = this.importedImage_.height; + + // FIXME : We remove the onload callback here because JsGif will insert + // the image again and we want to avoid retriggering the image onload + this.importedImage_.onload = function () {}; + var filePath = this.hiddenFileInput.val(); var fileName = this.extractFileNameFromPath_(filePath); this.fileInputStatus.html(fileName); @@ -144,27 +149,53 @@ }; ns.ImportController.prototype.importImageToPiskel_ = function () { - if (this.importedImage_) { + var image = this.importedImage_; + if (image) { if (window.confirm('You are about to create a new Piskel, unsaved changes will be lost.')) { - var w = this.resizeWidth.val(), - h = this.resizeHeight.val(), - smoothing = !!this.smoothResize.prop('checked'); + var gifLoader = new window.SuperGif({ + gif : image + }); - var image = pskl.utils.ImageResizer.resize(this.importedImage_, w, h, smoothing); - var frame = pskl.utils.FrameUtils.createFromImage(image); + gifLoader.load({ + success : function(){ + var images = gifLoader.getFrames().map(function (frame) { + return pskl.CanvasUtils.createFromImageData(frame.data); + }); + this.createPiskelFromImages_(images); + }.bind(this), + error : function () { + this.createPiskelFromImages_([image]); + }.bind(this) + }); - var layer = pskl.model.Layer.fromFrames('Layer 1', [frame]); - - var descriptor = new pskl.model.piskel.Descriptor('Imported piskel', ''); - var piskel = pskl.model.Piskel.fromLayers([layer], descriptor); - - pskl.app.piskelController.setPiskel(piskel); - pskl.app.animationController.setFPS(Constants.DEFAULT.FPS); - this.reset_(); } } }; + ns.ImportController.prototype.createFramesFromImages_ = function (images) { + var w = this.resizeWidth.val(); + var h = this.resizeHeight.val(); + var smoothing = !!this.smoothResize.prop('checked'); + + var frames = images.map(function (image) { + var resizedImage = pskl.utils.ImageResizer.resize(image, w, h, smoothing); + return pskl.utils.FrameUtils.createFromImage(resizedImage); + }); + return frames; + }; + + ns.ImportController.prototype.createPiskelFromImages_ = function (images) { + var frames = this.createFramesFromImages_(images); + var layer = pskl.model.Layer.fromFrames('Layer 1', frames); + var descriptor = new pskl.model.piskel.Descriptor('Imported piskel', ''); + var piskel = pskl.model.Piskel.fromLayers([layer], descriptor); + + pskl.app.piskelController.setPiskel(piskel); + pskl.app.animationController.setFPS(Constants.DEFAULT.FPS); + + this.reset_(); + }; + ns.ImportController.prototype.isImage_ = function (file) { return file.type.indexOf('image') === 0; }; diff --git a/src/js/lib/gif/libgif.js b/src/js/lib/gif/libgif.js new file mode 100644 index 00000000..ef1d6ab4 --- /dev/null +++ b/src/js/lib/gif/libgif.js @@ -0,0 +1,665 @@ +( function( window ) { 'use strict'; + +// Stream +/** + * @constructor + */ +// Make compiler happy. +var Stream = function (data) { + this.data = data; + this.len = this.data.length; + this.pos = 0; +}; + +Stream.prototype.readByte = function () { + if (this.pos >= this.data.length) { + throw new Error('Attempted to read past end of stream.'); + } + return this.data.charCodeAt(this.pos++) & 0xFF; +}; + +Stream.prototype.readBytes = function (n) { + var bytes = []; + for (var i = 0; i < n; i++) { + bytes.push(this.readByte()); + } + return bytes; +}; + +Stream.prototype.read = function (n) { + var s = ''; + for (var i = 0; i < n; i++) { + s += String.fromCharCode(this.readByte()); + } + return s; +}; + +Stream.prototype.readUnsigned = function () { // Little-endian. + var a = this.readBytes(2); + return (a[1] << 8) + a[0]; +}; + +var SuperGIF = window.SuperGIF = window.SuperGIF || {}; +SuperGIF.Stream = Stream; + +})( window ); + +/* + SuperGif + + Example usage: + + + + + + Image tag attributes: + + data-animated-src - If this url is specified, it's loaded into the player instead of src. + This allows a preview frame to be shown until animated gif data is streamed into the canvas + + Constructor options args + + gif Required. The DOM element of an img tag. + + Instance methods + + // loading + load( callback ) Loads the gif into a canvas element and then calls callback if one is passed + + For additional customization (viewport inside iframe) these params may be passed: + c_w, c_h - width and height of canvas + vp_t, vp_l, vp_ w, vp_h - top, left, width and height of the viewport + + A bonus: few articles to understand what is going on + http://enthusiasms.org/post/16976438906 + http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + http://humpy77.deviantart.com/journal/Frame-Delay-Times-for-Animated-GIFs-214150546 + +*/ + +( function( window ) { 'use strict'; + +// Generic functions +var bitsToNum = function (ba) { + return ba.reduce(function (s, n) { + return s * 2 + n; + }, 0); +}; + +var byteToBitArr = function (bite) { + var a = []; + for (var i = 7; i >= 0; i--) { + a.push( !! (bite & (1 << i))); + } + return a; +}; + +// Stream +/** + * @constructor + */ +// Make compiler happy. +var Stream = function (data) { + this.data = data; + this.len = this.data.length; + this.pos = 0; + + this.readByte = function () { + if (this.pos >= this.data.length) { + throw new Error('Attempted to read past end of stream.'); + } + return data.charCodeAt(this.pos++) & 0xFF; + }; + + this.readBytes = function (n) { + var bytes = []; + for (var i = 0; i < n; i++) { + bytes.push(this.readByte()); + } + return bytes; + }; + + this.read = function (n) { + var s = ''; + for (var i = 0; i < n; i++) { + s += String.fromCharCode(this.readByte()); + } + return s; + }; + + this.readUnsigned = function () { // Little-endian. + var a = this.readBytes(2); + return (a[1] << 8) + a[0]; + }; +}; + +var lzwDecode = function (minCodeSize, data) { + // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String? + var pos = 0; // Maybe this streaming thing should be merged with the Stream? + var readCode = function (size) { + var code = 0; + for (var i = 0; i < size; i++) { + if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) { + code |= 1 << i; + } + pos++; + } + return code; + }; + + var output = []; + + var clearCode = 1 << minCodeSize; + var eoiCode = clearCode + 1; + + var codeSize = minCodeSize + 1; + + var dict = []; + + var clear = function () { + dict = []; + codeSize = minCodeSize + 1; + for (var i = 0; i < clearCode; i++) { + dict[i] = [i]; + } + dict[clearCode] = []; + dict[eoiCode] = null; + + }; + + var code; + var last; + + while (true) { + last = code; + code = readCode(codeSize); + + if (code === clearCode) { + clear(); + continue; + } + if (code === eoiCode) break; + + if (code < dict.length) { + if (last !== clearCode) { + dict.push(dict[last].concat(dict[code][0])); + } + } + else { + if (code !== dict.length) throw new Error('Invalid LZW code.'); + dict.push(dict[last].concat(dict[last][0])); + } + output.push.apply(output, dict[code]); + + if (dict.length === (1 << codeSize) && codeSize < 12) { + // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long. + codeSize++; + } + } + + // I don't know if this is technically an error, but some GIFs do it. + //if (Math.ceil(pos / 8) !== data.length) throw new Error('Extraneous LZW bytes.'); + return output; +}; + + +// The actual parsing; returns an object with properties. +var parseGIF = function (st, handler) { + handler || (handler = {}); + + // LZW (GIF-specific) + var parseCT = function (entries) { // Each entry is 3 bytes, for RGB. + var ct = []; + for (var i = 0; i < entries; i++) { + ct.push(st.readBytes(3)); + } + return ct; + }; + + var readSubBlocks = function () { + var size, data; + data = ''; + do { + size = st.readByte(); + data += st.read(size); + } while (size !== 0); + return data; + }; + + var parseHeader = function () { + var hdr = {}; + hdr.sig = st.read(3); + hdr.ver = st.read(3); + if (hdr.sig !== 'GIF') { + handler.onError(); // XXX: This should probably be handled more nicely. + throw new Error('Not a GIF file.'); + } + hdr.width = st.readUnsigned(); + hdr.height = st.readUnsigned(); + + var bits = byteToBitArr(st.readByte()); + hdr.gctFlag = bits.shift(); + hdr.colorRes = bitsToNum(bits.splice(0, 3)); + hdr.sorted = bits.shift(); + hdr.gctSize = bitsToNum(bits.splice(0, 3)); + + hdr.bgColor = st.readByte(); + hdr.pixelAspectRatio = st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 + if (hdr.gctFlag) { + hdr.gct = parseCT(1 << (hdr.gctSize + 1)); + } + handler.hdr && handler.hdr(hdr); + }; + + var parseExt = function (block) { + var parseGCExt = function (block) { + var blockSize = st.readByte(); // Always 4 + var bits = byteToBitArr(st.readByte()); + block.reserved = bits.splice(0, 3); // Reserved; should be 000. + block.disposalMethod = bitsToNum(bits.splice(0, 3)); + block.userInput = bits.shift(); + block.transparencyGiven = bits.shift(); + + block.delayTime = st.readUnsigned(); + + block.transparencyIndex = st.readByte(); + + block.terminator = st.readByte(); + + handler.gce && handler.gce(block); + }; + + var parseComExt = function (block) { + block.comment = readSubBlocks(); + handler.com && handler.com(block); + }; + + var parsePTExt = function (block) { + // No one *ever* uses this. If you use it, deal with parsing it yourself. + var blockSize = st.readByte(); // Always 12 + block.ptHeader = st.readBytes(12); + block.ptData = readSubBlocks(); + handler.pte && handler.pte(block); + }; + + var parseAppExt = function (block) { + var parseNetscapeExt = function (block) { + var blockSize = st.readByte(); // Always 3 + block.unknown = st.readByte(); // ??? Always 1? What is this? + block.iterations = st.readUnsigned(); + block.terminator = st.readByte(); + handler.app && handler.app.NETSCAPE && handler.app.NETSCAPE(block); + }; + + var parseUnknownAppExt = function (block) { + block.appData = readSubBlocks(); + // FIXME: This won't work if a handler wants to match on any identifier. + handler.app && handler.app[block.identifier] && handler.app[block.identifier](block); + }; + + var blockSize = st.readByte(); // Always 11 + block.identifier = st.read(8); + block.authCode = st.read(3); + switch (block.identifier) { + case 'NETSCAPE': + parseNetscapeExt(block); + break; + default: + parseUnknownAppExt(block); + break; + } + }; + + var parseUnknownExt = function (block) { + block.data = readSubBlocks(); + handler.unknown && handler.unknown(block); + }; + + block.label = st.readByte(); + switch (block.label) { + case 0xF9: + block.extType = 'gce'; + parseGCExt(block); + break; + case 0xFE: + block.extType = 'com'; + parseComExt(block); + break; + case 0x01: + block.extType = 'pte'; + parsePTExt(block); + break; + case 0xFF: + block.extType = 'app'; + parseAppExt(block); + break; + default: + block.extType = 'unknown'; + parseUnknownExt(block); + break; + } + }; + + var parseImg = function (img) { + var deinterlace = function (pixels, width) { + // Of course this defeats the purpose of interlacing. And it's *probably* + // the least efficient way it's ever been implemented. But nevertheless... + var newPixels = new Array(pixels.length); + var rows = pixels.length / width; + var cpRow = function (toRow, fromRow) { + var fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width); + newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels)); + }; + + // See appendix E. + var offsets = [0, 4, 2, 1]; + var 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; + }; + + img.leftPos = st.readUnsigned(); + img.topPos = st.readUnsigned(); + img.width = st.readUnsigned(); + img.height = st.readUnsigned(); + + var bits = byteToBitArr(st.readByte()); + img.lctFlag = bits.shift(); + img.interlaced = bits.shift(); + img.sorted = bits.shift(); + img.reserved = bits.splice(0, 2); + img.lctSize = bitsToNum(bits.splice(0, 3)); + + if (img.lctFlag) { + img.lct = parseCT(1 << (img.lctSize + 1)); + } + + img.lzwMinCodeSize = st.readByte(); + + var lzwData = readSubBlocks(); + + img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData); + + if (img.interlaced) { // Move + img.pixels = deinterlace(img.pixels, img.width); + } + + handler.img && handler.img(img); + }; + + var parseBlock = function () { + var block = {}; + block.sentinel = st.readByte(); + + switch (String.fromCharCode(block.sentinel)) { // For ease of matching + case '!': + block.type = 'ext'; + parseExt(block); + break; + case ',': + block.type = 'img'; + parseImg(block); + break; + case ';': + block.type = 'eof'; + handler.eof && handler.eof(block); + break; + default: + return handler.onError(new Error('Unknown block: 0x' + block.sentinel.toString(16))); // TODO: Pad this with a 0. + } + + if (block.type !== 'eof') { + setTimeout(parseBlock, 0); + } + }; + + var parse = function () { + parseHeader(); + setTimeout(parseBlock, 0); + }; + + parse(); +}; + + +var SuperGif = function ( opts ) { + var options = { + //viewport position + vp_l: 0, + vp_t: 0, + vp_w: null, + vp_h: null, + //canvas sizes + c_w: null, + c_h: null + }; + for (var i in opts ) { options[i] = opts[i] } + if (options.vp_w && options.vp_h) options.is_vp = true; + + var stream; + var hdr; + + var loadError = null; + var loading = false; + + var transparency = null; + var delay = null; + var disposalMethod = null; + var disposalRestoreFromIdx = 0; + var lastDisposalMethod = null; + var frame = null; + var lastImg = null; + + var frames = []; + + var gif = options.gif; + + var clear = function () { + transparency = null; + delay = null; + lastDisposalMethod = disposalMethod; + disposalMethod = null; + frame = null; + }; + + // XXX: There's probably a better way to handle catching exceptions when + // callbacks are involved. + var doParse = function () { + try { + parseGIF(stream, handler); + } + catch (err) { + doLoadError('parse'); + } + }; + + var setSizes = function(w, h) { + tmpCanvas.width = w; + tmpCanvas.height = h; + tmpCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0); + } + + var doLoadError = function (originOfError) { + + + loadError = originOfError; + hdr = { + width: gif.width, + height: gif.height + }; // Fake header. + frames = []; + }; + + var doHdr = function (_hdr) { + hdr = _hdr; + setSizes(hdr.width, hdr.height) + }; + + var doGCE = function (gce) { + pushFrame(); + clear(); + transparency = gce.transparencyGiven ? gce.transparencyIndex : null; + delay = gce.delayTime; + disposalMethod = gce.disposalMethod; + // We don't have much to do with the rest of GCE. + }; + + var pushFrame = function () { + if (!frame) return; + frames.push({ + data: frame.getImageData(0, 0, hdr.width, hdr.height), + delay: delay + }); + }; + + // flag for drawing initial frame + var isInitFrameDrawn = false; + + var doImg = function (img) { + if (!frame) frame = tmpCanvas.getContext('2d'); + + var currIdx = frames.length; + + //ct = color table, gct = global color table + var ct = img.lctFlag ? img.lct : hdr.gct; // TODO: What if neither exists? + + /* + Disposal method indicates the way in which the graphic is to + be treated after being displayed. + + Values : 0 - No disposal specified. The decoder is + not required to take any action. + 1 - Do not dispose. The graphic is to be left + in place. + 2 - Restore to background color. The area used by the + graphic must be restored to the background color. + 3 - Restore to previous. The decoder is required to + restore the area overwritten by the graphic with + what was there prior to rendering the graphic. + + Importantly, "previous" means the frame state + after the last disposal of method 0, 1, or 2. + */ + if (currIdx > 0) { + if (lastDisposalMethod === 3) { + // Restore to previous + frame.putImageData(frames[disposalRestoreFromIdx].data, 0, 0); + } else { + disposalRestoreFromIdx = currIdx - 1; + } + + if (lastDisposalMethod === 2) { + // Restore to background color + // Browser implementations historically restore to transparent; we do the same. + // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079 + frame.clearRect(lastImg.leftPos, lastImg.topPos, lastImg.width, lastImg.height); + } + } + // else, Undefined/Do not dispose. + // frame contains final pixel data from the last frame; do nothing + + //Get existing pixels for img region after applying disposal method + var imgData = frame.getImageData(img.leftPos, img.topPos, img.width, img.height); + //apply color table colors + var cdd = imgData.data; + img.pixels.forEach(function (pixel, i) { + // imgData.data === [R,G,B,A,R,G,B,A,...] + if (pixel !== transparency) { + cdd[i * 4 + 0] = ct[pixel][0]; + cdd[i * 4 + 1] = ct[pixel][1]; + cdd[i * 4 + 2] = ct[pixel][2]; + cdd[i * 4 + 3] = 255; // Opaque. + } + }); + + frame.putImageData(imgData, img.leftPos, img.topPos); + + lastImg = img; + }; + + var doNothing = function () {}; + /** + * @param{boolean=} draw Whether to draw progress bar or not; this is not idempotent because of translucency. + * Note that this means that the text will be unsynchronized with the progress bar on non-frames; + * but those are typically so small (GCE etc.) that it doesn't really matter. TODO: Do this properly. + */ + var handler = { + hdr: doHdr, + gce: doGCE, + // I guess that's all for now. + // app: { + // // TODO: Is there much point in actually supporting iterations? + // NETSCAPE: withProgress(doNothing) + // }, + img: doImg, + eof: function (block) { + pushFrame(); + loading = false; + if (load_callback) { + load_callback(); + } + }, + + onError : function (error) { + if (error_callback) { + error_callback(); + } + } + }; + + var load_callback = false; + var error_callback = false; + var tmpCanvas = document.createElement('canvas'); + + return { + + load: function (callback) { + + load_callback = callback.success; + error_callback = callback.error; + + loading = true; + + if (gif.src.indexOf('data:') !== -1) { + var data = gif.src.substring(gif.src.indexOf(',')+1); + stream = new Stream(window.atob(data)); + doParse(); + } else { + var h = new XMLHttpRequest(); + h.overrideMimeType('text/plain; charset=x-user-defined'); + h.onload = function(e) { + stream = new Stream(h.responseText); + setTimeout(doParse, 0); + }; + h.onerror = function() { doLoadError('xhr'); }; + h.open('GET', gif.getAttribute('data-animated-src') || gif.src, true); + h.send(); + } + }, + + getFrames : function () { + return frames; + } + }; +}; + + +window.SuperGif = SuperGif; + +})( window ); \ No newline at end of file diff --git a/src/js/utils/CanvasUtils.js b/src/js/utils/CanvasUtils.js index 50f53ea2..a6eca20e 100644 --- a/src/js/utils/CanvasUtils.js +++ b/src/js/utils/CanvasUtils.js @@ -19,6 +19,13 @@ return canvas; }, + createFromImageData : function (imageData) { + var canvas = pskl.CanvasUtils.createCanvas(imageData.width, imageData.height); + var context = canvas.getContext('2d'); + context.putImageData(imageData, 0, 0); + return canvas; + }, + /** * By default, all scaling operations on a Canvas 2D Context are performed using antialiasing. * Resizing a 32x32 image to 320x320 will lead to a blurry output. diff --git a/src/piskel-script-list.js b/src/piskel-script-list.js index 9d0cd5c9..3a7b8809 100644 --- a/src/piskel-script-list.js +++ b/src/piskel-script-list.js @@ -7,6 +7,7 @@ // GIF Encoding libraries "js/lib/gif/gif.worker.js", "js/lib/gif/gif.js", + "js/lib/gif/libgif.js", // JSZip https://github.com/Stuk/jszip "js/lib/jszip/jszip.min.js",