209 lines
5.8 KiB
CoffeeScript
209 lines
5.8 KiB
CoffeeScript
{EventEmitter} = require 'events'
|
|
browser = require './browser.coffee'
|
|
|
|
class GIF extends EventEmitter
|
|
|
|
defaults =
|
|
workerScript: 'gif.worker.js'
|
|
workers: 2
|
|
repeat: 0 # repeat forever, -1 = repeat once
|
|
background: '#fff'
|
|
quality: 10 # pixel sample interval, lower is better
|
|
width: null # size derermined from first frame if possible
|
|
height: null
|
|
transparent: null
|
|
debug: false
|
|
dither: false # see GIFEncoder.js for dithering options
|
|
|
|
frameDefaults =
|
|
delay: 500 # ms
|
|
copy: false
|
|
|
|
constructor: (options) ->
|
|
@running = false
|
|
|
|
@options = {}
|
|
@frames = []
|
|
|
|
@freeWorkers = []
|
|
@activeWorkers = []
|
|
|
|
@setOptions options
|
|
for key, value of defaults
|
|
@options[key] ?= value
|
|
|
|
setOption: (key, value) ->
|
|
@options[key] = value
|
|
if @_canvas? and key in ['width', 'height']
|
|
@_canvas[key] = value
|
|
|
|
setOptions: (options) ->
|
|
@setOption key, value for own key, value of options
|
|
|
|
addFrame: (image, options={}) ->
|
|
frame = {}
|
|
frame.transparent = @options.transparent
|
|
for key of frameDefaults
|
|
frame[key] = options[key] or frameDefaults[key]
|
|
|
|
# use the images width and height for options unless already set
|
|
@setOption 'width', image.width unless @options.width?
|
|
@setOption 'height', image.height unless @options.height?
|
|
|
|
if ImageData? and image instanceof ImageData
|
|
frame.data = image.data
|
|
else if (CanvasRenderingContext2D? and image instanceof CanvasRenderingContext2D) or (WebGLRenderingContext? and image instanceof WebGLRenderingContext)
|
|
if options.copy
|
|
frame.data = @getContextData image
|
|
else
|
|
frame.context = image
|
|
else if image.childNodes?
|
|
if options.copy
|
|
frame.data = @getImageData image
|
|
else
|
|
frame.image = image
|
|
else
|
|
throw new Error 'Invalid image'
|
|
|
|
@frames.push frame
|
|
|
|
render: ->
|
|
throw new Error 'Already running' if @running
|
|
|
|
if not @options.width? or not @options.height?
|
|
throw new Error 'Width and height must be set prior to rendering'
|
|
|
|
@running = true
|
|
@nextFrame = 0
|
|
@finishedFrames = 0
|
|
|
|
@imageParts = (null for i in [0...@frames.length])
|
|
numWorkers = @spawnWorkers()
|
|
# we need to wait for the palette
|
|
if @options.globalPalette == true
|
|
@renderNextFrame()
|
|
else
|
|
@renderNextFrame() for i in [0...numWorkers]
|
|
|
|
@emit 'start'
|
|
@emit 'progress', 0
|
|
|
|
abort: ->
|
|
loop
|
|
worker = @activeWorkers.shift()
|
|
break unless worker?
|
|
@log 'killing active worker'
|
|
worker.terminate()
|
|
@running = false
|
|
@emit 'abort'
|
|
|
|
# private
|
|
|
|
spawnWorkers: ->
|
|
numWorkers = Math.min(@options.workers, @frames.length)
|
|
[@freeWorkers.length...numWorkers].forEach (i) =>
|
|
@log "spawning worker #{ i }"
|
|
worker = new Worker @options.workerScript
|
|
worker.onmessage = (event) =>
|
|
@activeWorkers.splice @activeWorkers.indexOf(worker), 1
|
|
@freeWorkers.push worker
|
|
@frameFinished event.data
|
|
@freeWorkers.push worker
|
|
return numWorkers
|
|
|
|
frameFinished: (frame) ->
|
|
@log "frame #{ frame.index } finished - #{ @activeWorkers.length } active"
|
|
@finishedFrames++
|
|
@emit 'progress', @finishedFrames / @frames.length
|
|
@imageParts[frame.index] = frame
|
|
# remember calculated palette, spawn the rest of the workers
|
|
if @options.globalPalette == true
|
|
@options.globalPalette = frame.globalPalette
|
|
@log 'global palette analyzed'
|
|
@renderNextFrame() for i in [1...@freeWorkers.length] if @frames.length > 2
|
|
if null in @imageParts
|
|
@renderNextFrame()
|
|
else
|
|
@finishRendering()
|
|
|
|
finishRendering: ->
|
|
len = 0
|
|
for frame in @imageParts
|
|
len += (frame.data.length - 1) * frame.pageSize + frame.cursor
|
|
len += frame.pageSize - frame.cursor
|
|
@log "rendering finished - filesize #{ Math.round(len / 1000) }kb"
|
|
data = new Uint8Array len
|
|
offset = 0
|
|
for frame in @imageParts
|
|
for page, i in frame.data
|
|
data.set page, offset
|
|
if i is frame.data.length - 1
|
|
offset += frame.cursor
|
|
else
|
|
offset += frame.pageSize
|
|
|
|
image = new Blob [data],
|
|
type: 'image/gif'
|
|
|
|
@emit 'finished', image, data
|
|
|
|
renderNextFrame: ->
|
|
throw new Error 'No free workers' if @freeWorkers.length is 0
|
|
return if @nextFrame >= @frames.length # no new frame to render
|
|
|
|
frame = @frames[@nextFrame++]
|
|
worker = @freeWorkers.shift()
|
|
task = @getTask frame
|
|
|
|
@log "starting frame #{ task.index + 1 } of #{ @frames.length }"
|
|
@activeWorkers.push worker
|
|
worker.postMessage task#, [task.data.buffer]
|
|
|
|
getContextData: (ctx) ->
|
|
return ctx.getImageData(0, 0, @options.width, @options.height).data
|
|
|
|
getImageData: (image) ->
|
|
if not @_canvas?
|
|
@_canvas = document.createElement 'canvas'
|
|
@_canvas.width = @options.width
|
|
@_canvas.height = @options.height
|
|
|
|
ctx = @_canvas.getContext '2d'
|
|
ctx.setFill = @options.background
|
|
ctx.fillRect 0, 0, @options.width, @options.height
|
|
ctx.drawImage image, 0, 0
|
|
|
|
return @getContextData ctx
|
|
|
|
getTask: (frame) ->
|
|
index = @frames.indexOf frame
|
|
task =
|
|
index: index
|
|
last: index is (@frames.length - 1)
|
|
delay: frame.delay
|
|
transparent: frame.transparent
|
|
width: @options.width
|
|
height: @options.height
|
|
quality: @options.quality
|
|
dither: @options.dither
|
|
globalPalette: @options.globalPalette
|
|
repeat: @options.repeat
|
|
canTransfer: (browser.name is 'chrome')
|
|
|
|
if frame.data?
|
|
task.data = frame.data
|
|
else if frame.context?
|
|
task.data = @getContextData frame.context
|
|
else if frame.image?
|
|
task.data = @getImageData frame.image
|
|
else
|
|
throw new Error 'Invalid frame'
|
|
|
|
return task
|
|
|
|
log: (args...) ->
|
|
return unless @options.debug
|
|
console.log args...
|
|
|
|
|
|
module.exports = GIF
|