Skip to content

Commit

Permalink
feat: streaming style coding
Browse files Browse the repository at this point in the history
  • Loading branch information
qq15725 committed Jan 10, 2024
1 parent a52e546 commit 44e5777
Show file tree
Hide file tree
Showing 32 changed files with 1,003 additions and 973 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

## Features

- ⚡️ Encode(or create Encoder), Decode
- ⚡️ Encode, Decode

- 🎨 Set max colors(2 - 255)

Expand Down
61 changes: 25 additions & 36 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,65 +68,54 @@ <h2>Demo</h2>

<script type="module">
import workerUrl from '../dist/worker?url'
import { createEncoder, decode, decodeFrames } from '../src'
import { decode, decodeFrames, encode } from '../src'

const img = document.querySelector('.example')
const pre = document.querySelector('pre')

let frames = null
const props = {
maxColors: 255,
delay: 100,
}
const encoder = createEncoder({
debug: true,
workerUrl,
workerNumber: 2,
width: 1,
height: 1,
maxColors: props.maxColors,
})
frames: [],
}

async function init() {
const source = await fetch(img.src).then(res => res.arrayBuffer())
const gif = decode(source)
frames = await decodeFrames(source)
encoder.width = gif.width
encoder.height = gif.height
// eslint-disable-next-line no-console
console.log(gif)
props.frames = await decodeFrames(source)
props.width = gif.width
props.height = gif.height
render()
}

