├── .babelrc
├── .eslintrc.json
├── .gitignore
├── .npmignore
├── LICENSE.txt
├── README.md
├── docs
├── index.html
├── index.js
└── sample.png
├── package-lock.json
├── package.json
├── src
├── demo-page
│ ├── index.js
│ └── style.css
└── library
│ ├── crc32.js
│ ├── parser.js
│ ├── player.js
│ └── structs.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015",
4 | "stage-1"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": { "es6": true },
3 | "parser": "babel-eslint",
4 | "extends": [
5 | "eslint:recommended"
6 | ]
7 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .*
2 | !.npmignore
3 | !.gitignore
4 | !.babelrc
5 | !.eslintrc.json
6 | node_modules
7 | lib/
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .*
2 | !.npmignore
3 | !.gitignore
4 | !.babelrc
5 | node_modules
6 | src/
7 | docs/
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 David Mzareulyan & Copyright (c) 2018 Talkr, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # talkr-apng
2 |
3 | `talkr-apng` is forked from the excellent [apng-js](https://github.com/davidmz/apng-js) library which provides a generalized APNG parsing framework. `talkr-apng` adds functionality specific to playing APNG files in sync with audio or text-to-speech animations. Similar to talkr's [GIF parsing library](https://github.com/talkr-app/gif-talkr), `talkr-apng` has special code to play blink and eyebrow animations on files that were generated from the iOS app [talkr](https://talkrapp). Memory use and loading times are drastically reduced by `talkr-apng` as compared to `gif-talkr` because the png frames are compressed.
4 |
5 | ## Usage
6 | `npm install talkr-apng`
7 |
8 | ## Demo
9 | Open the docs/index.html file to load an APNG file and play an animation on it.
10 |
11 | ## API
12 |
13 | Please refer to ([apng-js](https://github.com/davidmz/apng-js)) documentation on parseAPNG or the APNG, Player and Frame objects.
14 |
15 | The play_for_duration function was added to the player, which will ping-pong for duration on regular APNG files and play animations that include eyebrow raises and blinks on talkr APNGs. It takes the number of milliseconds to play as an agument.
16 |
17 | ## Creating APNG files
18 |
19 | ### talkr APNGs
20 |
21 | The premium version of talkr ($2.99) can output APNG files for use with this library. Select the option in settings, then hit the Edit button on top of the image select screen to select and export your APNG file(s). Talkr APNGs include eyebrow and blink animations that will layer on top of the lip movements. Frame 0 will always be displayed as the bottom layer, while frames 1-22 are looped forwards and then backwards to animate the lips while frames 23-29 are layered on top to animage the eyelids and eyebrows.
22 |
23 | ### normal APNGs
24 |
25 | Normal APNGs do not need any special configuration. No eyebrow and blink animations will be created, and no layers are used. The frames are simply played forwards and then backwards.
26 |
27 | Any conversion library like [gif2apng](http://gif2apng.sourceforge.net/) will let you create compatible PNG files from standard GIF files. You can also assemble and dissassemble APNGs from/to their component images. There is some skill required to choose animations that will look good in sync with text-to-speech. Try to find input files that start with a closed mouth, then open the mouth in the first few frames. Any movements (especially in the early frames) will be repeated frequently, so try to find input with minimal head movement in the early frames.
28 |
29 | This library will attempt to detect APNG files created with talkr by looking at the image metadata in the creator field. If you want to create your own APNG files with eyebrow and blink animations, be sure to pass true as the second parameter to parseAPNG to load your 30-frame APNG as a talkr APNG without looking at the metadata.
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | apng-js demo page
6 |
7 |
8 |
9 |
10 |
11 |
talkr-apng demo
12 |
13 | See github.com/talkr-app/talkr-apng .
14 |
15 |
16 | Choose APNG file
17 | Force load as talkr file
18 |
Note: A sample talkr file is provided with this index.html file
19 |
20 |
21 |
22 |
25 |
26 |
27 |
28 |
Animation on canvas
29 |
30 |
31 | Play 2 seconds
32 |
33 |
34 |
35 |
36 |
37 |
APNG info:
38 |
39 |
40 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/docs/index.js:
--------------------------------------------------------------------------------
1 | !function(e){function t(r){if(n[r])return n[r].exports;var i=n[r]={exports:{},id:r,loaded:!1};return e[r].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e){var t=document.querySelector(".apng-result"),n=document.querySelector(".apng-error"),r=n.querySelector(".alert"),i=document.querySelector(".apng-info"),s=document.querySelector(".apng-frames"),u=document.querySelector(".apng-ani");t.classList.add("hidden"),n.classList.add("hidden"),a(i),a(s),a(u),a(r),l&&l.stop();var p=new FileReader;p.onload=function(){console.log(document.getElementById("force-talkr-cbx").checked),c=document.getElementById("force-talkr-cbx").checked;var e=(0,o.default)(p.result,c);return e instanceof Error?(r.appendChild(document.createTextNode(e.message)),void n.classList.remove("hidden")):(e.createImages().then(function(){i.appendChild(document.createTextNode(JSON.stringify(e,null," "))),e.frames.forEach(function(t){var n=s.appendChild(document.createElement("div"));n.appendChild(t.imageElement),n.style.width=e.width+"px",n.style.height=e.height+"px",t.imageElement.style.left=t.left+"px",t.imageElement.style.top=t.top+"px"});var t=document.createElement("canvas");t.width=e.width,t.height=e.height,u.appendChild(t),e.getPlayer(t.getContext("2d")).then(function(e){l=e,l.playbackRate=f,l.play_for_duration(h)})}),void t.classList.remove("hidden"))},p.readAsArrayBuffer(e)}function a(e){for(var t=void 0;null!==(t=e.firstChild);)e.removeChild(t)}var s=n(1),o=r(s);n(6);var u=document.createElement("input");u.type="file",u.accept="image/png",document.getElementById("choose-btn").addEventListener("click",function(){return u.click()}),u.addEventListener("change",function(){u.files.length>0&&i(u.files[0]),u.value=""});var l=null,h=2e3;document.getElementById("play-pause-btn").addEventListener("click",function(){l&&(l.paused?l.play_for_duration(h):l.stop())});var f=1,c=!1},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e){return e===d}function a(e){return e===m}function s(e){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=new Uint8Array(e);if(Array.prototype.some.call(_,function(e,t){return e!==n[t]}))return d;var r=!1;if(o(n,function(e){return!(r="acTL"===e)}),!r)return m;var i=[],a=[],s=null,l=null,f=0,c=new p.APNG;if(o(n,function(e,t,n,r){var o=new DataView(t.buffer);switch(e){case"IHDR":s=t.subarray(n+8,n+8+r),c.width=o.getUint32(n+8),c.height=o.getUint32(n+12);break;case"acTL":c.numPlays=o.getUint32(n+8+4);break;case"fcTL":l&&(c.frames.push(l),f++),l=new p.Frame,l.width=o.getUint32(n+8+4),l.height=o.getUint32(n+8+8),l.left=o.getUint32(n+8+12),l.top=o.getUint32(n+8+16);var d=o.getUint16(n+8+20),m=o.getUint16(n+8+22);0===m&&(m=100),l.delay=1e3*d/m,l.delay<=10&&(l.delay=100),c.playTime+=l.delay,l.disposeOp=o.getUint8(n+8+24),l.blendOp=o.getUint8(n+8+25),l.dataParts=[],0===f&&2===l.disposeOp&&(l.disposeOp=1);break;case"fdAT":l&&l.dataParts.push(t.subarray(n+8+4,n+8+r));break;case"IDAT":l&&l.dataParts.push(t.subarray(n+8,n+8+r));break;case"IEND":a.push(h(t,n,12+r));break;case"iTXt":var v=u(t,n+8,r),_=v.match(new RegExp("(.*) ","gms"));_&&_[0].includes("talkrapp.com")&&(c.isTalkrFile=!0);break;default:i.push(h(t,n,12+r))}}),l&&c.frames.push(l),0==c.frames.length)return m;var b=new Blob(i),x=new Blob(a);if(c.frames.forEach(function(e){var t=[];t.push(_),s.set(y(e.width),0),s.set(y(e.height),4),t.push(g("IHDR",s)),t.push(b),e.dataParts.forEach(function(e){return t.push(g("IDAT",e))}),t.push(x),e.imageData=new Blob(t,{type:"image/png"}),delete e.dataParts,t=null}),t){if(30!=c.frames.length)return v;c.isTalkrFile=!0}return c}function o(e,t){var n=new DataView(e.buffer),r=8,i=void 0,a=void 0,s=void 0;do a=n.getUint32(r),i=u(e,r+4,4),s=t(i,e,r,a),r+=12+a;while(s!==!1&&"IEND"!=i&&r>>24&255,e>>>16&255,e>>>8&255,255&e])}},function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:e.length-t,i=-1,a=t,s=t+r;a>>8^n[255&(i^e[a])];return i^-1};for(var n=new Uint32Array(256),r=0;r<256;r++){for(var i=r,a=0;a<8;a++)i=0!==(1&i)?3988292384^i>>>1:i>>>1;n[r]=i}},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0}),t.FrameAnim=t.Frame=t.APNG=void 0;var a=function(){function e(e,t){for(var n=0;n1&&void 0!==arguments[1]&&arguments[1];return this.createImages().then(function(){return new o.default(t,e,n)})}}]),e}(),t.Frame=function(){function e(){i(this,e),this.left=0,this.top=0,this.width=0,this.height=0,this.delay=0,this.disposeOp=0,this.blendOp=0,this.imageData=null,this.imageElement=null}return a(e,[{key:"createImage",value:function(){var e=this;return this.imageElement?Promise.resolve():new Promise(function(t,n){var r=URL.createObjectURL(e.imageData);e.imageElement=document.createElement("img"),e.imageElement.onload=function(){URL.revokeObjectURL(r),t()},e.imageElement.onerror=function(){URL.revokeObjectURL(r),e.imageElement=null,n(new Error("Image creation error"))},e.imageElement.src=r})}}]),e}(),t.FrameAnim=function(){function e(){i(this,e),this.frames=[],this.nextRenderTime=0,this.currentFrameIndex=0}return a(e,[{key:"fromArray",value:function(e,t){this.frames=[],e.forEach(function(e){frames.push(e,t)})}},{key:"fromFrames",value:function(e){this.frames=[];var t=e.map(function(e){return e.slice()});e.forEach(function(e){if(!Array.isArray(e))throw new Error("Error. Animation must be array of arrays. [[i,dur]..]")}),this.frames=t}},{key:"tick",value:function(e,t){for(;e>=this.nextRenderTime&&this.frames.length>0;){var n=this.frames.shift();this.currentFrameIndex=n[0],this.nextRenderTime=this.nextRenderTime+n[1]/t}return e>=this.nextRenderTime&&0==this.frames.length}}]),e}()},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e){if(Array.isArray(e)){for(var t=0,n=Array(e.length);t=this._apng.numPlays&&(this.emit("end"),this._ended=!0,this._paused=!0)),this._prevFrame&&1==this._prevFrame.disposeOp?this.context.clearRect(this._prevFrame.left,this._prevFrame.top,this._prevFrame.width,this._prevFrame.height):this._prevFrame&&2==this._prevFrame.disposeOp&&this.context.putImageData(this._prevFrameData,this._prevFrame.left,this._prevFrame.top);var e=this.currentFrame;this._prevFrame=e,this._prevFrameData=null,2==e.disposeOp&&(this._prevFrameData=this.context.getImageData(e.left,e.top,e.width,e.height)),0==e.blendOp&&this.context.clearRect(e.left,e.top,e.width,e.height),this.context.drawImage(e.imageElement,e.left,e.top)}},{key:"play",value:function(){var e=this;this.emit("play"),this._ended&&this.stop(),this._paused=!1;var t=performance.now()+this.currentFrame.delay/this.playbackRate,n=function n(r){if(!e._ended&&!e._paused){if(r>=t){for(;r-t>=e._apng.playTime/e.playbackRate;)t+=e._apng.playTime/e.playbackRate,e._numPlays++;do e.renderNextFrame(),t+=e.currentFrame.delay/e.playbackRate;while(!e._ended&&r>t)}requestAnimationFrame(n)}};requestAnimationFrame(n)}},{key:"pause",value:function(){this._paused||(this.emit("pause"),this._paused=!0)}},{key:"createFullFrames",value:function(){this._fullFrameData=[],this._currentFrameNumber=-1,this.context.clearRect(0,0,this._apng.width,this._apng.height);for(var e=0;e=0&&this.context.putImageData(this._fullFrameData[e],0,0)}},{key:"addAnimToPlay",value:function(e){if(e&&0!=e.length){var t=new f.FrameAnim;t.fromFrames(e),this._anims.push(t)}}},{key:"play_anims",value:function(){var e=this;!this._ended,this._ended=!1,this._paused=!1,this._anims.forEach(function(t){if(t.frames.length>0){var n=t.frames[0];t.nextRenderTime=performance.now()+n[1]/e.playbackRate,t.currentFrameIndex=n[0]}});var t=function(){e.context.drawImage(e._apng.frames[0].imageElement,e._apng.frames[0].left,e._apng.frames[0].top),e._apng.isTalkrFile&&e.context.drawImage(e._apng.frames[1].imageElement,e._apng.frames[1].left,e._apng.frames[1].top)},n=function n(r){if(e._ended||e._paused||0==e._anims.length)return void t();var a=!1;e._anims.forEach(function(e){r>=e.nextRenderTime&&(a=!0)});var s=[];if(a){for(var o=e._anims.length-1;o>=0;--o){var u=e._anims[o].tick(r,e.playbackRate);u?e._anims.splice(o,1):s.push(e._anims[o].currentFrameIndex)}s=[].concat(i(new Set(s))).sort(function(e,t){return e-t}),e._apng.isTalkrFile?(e.context.clearRect(0,0,e._apng.width,e._apng.height),t(),s.forEach(function(t){t>e._startIndex&&e.context.drawImage(e._apng.frames[t].imageElement,e._apng.frames[t].left,e._apng.frames[t].top)})):e.renderFullFrame(s[s.length-1])}return 0==e._anims.length?(e.emit("end"),e._ended=!0,void(e._paused=!0)):void requestAnimationFrame(n)};requestAnimationFrame(n)}},{key:"create_blink_anim",value:function(e){var t=Math.random();return t<.3?[]:t<.6?[[23,50],[24,50],[25,50],[24,50],[23,50]]:[[1,t*e],[23,50],[24,50],[25,50],[24,50],[23,50]]}},{key:"create_brow_anim",value:function(e){var t=Math.random();return t<.3?[]:t<.6?[[26,50],[27,50],[28,50],[29,100],[28,80],[27,80],[26,80]]:e<1e3?[[26,50],[27,50],[28,50],[29,.9*e],[28,80],[27,80],[26,80]]:void 0}},{key:"play_for_duration",value:function(e){for(var t=this._defaultFrameLength/this.playbackRate,n=[],r=this._startIndex,i=!1,a=t,s=e-t;a<=s;){r!==this._last_lipsync_frame_index&&r!==this._startIndex||(i=r===this._last_lipsync_frame_index),!i&&r>this._startIndex&&a+r*t>s&&(i=!0);var o=i?-1:1;r+=o,n.push([r,t]),a+=t}r!=this._startIndex&&n.push([this._startIndex,t]),this._anims=[],this.addAnimToPlay(n),this._apng.isTalkrFile&&(this.addAnimToPlay(this.create_blink_anim()),this.addAnimToPlay(this.create_brow_anim())),this.play_anims()}},{key:"stop",value:function(){this.emit("stop"),this._numPlays=0,this._ended=!1,this._paused=!0,this._currentFrameNumber=-1,this.context.clearRect(0,0,this._apng.width,this._apng.height),this.renderNextFrame()}},{key:"currentFrameNumber",get:function(){return this._currentFrameNumber}},{key:"currentFrame",get:function(){return this._apng.frames[this._currentFrameNumber]}},{key:"paused",get:function(){return this._paused}},{key:"ended",get:function(){return this._ended}}]),t}(h.default);t.default=c},function(e,t){function n(){this._events=this._events||{},this._maxListeners=this._maxListeners||void 0}function r(e){return"function"==typeof e}function i(e){return"number"==typeof e}function a(e){return"object"==typeof e&&null!==e}function s(e){return void 0===e}e.exports=n,n.EventEmitter=n,n.prototype._events=void 0,n.prototype._maxListeners=void 0,n.defaultMaxListeners=10,n.prototype.setMaxListeners=function(e){if(!i(e)||e<0||isNaN(e))throw TypeError("n must be a positive number");return this._maxListeners=e,this},n.prototype.emit=function(e){var t,n,i,o,u,l;if(this._events||(this._events={}),"error"===e&&(!this._events.error||a(this._events.error)&&!this._events.error.length)){if(t=arguments[1],t instanceof Error)throw t;var h=new Error('Uncaught, unspecified "error" event. ('+t+")");throw h.context=t,h}if(n=this._events[e],s(n))return!1;if(r(n))switch(arguments.length){case 1:n.call(this);break;case 2:n.call(this,arguments[1]);break;case 3:n.call(this,arguments[1],arguments[2]);break;default:o=Array.prototype.slice.call(arguments,1),n.apply(this,o)}else if(a(n))for(o=Array.prototype.slice.call(arguments,1),l=n.slice(),i=l.length,u=0;u0&&this._events[e].length>i&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace())),this},n.prototype.on=n.prototype.addListener,n.prototype.once=function(e,t){function n(){this.removeListener(e,n),i||(i=!0,t.apply(this,arguments))}if(!r(t))throw TypeError("listener must be a function");var i=!1;return n.listener=t,this.on(e,n),this},n.prototype.removeListener=function(e,t){var n,i,s,o;if(!r(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(n=this._events[e],s=n.length,i=-1,n===t||r(n.listener)&&n.listener===t)delete this._events[e],this._events.removeListener&&this.emit("removeListener",e,t);else if(a(n)){for(o=s;o-- >0;)if(n[o]===t||n[o].listener&&n[o].listener===t){i=o;break}if(i<0)return this;1===n.length?(n.length=0,delete this._events[e]):n.splice(i,1),this._events.removeListener&&this.emit("removeListener",e,t)}return this},n.prototype.removeAllListeners=function(e){var t,n;if(!this._events)return this;if(!this._events.removeListener)return 0===arguments.length?this._events={}:this._events[e]&&delete this._events[e],this;if(0===arguments.length){for(t in this._events)"removeListener"!==t&&this.removeAllListeners(t);return this.removeAllListeners("removeListener"),this._events={},this}if(n=this._events[e],r(n))this.removeListener(e,n);else if(n)for(;n.length;)this.removeListener(e,n[n.length-1]);return delete this._events[e],this},n.prototype.listeners=function(e){var t;return t=this._events&&this._events[e]?r(this._events[e])?[this._events[e]]:this._events[e].slice():[]},n.prototype.listenerCount=function(e){if(this._events){var t=this._events[e];if(r(t))return 1;if(t)return t.length}return 0},n.listenerCount=function(e,t){return e.listenerCount(t)}},function(e,t,n){var r=n(7);"string"==typeof r&&(r=[[e.id,r,""]]),n(9)(r,{}),r.locals&&(e.exports=r.locals)},function(e,t,n){t=e.exports=n(8)(),t.push([e.id,".apng-frames,.apng-info{max-height:600px;overflow:auto}.apng-frames>div{float:left;margin:1px 1px 8px 8px;box-shadow:0 0 0 1px;position:relative;background:linear-gradient(45deg,#fff 25%,transparent 26%,transparent 75%,#fff 76%),linear-gradient(-45deg,#fff 25%,transparent 26%,transparent 75%,#fff 76%);background-color:#eee;background-size:20px 20px}.apng-frames>div>img{position:absolute;box-shadow:0 0 0 1px rgba(255,0,0,.75)}#playback-rate{width:12em;display:inline-block}",""])},function(e,t){e.exports=function(){var e=[];return e.toString=function(){for(var e=[],t=0;t=0&&y.splice(t,1)}function o(e){var t=document.createElement("style");return t.type="text/css",a(e,t),t}function u(e){var t=document.createElement("link");return t.rel="stylesheet",a(e,t),t}function l(e,t){var n,r,i;if(t.singleton){var a=g++;n=_||(_=o(t)),r=h.bind(null,n,a,!1),i=h.bind(null,n,a,!0)}else e.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(n=u(t),r=c.bind(null,n),i=function(){s(n),n.href&&URL.revokeObjectURL(n.href)}):(n=o(t),r=f.bind(null,n),i=function(){s(n)});return r(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;r(e=t)}else i()}}function h(e,t,n,r){var i=n?"":r.css;if(e.styleSheet)e.styleSheet.cssText=b(t,i);else{var a=document.createTextNode(i),s=e.childNodes;s[t]&&e.removeChild(s[t]),s.length?e.insertBefore(a,s[t]):e.appendChild(a)}}function f(e,t){var n=t.css,r=t.media;if(r&&e.setAttribute("media",r),e.styleSheet)e.styleSheet.cssText=n;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(n))}}function c(e,t){var n=t.css,r=t.sourceMap;r&&(n+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(r))))+" */");var i=new Blob([n],{type:"text/css"}),a=e.href;e.href=URL.createObjectURL(i),a&&URL.revokeObjectURL(a)}var p={},d=function(e){var t;return function(){return"undefined"==typeof t&&(t=e.apply(this,arguments)),t}},m=d(function(){return/msie [6-9]\b/.test(self.navigator.userAgent.toLowerCase())}),v=d(function(){return document.head||document.getElementsByTagName("head")[0]}),_=null,g=0,y=[];e.exports=function(e,t){t=t||{},"undefined"==typeof t.singleton&&(t.singleton=m()),"undefined"==typeof t.insertAt&&(t.insertAt="bottom");var n=i(e);return r(n,t),function(e){for(var a=[],s=0;s fileInput.click())
9 |
10 | fileInput.addEventListener('change', () => {
11 | if (fileInput.files.length > 0) {
12 | processFile(fileInput.files[0])
13 | }
14 | fileInput.value = ''
15 | })
16 |
17 | let player = null
18 | let defaultDuration = 2000
19 |
20 | document.getElementById('play-pause-btn').addEventListener('click', () => {
21 | if (player) {
22 | if (player.paused) {
23 | player.play_for_duration(defaultDuration)
24 | } else {
25 | player.stop()
26 | }
27 | }
28 | })
29 |
30 |
31 | let playbackRate = 1.0
32 | let bForceLoadAsTalkr = false
33 | function processFile (file) {
34 | const resultBlock = document.querySelector('.apng-result')
35 | const errorBlock = document.querySelector('.apng-error')
36 | const errDiv = errorBlock.querySelector('.alert')
37 | const infoDiv = document.querySelector('.apng-info')
38 | const framesDiv = document.querySelector('.apng-frames')
39 | const canvasDiv = document.querySelector('.apng-ani')
40 |
41 | resultBlock.classList.add('hidden')
42 | errorBlock.classList.add('hidden')
43 | emptyEl(infoDiv)
44 | emptyEl(framesDiv)
45 | emptyEl(canvasDiv)
46 | emptyEl(errDiv)
47 | if (player) {
48 | player.stop()
49 | }
50 |
51 | const reader = new FileReader()
52 | reader.onload = () => {
53 | console.log(document.getElementById('force-talkr-cbx').checked)
54 | bForceLoadAsTalkr = document.getElementById('force-talkr-cbx').checked
55 | const apng = parseAPNG(reader.result, bForceLoadAsTalkr)
56 | if (apng instanceof Error) {
57 | errDiv.appendChild(document.createTextNode(apng.message))
58 | errorBlock.classList.remove('hidden')
59 | return
60 | }
61 | apng.createImages().then(() => {
62 | infoDiv.appendChild(document.createTextNode(JSON.stringify(apng, null, ' ')))
63 | apng.frames.forEach(f => {
64 | const div = framesDiv.appendChild(document.createElement('div'))
65 | div.appendChild(f.imageElement)
66 | div.style.width = `${apng.width}px`
67 | div.style.height = `${apng.height}px`
68 | f.imageElement.style.left = `${f.left}px`
69 | f.imageElement.style.top = `${f.top}px`
70 | })
71 |
72 | const canvas = document.createElement('canvas')
73 | canvas.width = apng.width
74 | canvas.height = apng.height
75 | canvasDiv.appendChild(canvas)
76 |
77 | apng.getPlayer(canvas.getContext('2d')).then(p => {
78 | player = p
79 | player.playbackRate = playbackRate
80 | player.play_for_duration(defaultDuration)
81 | })
82 | })
83 | resultBlock.classList.remove('hidden')
84 | }
85 | reader.readAsArrayBuffer(file)
86 | }
87 |
88 | function emptyEl (el) {
89 | let c
90 | while ((c = el.firstChild) !== null) {
91 | el.removeChild(c)
92 | }
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/src/demo-page/style.css:
--------------------------------------------------------------------------------
1 | .apng-info,
2 | .apng-frames {
3 | max-height: 600px;
4 | overflow: auto;
5 | }
6 |
7 | .apng-frames > div {
8 | float: left;
9 | margin: 1px 1px 8px 8px;
10 | box-shadow: 0 0 0 1px;
11 | position: relative;
12 | background: linear-gradient(45deg, #fff 25%, transparent 26%, transparent 75%, #fff 76%),
13 | linear-gradient(-45deg, #fff 25%, transparent 26%, transparent 75%, #fff 76%);
14 | background-color: #eee;
15 | background-size: 20px 20px;
16 | }
17 |
18 | .apng-frames > div > img {
19 | position: absolute;
20 | box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.75);
21 | }
22 |
23 | #playback-rate {
24 | width: 12em;
25 | display: inline-block;
26 | }
--------------------------------------------------------------------------------
/src/library/crc32.js:
--------------------------------------------------------------------------------
1 | const table = new Uint32Array(256)
2 |
3 | for (let i = 0; i < 256; i++) {
4 | let c = i
5 | for (let k = 0; k < 8; k++) {
6 | c = ((c & 1) !== 0) ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
7 | }
8 | table[i] = c
9 | }
10 |
11 | /**
12 | *
13 | * @param {Uint8Array} bytes
14 | * @param {number} start
15 | * @param {number} length
16 | * @return {number}
17 | */
18 | export default function (bytes, start = 0, length = bytes.length - start) {
19 | let crc = -1
20 | for (let i = start, l = start + length; i < l; i++) {
21 | crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xFF]
22 | }
23 | return crc ^ (-1)
24 | };
25 |
--------------------------------------------------------------------------------
/src/library/parser.js:
--------------------------------------------------------------------------------
1 | import crc32 from './crc32';
2 | import {APNG, Frame} from './structs';
3 |
4 | const errNotPNG = new Error('Not a PNG');
5 | const errNotAPNG = new Error('Not an animated PNG');
6 | const errNotTalkrAPNG = new Error('Not a talkr PNG with 30 frames');
7 |
8 | export function isNotPNG(err) { return err === errNotPNG; }
9 | export function isNotAPNG(err) { return err === errNotAPNG; }
10 |
11 | // '\x89PNG\x0d\x0a\x1a\x0a'
12 | const PNGSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13 |
14 | /**
15 | * Parse APNG data
16 | * @param {ArrayBuffer} buffer
17 | * @return {APNG|Error}
18 | */
19 | export default function parseAPNG(buffer, forceTalkrFile = false) {
20 | const bytes = new Uint8Array(buffer);
21 |
22 | if (Array.prototype.some.call(PNGSignature, (b, i) => b !== bytes[i])) {
23 | return errNotPNG;
24 | }
25 |
26 | // fast animation test
27 | let isAnimated = false;
28 | eachChunk(bytes, type => !(isAnimated = (type === 'acTL')));
29 | if (!isAnimated) {
30 | return errNotAPNG;
31 | }
32 |
33 | const
34 | preDataParts = [],
35 | postDataParts = [];
36 | let
37 | headerDataBytes = null,
38 | frame = null,
39 | frameNumber = 0,
40 | apng = new APNG();
41 |
42 | eachChunk(bytes, (type, bytes, off, length) => {
43 | const dv = new DataView(bytes.buffer);
44 | switch (type) {
45 | case 'IHDR':
46 | headerDataBytes = bytes.subarray(off + 8, off + 8 + length);
47 | apng.width = dv.getUint32(off + 8);
48 | apng.height = dv.getUint32(off + 12);
49 | break;
50 | case 'acTL':
51 | apng.numPlays = dv.getUint32(off + 8 + 4);
52 | break;
53 | case 'fcTL':
54 | if (frame) {
55 | apng.frames.push(frame);
56 | frameNumber++;
57 | }
58 | frame = new Frame();
59 | frame.width = dv.getUint32(off + 8 + 4);
60 | frame.height = dv.getUint32(off + 8 + 8);
61 | frame.left = dv.getUint32(off + 8 + 12);
62 | frame.top = dv.getUint32(off + 8 + 16);
63 | var delayN = dv.getUint16(off + 8 + 20);
64 | var delayD = dv.getUint16(off + 8 + 22);
65 | if (delayD === 0) {
66 | delayD = 100;
67 | }
68 | frame.delay = 1000 * delayN / delayD;
69 | // https://bugzilla.mozilla.org/show_bug.cgi?id=125137
70 | // https://bugzilla.mozilla.org/show_bug.cgi?id=139677
71 | // https://bugzilla.mozilla.org/show_bug.cgi?id=207059
72 | if (frame.delay <= 10) {
73 | frame.delay = 100;
74 | }
75 | apng.playTime += frame.delay;
76 | frame.disposeOp = dv.getUint8(off + 8 + 24);
77 | frame.blendOp = dv.getUint8(off + 8 + 25);
78 | frame.dataParts = [];
79 | if (frameNumber === 0 && frame.disposeOp === 2) {
80 | frame.disposeOp = 1;
81 | }
82 | break;
83 | case 'fdAT':
84 | if (frame) {
85 | frame.dataParts.push(bytes.subarray(off + 8 + 4, off + 8 + length));
86 | }
87 | break;
88 | case 'IDAT':
89 | if (frame) {
90 | frame.dataParts.push(bytes.subarray(off + 8, off + 8 + length));
91 | }
92 | break;
93 | case 'IEND':
94 | postDataParts.push(subBuffer(bytes, off, 12 + length));
95 | break;
96 | case 'iTXt':
97 | // We look for the 'talkr-apng' author in the PNG author data.
98 | var iTXt = readString(bytes, off+8, length)
99 | var creator = iTXt.match(new RegExp("(.*)<\/dc:creator>", 'gms'))
100 | if(creator && creator[0].includes('talkrapp.com')) {
101 | apng.isTalkrFile = true
102 | }
103 | break;
104 | default:
105 | preDataParts.push(subBuffer(bytes, off, 12 + length));
106 | }
107 | });
108 |
109 | if (frame) {
110 | apng.frames.push(frame);
111 | }
112 |
113 | if (apng.frames.length == 0) {
114 | return errNotAPNG;
115 | }
116 |
117 | const preBlob = new Blob(preDataParts),
118 | postBlob = new Blob(postDataParts);
119 |
120 | apng.frames.forEach(frame => {
121 | var bb = [];
122 | bb.push(PNGSignature);
123 | headerDataBytes.set(makeDWordArray(frame.width), 0);
124 | headerDataBytes.set(makeDWordArray(frame.height), 4);
125 | bb.push(makeChunkBytes('IHDR', headerDataBytes));
126 | bb.push(preBlob);
127 | frame.dataParts.forEach(p => bb.push(makeChunkBytes('IDAT', p)));
128 | bb.push(postBlob);
129 | frame.imageData = new Blob(bb, {'type': 'image/png'});
130 | delete frame.dataParts;
131 | bb = null;
132 | });
133 |
134 | if (forceTalkrFile) {
135 | if (apng.frames.length == 30) {
136 | apng.isTalkrFile = true
137 | } else {
138 | return errNotTalkrAPNG;
139 | }
140 | }
141 |
142 | return apng;
143 | }
144 |
145 | /**
146 | * @param {Uint8Array} bytes
147 | * @param {function(string, Uint8Array, int, int): boolean} callback
148 | */
149 | function eachChunk(bytes, callback) {
150 | const dv = new DataView(bytes.buffer);
151 | let off = 8, type, length, res;
152 | do {
153 | length = dv.getUint32(off);
154 | type = readString(bytes, off + 4, 4);
155 | res = callback(type, bytes, off, length);
156 | off += 12 + length;
157 | } while (res !== false && type != 'IEND' && off < bytes.length);
158 | }
159 |
160 | /**
161 | *
162 | * @param {Uint8Array} bytes
163 | * @param {number} off
164 | * @param {number} length
165 | * @return {string}
166 | */
167 | function readString(bytes, off, length) {
168 | const chars = Array.prototype.slice.call(bytes.subarray(off, off + length));
169 | return String.fromCharCode.apply(String, chars);
170 | }
171 |
172 | /**
173 | *
174 | * @param {string} x
175 | * @return {Uint8Array}
176 | */
177 | function makeStringArray(x) {
178 | const res = new Uint8Array(x.length);
179 | for (let i = 0; i < x.length; i++) {
180 | res[i] = x.charCodeAt(i);
181 | }
182 | return res;
183 | }
184 |
185 |
186 | /**
187 | * @param {Uint8Array} bytes
188 | * @param {int} start
189 | * @param {int} length
190 | * @return {Uint8Array}
191 | */
192 | function subBuffer(bytes, start, length) {
193 | const a = new Uint8Array(length);
194 | a.set(bytes.subarray(start, start + length));
195 | return a;
196 | }
197 |
198 | /**
199 | * @param {string} type
200 | * @param {Uint8Array} dataBytes
201 | * @return {Uint8Array}
202 | */
203 | var makeChunkBytes = function (type, dataBytes) {
204 | const crcLen = type.length + dataBytes.length;
205 | const bytes = new Uint8Array(crcLen + 8);
206 | const dv = new DataView(bytes.buffer);
207 |
208 | dv.setUint32(0, dataBytes.length);
209 | bytes.set(makeStringArray(type), 4);
210 | bytes.set(dataBytes, 8);
211 | var crc = crc32(bytes, 4, crcLen);
212 | dv.setUint32(crcLen + 4, crc);
213 | return bytes;
214 | };
215 |
216 | var makeDWordArray = function (x) {
217 | return new Uint8Array([(x >>> 24) & 0xff, (x >>> 16) & 0xff, (x >>> 8) & 0xff, x & 0xff]);
218 | };
219 |
--------------------------------------------------------------------------------
/src/library/player.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events';
2 | import {APNG, Frame, FrameAnim} from './structs';
3 | export default class extends EventEmitter {
4 | /** @type {CanvasRenderingContext2D} */
5 | context;
6 | /** @type {number} */
7 | playbackRate = 1.0;
8 |
9 | /** @type {APNG} */
10 | _apng;
11 | /** @type {Frame} */
12 | _prevFrame;
13 | /** @type {ImageData} */
14 | _prevFrameData;
15 | /** @type {number} */
16 | _currentFrameNumber = 0;
17 |
18 | /** @type {boolean} */
19 | _ended = false;
20 | /** @type {boolean} */
21 | _paused = true;
22 | /** @type {number} */
23 | _numPlays = 0;
24 | /** @type {number} */
25 | _defaultFrameLength = 40;
26 |
27 | // talkr files will attempt to layer blink and eyebrow anims
28 | // on top of the lip-sync animation. Non-talkr files will
29 | // simply ping-pong all frames.
30 | /** @type {boolean} */
31 | _is_talkr_file = false;
32 |
33 | /** @type {number} */
34 | _last_lipsync_frame_index = 0;
35 |
36 | /** @type {number} */
37 | _startIndex = 0;
38 |
39 | /** @type FrameAnim[] */
40 | _anims = [];
41 |
42 |
43 |
44 | /**
45 | * @param {APNG} apng
46 | * @param {CanvasRenderingContext2D} context
47 | * @param {boolean} autoPlay
48 | */
49 | constructor(apng, context, autoPlay) {
50 | super();
51 | this._apng = apng;
52 | this.context = context;
53 |
54 | if (!this._apng.isTalkrFile || this._apng.frames.length < 30 ) {
55 | // In order to play this non-talkr GIF file forwards and backwards, without
56 | // considering frame disposal options, we need to store the "full" frames
57 | // in memory so they can be displayed without knowing which frame came before.
58 | this.createFullFrames();
59 | this._last_lipsync_frame_index = this._apng.frames.length -1
60 |
61 | } else {
62 | this._last_lipsync_frame_index = 22
63 | this._startIndex = 1
64 | }
65 |
66 | this.stop();
67 |
68 | if (autoPlay) {
69 | this.play();
70 | }
71 | }
72 |
73 | /**
74 | *
75 | * @return {number}
76 | */
77 | get currentFrameNumber() {
78 | return this._currentFrameNumber;
79 | }
80 |
81 | /**
82 | *
83 | * @return {Frame}
84 | */
85 | get currentFrame() {
86 | return this._apng.frames[this._currentFrameNumber];
87 | }
88 |
89 | renderNextFrame() {
90 | this._currentFrameNumber = (this._currentFrameNumber + 1) % this._apng.frames.length;
91 | if (this._currentFrameNumber === this._apng.frames.length - 1) {
92 | this._numPlays++;
93 | if (this._apng.numPlays !== 0 && this._numPlays >= this._apng.numPlays) {
94 | this.emit('end');
95 | this._ended = true;
96 | this._paused = true;
97 | }
98 | }
99 |
100 | if (this._prevFrame && this._prevFrame.disposeOp == 1) {
101 | this.context.clearRect(this._prevFrame.left, this._prevFrame.top, this._prevFrame.width, this._prevFrame.height);
102 | } else if (this._prevFrame && this._prevFrame.disposeOp == 2) {
103 | this.context.putImageData(this._prevFrameData, this._prevFrame.left, this._prevFrame.top);
104 | }
105 |
106 | const frame = this.currentFrame;
107 | this._prevFrame = frame;
108 | this._prevFrameData = null;
109 | if (frame.disposeOp == 2) {
110 | this._prevFrameData = this.context.getImageData(frame.left, frame.top, frame.width, frame.height);
111 | }
112 | if (frame.blendOp == 0) {
113 | this.context.clearRect(frame.left, frame.top, frame.width, frame.height);
114 | }
115 |
116 | this.context.drawImage(frame.imageElement, frame.left, frame.top);
117 | }
118 |
119 | // playback
120 |
121 | get paused() { return this._paused; }
122 |
123 | get ended() { return this._ended; }
124 |
125 | play() {
126 | this.emit('play');
127 |
128 | if (this._ended) {
129 | this.stop();
130 | }
131 | this._paused = false;
132 |
133 | let nextRenderTime = performance.now() + this.currentFrame.delay / this.playbackRate;
134 | const tick = now => {
135 | if (this._ended || this._paused) {
136 | return;
137 | }
138 | if (now >= nextRenderTime) {
139 | while (now - nextRenderTime >= this._apng.playTime / this.playbackRate) {
140 | nextRenderTime += this._apng.playTime / this.playbackRate;
141 | this._numPlays++;
142 | }
143 | do {
144 | this.renderNextFrame();
145 | nextRenderTime += this.currentFrame.delay / this.playbackRate;
146 | } while (!this._ended && now > nextRenderTime);
147 | }
148 | requestAnimationFrame(tick);
149 | };
150 | requestAnimationFrame(tick);
151 | }
152 |
153 | pause() {
154 | if (!this._paused) {
155 | this.emit('pause');
156 | this._paused = true;
157 | }
158 | }
159 |
160 | // Non-talkr APNG files will need this to play in reverse
161 | createFullFrames() {
162 |
163 | // full frame data contains the full data for the frame
164 | // as opposed to the data that needs to be applied to the previous frame
165 | // (which was added to the frame before that, etc.)
166 | this._fullFrameData = []
167 | this._currentFrameNumber = -1;
168 |
169 | this.context.clearRect(0, 0, this._apng.width, this._apng.height);
170 | for (var i = 0; i < this._apng.frames.length; ++i) {
171 | this.renderNextFrame();
172 | var frame = this._apng.frames[i]
173 | this._fullFrameData.push(this.context.getImageData(0, 0, this._apng.width, this._apng.height));
174 | }
175 | }
176 |
177 | /**
178 | * @param {number} index
179 | */
180 | renderFullFrame(index) {
181 | index = index % this._apng.frames.length;
182 | if( index>=0){
183 | this.context.putImageData(this._fullFrameData[index], 0, 0);
184 | }
185 | }
186 |
187 | addAnimToPlay(anim){
188 | if(!anim || anim.length == 0) {
189 | return;
190 | }
191 | let newFrameAnim = new FrameAnim();
192 | newFrameAnim.fromFrames(anim)
193 | this._anims.push(newFrameAnim)
194 | }
195 |
196 | play_anims() {
197 | if( !this._ended ){
198 | // Interrupting current animation. Could be snaps was we swap out
199 | }
200 | this._ended = false;
201 | this._paused = false;
202 |
203 | this._anims.forEach( (anim) => {
204 | if(anim.frames.length > 0){
205 | let animframe = anim.frames[0];
206 | anim.nextRenderTime = performance.now() + animframe[1] / this.playbackRate;
207 | anim.currentFrameIndex = animframe[0];
208 | }
209 | });
210 | const drawF0 = () => {
211 | this.context.drawImage(this._apng.frames[0].imageElement, this._apng.frames[0].left, this._apng.frames[0].top);
212 | if (this._apng.isTalkrFile) {
213 | // The base image for talkr files includes frame 1 drawn over frame 0. This allows frame 0 to represent
214 | // the original image, with an overlay on frame 1 to add things like teeth or other effects.
215 | this.context.drawImage(this._apng.frames[1].imageElement, this._apng.frames[1].left, this._apng.frames[1].top);
216 | }
217 | }
218 | const tick = now => {
219 | // @todo, support resuming from a paused animation. Create resume function?
220 | if (this._ended || this._paused || this._anims.length == 0) {
221 | drawF0()
222 | return;
223 | }
224 |
225 | // Don't change the canvas if nothing changed.
226 | let refreshed = false;
227 | this._anims.forEach(function(anim){
228 | if(now >= anim.nextRenderTime ){
229 | refreshed = true;
230 | }
231 | });
232 | let frames_to_draw = [];
233 | if(refreshed){
234 | for(let i = this._anims.length -1; i >= 0; --i){
235 | let bDelete = this._anims[i].tick(now, this.playbackRate)
236 |
237 | if(bDelete){
238 | this._anims.splice(i,1);
239 | } else {
240 | frames_to_draw.push(this._anims[i].currentFrameIndex)
241 | }
242 | }
243 | // sort and remove duplicates.
244 | frames_to_draw = [...new Set(frames_to_draw)].sort( (a,b) => { return a-b;});
245 |
246 | if (!this._apng.isTalkrFile) {
247 | // Non _talkr files just loop their full frames.
248 | this.renderFullFrame(frames_to_draw[frames_to_draw.length -1])
249 | } else {
250 | this.context.clearRect(0, 0, this._apng.width, this._apng.height);
251 | drawF0()
252 | frames_to_draw.forEach(f => {
253 | if(f>this._startIndex) {
254 | this.context.drawImage(this._apng.frames[f].imageElement, this._apng.frames[f].left, this._apng.frames[f].top);
255 | }
256 | });
257 | }
258 | }
259 | if(this._anims.length == 0 ){
260 | this.emit('end');
261 | this._ended = true;
262 | this._paused = true;
263 | return;
264 | }
265 | requestAnimationFrame(tick);
266 | }
267 | requestAnimationFrame(tick);
268 | }
269 | /**
270 | * @param {number} duration
271 | * @return {FrameAnim}
272 | */
273 | create_blink_anim(duration) {
274 | let rand = Math.random();
275 | if(rand < 0.3){
276 | return [];
277 | }
278 | if(rand < 0.6){
279 | return [[23, 50],[24, 50],[25, 50],[24, 50],[23, 50]];
280 | }
281 | return [[1,rand*duration],[23, 50],[24, 50],[25, 50],[24, 50],[23, 50]];
282 | }
283 | /**
284 | * @param {number} duration
285 | * @return {FrameAnim}
286 | */
287 | create_brow_anim(duration) {
288 | let rand = Math.random();
289 | if(rand < 0.3){
290 | return [];
291 | }
292 | if(rand < 0.6){
293 | return [[26, 50],[27, 50],[28, 50],[29, 100],[28, 80],[27, 80],[26, 80]];
294 | }
295 | // Hold eyebrows up for the entire short utterance.
296 | if(duration < 1000 ){
297 | return [[26, 50],[27, 50],[28, 50],[29, duration*0.9],[28, 80],[27, 80],[26, 80]];
298 | }
299 | }
300 | /**
301 | * @param {number} duration
302 | */
303 | play_for_duration(dur) {
304 | let normalizedFrameTime = this._defaultFrameLength / this.playbackRate;
305 | let frames = [];
306 |
307 | let i = this._startIndex
308 | let reverse = false;
309 | let t = normalizedFrameTime;
310 | let lastNonZeroFrameTime = dur - normalizedFrameTime;
311 |
312 | while( t <= lastNonZeroFrameTime ){
313 | if (i === this._last_lipsync_frame_index || i === this._startIndex ) {
314 | reverse = i === this._last_lipsync_frame_index;
315 | }
316 | // Make sure we reverse in time to reach frame 1 before lastNonZeroFrameTime.
317 | if (!reverse && i > this._startIndex && t + i * normalizedFrameTime > lastNonZeroFrameTime) {
318 | reverse = true
319 | }
320 | let increment = reverse ? -1 : 1
321 | i += increment;
322 | frames.push([i, normalizedFrameTime]);
323 | t += normalizedFrameTime;
324 | }
325 | if( i != this._startIndex){
326 | frames.push([this._startIndex, normalizedFrameTime]);
327 | }
328 | this._anims = []
329 |
330 | this.addAnimToPlay(frames);
331 |
332 | if( this._apng.isTalkrFile ) {
333 | this.addAnimToPlay(this.create_blink_anim());
334 | this.addAnimToPlay(this.create_brow_anim());
335 | }
336 |
337 | this.play_anims();
338 | }
339 |
340 | stop() {
341 | this.emit('stop');
342 | this._numPlays = 0;
343 | this._ended = false;
344 | this._paused = true;
345 | // render first frame
346 | this._currentFrameNumber = -1;
347 | this.context.clearRect(0, 0, this._apng.width, this._apng.height);
348 | this.renderNextFrame();
349 | }
350 | }
--------------------------------------------------------------------------------
/src/library/structs.js:
--------------------------------------------------------------------------------
1 | import Player from './player';
2 |
3 | /**
4 | * @property {number} currFrameNumber
5 | * @property {Frame} currFrame
6 | * @property {boolean} paused
7 | * @property {boolean} ended
8 | * @property {boolean} isTalkrFile
9 | */
10 | export class APNG {
11 | /** @type {number} */
12 | width = 0;
13 | /** @type {number} */
14 | height = 0;
15 | /** @type {number} */
16 | numPlays = 0;
17 | /** @type {number} */
18 | playTime = 0;
19 | /** @type {Frame[]} */
20 | frames = [];
21 |
22 | /**
23 | *
24 | * @return {Promise.<*>}
25 | */
26 | createImages() {
27 | return Promise.all(this.frames.map(f => f.createImage()));
28 | }
29 |
30 | /**
31 | *
32 | * @param {CanvasRenderingContext2D} context
33 | * @param {boolean} autoPlay
34 | * @return {Promise.}
35 | */
36 | getPlayer(context, autoPlay = false) {
37 | return this.createImages().then(() => new Player(this, context, autoPlay));
38 | }
39 | }
40 |
41 | export class Frame {
42 | /** @type {number} */
43 | left = 0;
44 | /** @type {number} */
45 | top = 0;
46 | /** @type {number} */
47 | width = 0;
48 | /** @type {number} */
49 | height = 0;
50 | /** @type {number} */
51 | delay = 0;
52 | /** @type {number} */
53 | disposeOp = 0;
54 | /** @type {number} */
55 | blendOp = 0;
56 | /** @type {Blob} */
57 | imageData = null;
58 | /** @type {HTMLImageElement} */
59 | imageElement = null;
60 |
61 | createImage() {
62 | if (this.imageElement) {
63 | return Promise.resolve();
64 | }
65 | return new Promise((resolve, reject) => {
66 | const url = URL.createObjectURL(this.imageData);
67 | this.imageElement = document.createElement('img');
68 | this.imageElement.onload = () => {
69 | URL.revokeObjectURL(url);
70 | resolve();
71 | };
72 | this.imageElement.onerror = () => {
73 | URL.revokeObjectURL(url);
74 | this.imageElement = null;
75 | reject(new Error("Image creation error"));
76 | };
77 | this.imageElement.src = url;
78 | });
79 | }
80 | }
81 |
82 |
83 | /**
84 | * @property {[number[]]} frames
85 | * @property {number} nextRenderTime
86 | * @property {number} currentFrameIndex
87 | */
88 | export class FrameAnim {
89 | /** @type {[number[]]} */
90 | frames = [];
91 |
92 | nextRenderTime = 0;
93 | currentFrameIndex = 0;
94 |
95 | fromArray(array, defaultVal){
96 | this.frames = [];
97 | array.forEach( f => {frames.push(f, defaultVal)});
98 | }
99 |
100 | fromFrames(frames){
101 | this.frames = [];
102 |
103 | // Make a copy
104 | var framesCopy = frames.map(function(arr) {
105 | return arr.slice();
106 | });
107 | frames.forEach(function(frame){
108 | if (!Array.isArray(frame) ) {
109 | throw new Error('Error. Animation must be array of arrays. [[i,dur]..]');
110 | }
111 | });
112 | this.frames = framesCopy;
113 | }
114 |
115 | // Ticks the animation to the current time.
116 | // Returns true if the animation has finished.
117 | tick(now, playbackRate){
118 | while ( now >= this.nextRenderTime && this.frames.length > 0){
119 | let framedata = this.frames.shift();
120 | this.currentFrameIndex = framedata[0];
121 | this.nextRenderTime = this.nextRenderTime + framedata[1] / playbackRate;
122 | }
123 | return now >= this.nextRenderTime && this.frames.length == 0;
124 | }
125 | }
126 |
127 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path')
2 | const webpack = require('webpack')
3 | module.exports = [
4 | {
5 | entry: path.join(__dirname, 'src', 'library', 'parser.js'),
6 | output: {
7 | path: path.join(__dirname, 'lib'),
8 | filename: 'index.js',
9 | library: 'talkr-apng',
10 | libraryTarget: 'umd'
11 | },
12 | module: {
13 | loaders: [
14 | {
15 | test: /\.js$/,
16 | loader: 'babel-loader',
17 | exclude: /[\\\/](node_modules|lib)[\\\/]/
18 | }
19 | ]
20 | }
21 | },
22 | {
23 | entry: path.join(__dirname, 'src', 'demo-page', 'index.js'),
24 | output: {
25 | path: path.join(__dirname, 'docs'),
26 | filename: 'index.js'
27 | },
28 | module: {
29 | loaders: [
30 | {
31 | test: /\.js$/,
32 | loader: 'babel-loader',
33 | exclude: /[\\\/](node_modules|lib)[\\\/]/
34 | },
35 | {
36 | test: /\.css$/,
37 | exclude: /[\\\/](node_modules|lib)[\\\/]/,
38 | loader: 'style-loader!css-loader'
39 | }
40 | ]
41 | },
42 | plugins: [
43 | new webpack.optimize.UglifyJsPlugin({
44 |
45 | })
46 | ]
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------