async function render() {
if (pre.textContent.startsWith('Encoding')) return
pre.textContent = 'Encoding...'

encoder.maxColors = props.maxColors

// eslint-disable-next-line no-console
console.time('encode')
await Promise.all(
frames.map(frame => {
return encoder.encode({
imageData: frame.imageData,
const start = Date.now()
const output = await encode({
debug: true,
workerUrl,
width: props.width,
height: props.height,
maxColors: props.maxColors,
frames: props.frames.map(frame => {
return {
data: frame.data.slice(),
delay: props.delay,
})
}
}),
)
// eslint-disable-next-line no-console
console.timeEnd('encode')

const start = Date.now()
const output = await encoder.flush()
})
const time = Date.now() - start

const blob = new Blob([output], { type: 'image/gif' })
const rawSrc = img.src
img.src = URL.createObjectURL(blob)
if (rawSrc.startsWith('blob:')) URL.revokeObjectURL(rawSrc)

pre.textContent = `Rendered ${ frames.length } frame(s) at ${ encoder.maxColors } max colors in ${ time }ms
pre.textContent = `Rendered ${ props.frames.length } frame(s) at ${ props.maxColors } max colors in ${ time }ms
Output size: ${ ~~(output.byteLength / 1024) }kb`
}

Expand All @@ -135,25 +124,25 @@ <h2>Demo</h2>
const { unit = '', name } = input.dataset
input.addEventListener('input', async () => {
if (input.type === 'file') {
frames.forEach(frame => {
props.frames.forEach(frame => {
if (typeof frame.imageData === 'string' && frame.imageData.startsWith('blob:')) {
URL.revokeObjectURL(frame.imageData)
}
})
frames = []
props.frames = []
for (let len = input.files.length, i = 0; i < len; i++) {
const file = input.files[i]
if (file.type === 'image/gif') {
const buffer = await file.arrayBuffer()
const gif = decode(buffer)
// eslint-disable-next-line no-console
console.log(gif)
encoder.width = gif.width
encoder.height = gif.height
frames.push(...(await decodeFrames(buffer, { workerUrl })))
props.width = gif.width
props.height = gif.height
props.frames.push(...(await decodeFrames(buffer, { workerUrl })))
} else {
const url = URL.createObjectURL(file)
frames.push({ imageData: url })
props.frames.push({ imageData: url })
}
}
render()
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@
"vitest": "^0.31.1"
},
"dependencies": {
"modern-palette": "^0.2.4"
"modern-palette": "^2.0.0"
}
}
12 changes: 8 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

226 changes: 226 additions & 0 deletions src/Encoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { Palette } from 'modern-palette'
import { Logger } from './Logger'
import { CropIndexedFrame, EncodeGif, EncodeIndexdFrame, FrameToIndexedFrame } from './transformers'
import { loadImage, resovleSource } from './utils'
import { createWorker } from './create-worker'
import type { Frame, Gif } from './types'

export interface EncoderOptions extends Partial<Omit<Gif, 'width' | 'height' | 'frames'>> {
/**
* GIF width
*/
width: number

/**
* GIF height
*/
height: number

/**
* The frames that needs to be encoded
*/
frames?: Array<Partial<Frame> & { data: Uint8ClampedArray }>

/**
* Enable debug mode to view the execution time log.
*/
debug?: boolean

/**
* Worker script url
*/
workerUrl?: string

/**
* Max colors count 2-255
*/
maxColors?: number

/**
* Palette premultipliedAlpha
*/
premultipliedAlpha?: boolean

/**
* Palette tint
*/
tint?: Array<number>
}

export interface EncoderConfig extends EncoderOptions {
debug: boolean
maxColors: number
premultipliedAlpha: boolean
tint: Array<number>
colorTableSize: number
backgroundColorIndex: number
}

export class Encoder {
config: EncoderConfig
logger: Logger
frames: Array<Partial<Frame> & { data: Uint8ClampedArray }> = []
palette: Palette
protected _encodeId = 0
protected _worker?: ReturnType<typeof createWorker>

constructor(options: EncoderOptions) {
this.config = this._resolveOptions(options)
this.logger = new Logger(this.config.debug)
this.palette = new Palette({
maxColors: this.config.maxColors,
premultipliedAlpha: this.config.premultipliedAlpha,
tint: this.config.tint,
})
if (this.config.workerUrl) {
this._worker = createWorker({ workerUrl: this.config.workerUrl })
this._worker.call('encoder:init', options)
} else {
this.config.frames?.forEach(frame => {
this.encode(frame)
})
}
}

protected _resolveOptions(options: EncoderOptions): EncoderConfig {
(['width', 'height'] as const).forEach(key => {
if (
typeof options[key] !== 'undefined'
&& Math.floor(options[key]!) !== options[key]
) {
console.warn(`${ key } cannot be a floating point number`)
options[key] = Math.floor(options[key]!)
}
})

const {
colorTableSize = 256,
backgroundColorIndex = colorTableSize - 1,
maxColors = colorTableSize - 1,
debug = false,
premultipliedAlpha = false,
tint = [0xFF, 0xFF, 0xFF],
} = options

return {
...options,
colorTableSize,
backgroundColorIndex,
maxColors,
debug,
premultipliedAlpha,
tint,
}
}

async encode(frame: Partial<Frame> & { data: CanvasImageSource | BufferSource | string }): Promise<void> {
if (this._worker) {
let transfer: any | undefined
if (ArrayBuffer.isView(frame.data)) {
transfer = [frame.data.buffer]
} else if (frame.data instanceof ArrayBuffer) {
transfer = [frame.data]
}
return this._worker.call('encoder:encode', frame, transfer)
}

const _encodeId = this._encodeId
this._encodeId++

const {
width: frameWidth = this.config.width,
height: frameHeight = this.config.height,
} = frame

let { data } = frame

try {
this.logger.time(`palette:sample-${ _encodeId }`)
data = typeof data === 'string'
? await loadImage(data)
: data

data = resovleSource(data, 'uint8ClampedArray', {
width: frameWidth,
height: frameHeight,
})

this.frames.push({
...frame,
width: frameWidth,
height: frameHeight,
data: data as any,
})

this.palette.addSample(data)
} finally {
this.logger.timeEnd(`palette:sample-${ _encodeId }`)
}
}

async flush(format: 'blob'): Promise<Blob>
async flush(format?: 'arrayBuffer'): Promise<ArrayBuffer>
async flush(format?: string): Promise<any> {
if (this._worker) {
return this._worker.call('encoder:flush', format)
}

this.logger.time('palette:generate')
const colors = await this.palette.generate()
this.logger.timeEnd('palette:generate')

const colorTable = colors.map(color => [color.rgb.r, color.rgb.g, color.rgb.b])
while (colorTable.length < this.config.colorTableSize) {
colorTable.push([0, 0, 0])
}

this.logger.debug('palette:maxColors', this.config.maxColors)
// eslint-disable-next-line no-console
this.config.debug && console.debug(
colors.map(() => '%c ').join(''),
...colors.map(color => `margin: 1px; background: ${ color.hex }`),
)

this.logger.time('encode')
const output = await new Promise<Uint8Array>(resolve => {
new ReadableStream({
start: controller => {
this.frames.forEach(frame => {
controller.enqueue(frame)
})
controller.close()
},
})
.pipeThrough(
new FrameToIndexedFrame(
this.config.backgroundColorIndex,
this.config.premultipliedAlpha,
this.config.tint,
colors,
),
)
.pipeThrough(new CropIndexedFrame(this.config.backgroundColorIndex))
.pipeThrough(new EncodeIndexdFrame())
.pipeThrough(new EncodeGif({
...this.config,
colorTable,
}))
.pipeTo(new WritableStream({
write: chunk => resolve(chunk),
}))
})
this.logger.timeEnd('encode')

// reset
this.frames = []
this._encodeId = 0

switch (format) {
case 'blob':
return new Blob([output.buffer], { type: 'image/gif' })
case 'arrayBuffer':
default:
return output.buffer
}
}
}
Loading

1 comment on commit 44e5777

@vercel
Copy link

@vercel vercel bot commented on 44e5777 Jan 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

modern-gif – ./

modern-gif.vercel.app
modern-gif-git-main-qq15725.vercel.app
modern-gif-qq15725.vercel.app

Please sign in to comment.