From 0d6ea12736be77be18e179aca4d12843ed798f07 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Fri, 9 Aug 2024 13:46:08 -0500 Subject: [PATCH] fix: Gif parsing did not work with certain formats (#3173) Closes #3172 This PR fixes GIF parsing for certain gif formats. Leverages the documentation provided by GIFLib https://giflib.sourceforge.net/whatsinagif/index.html ## Changes: - Tweak the image generation algorithm to be more efficient - Support Local Color Table in image generation - Support Disposal Method to allow frame to frame persistence --- CHANGELOG.md | 4 + sandbox/tests/gif/animatedGif.ts | 35 ++- sandbox/tests/gif/loading-screen.gif | Bin 0 -> 16267 bytes sandbox/tests/gif/stoplight.gif | Bin 0 -> 218 bytes src/engine/Resources/Gif.ts | 269 +++++++++++------- src/spec/GifSpec.ts | 39 ++- src/spec/images/GifSpec/frame1.png | Bin 771 -> 784 bytes src/spec/images/GifSpec/frame2.png | Bin 2068 -> 2080 bytes src/spec/images/GifSpec/loading-frame-20.png | Bin 0 -> 477 bytes src/spec/images/GifSpec/loading-screen.gif | Bin 0 -> 16267 bytes src/spec/images/GifSpec/stoplight-frame-1.png | Bin 0 -> 558 bytes src/spec/images/GifSpec/stoplight-frame-2.png | Bin 0 -> 654 bytes src/spec/images/GifSpec/stoplight-frame-3.png | Bin 0 -> 648 bytes src/spec/images/GifSpec/stoplight.gif | Bin 0 -> 218 bytes 14 files changed, 237 insertions(+), 110 deletions(-) create mode 100644 sandbox/tests/gif/loading-screen.gif create mode 100644 sandbox/tests/gif/stoplight.gif create mode 100644 src/spec/images/GifSpec/loading-frame-20.png create mode 100644 src/spec/images/GifSpec/loading-screen.gif create mode 100644 src/spec/images/GifSpec/stoplight-frame-1.png create mode 100644 src/spec/images/GifSpec/stoplight-frame-2.png create mode 100644 src/spec/images/GifSpec/stoplight-frame-3.png create mode 100644 src/spec/images/GifSpec/stoplight.gif diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c4e10d4..808264db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Breaking Changes +- `ex.Gif` transparent color constructor arg is removed in favor of the built in Gif file mechanism - Remove core-js dependency, it is no longer necessary in modern browsers. Technically a breaking change for older browsers - `ex.Particle` and `ex.ParticleEmitter` now have an API that looks like modern Excalibur APIs * `particleSprite` is renamed to `graphic` @@ -47,6 +48,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Deprecated + - `Vector.size` is deprecated, use `Vector.magnitude` instead - `ScreenShader` v_texcoord is deprecated, use v_uv. This is changed to match the materials shader API - `actor.getGlobalPos()` - use `actor.globalPos` instead @@ -55,6 +57,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- `ex.Gif` can now handle default embedded GIF frame timings - New `ex.Screen.worldToPagePixelRatio` API that will return the ratio between excalibur pixels and the HTML pixels. * Additionally excalibur will now decorate the document root with this same value as a CSS variable `--ex-pixel-ratio` * Useful for scaling HTML UIs to match your game @@ -90,6 +93,7 @@ are doing mtv adjustments during precollision. ### Fixed +- Fixed issue where `ex.Gif` was not parsing certain binary formats correctly - Fixed issue where the boot `ex.Loader` was removing pixelRatio override - Fixed `ex.RasterOptions`, it now extends `ex.GraphicsOptions` which is the underlying truth - Fixed issue where rayCast `filter` would not be called in hit order diff --git a/sandbox/tests/gif/animatedGif.ts b/sandbox/tests/gif/animatedGif.ts index b355e01bc..0fa16bfe4 100644 --- a/sandbox/tests/gif/animatedGif.ts +++ b/sandbox/tests/gif/animatedGif.ts @@ -10,10 +10,35 @@ var game = new ex.Engine({ game.currentScene.camera.zoom = 2; -var gif: ex.Gif = new ex.Gif('./sword.gif', ex.Color.Black); -var loader = new ex.Loader([gif]); +var gif: ex.Gif = new ex.Gif('./loading-screen.gif'); +var gif2: ex.Gif = new ex.Gif('./sword.gif'); +var gif3: ex.Gif = new ex.Gif('./stoplight.gif'); +var loader = new ex.Loader([gif, gif2, gif3]); game.start(loader).then(() => { - var actor = new ex.Actor({ x: game.currentScene.camera.x, y: game.currentScene.camera.y, width: gif.width, height: gif.height }); - actor.graphics.add(gif.toAnimation(500)); - game.add(actor); + var stoplight = new ex.Actor({ + x: game.currentScene.camera.x + 120, + y: game.currentScene.camera.y, + width: gif3.width, + height: gif3.height + }); + stoplight.graphics.add(gif3.toAnimation()); + game.add(stoplight); + + var sword = new ex.Actor({ + x: game.currentScene.camera.x - 120, + y: game.currentScene.camera.y, + width: gif2.width, + height: gif2.height + }); + sword.graphics.add(gif2.toAnimation()); + game.add(sword); + + var loading = new ex.Actor({ + x: game.currentScene.camera.x, + y: game.currentScene.camera.y, + width: gif2.width, + height: gif2.height + }); + loading.graphics.add(gif.toAnimation()); + game.add(loading); }); diff --git a/sandbox/tests/gif/loading-screen.gif b/sandbox/tests/gif/loading-screen.gif new file mode 100644 index 0000000000000000000000000000000000000000..e38abea94f74d2b429936014ea209995acb95131 GIT binary patch literal 16267 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 WeVG~D>}M~V`ut_dd5fpm`1~I(>lNMr literal 0 HcmV?d00001 diff --git a/sandbox/tests/gif/stoplight.gif b/sandbox/tests/gif/stoplight.gif new file mode 100644 index 0000000000000000000000000000000000000000..3d1c19c4a6f3f316ddadaf19771e3655eeddc2a0 GIT binary patch literal 218 zcmZ?wbhEHb+1tCfh;gk{Lk&@8WQa67~pE8XTZz|6jc1l z!jb}{bwCP0+8LM)Ja*l=r@YTYaqa7*rK*=FfA4vsK1=%gLh;#h3qDUX5x#SNt$9`c z_Z_ST8ec5Swx%$F4KV^4!UQyeok0L-h-8KOn$x_eJRff6+I{x>k8|pBNtyE&OE$Sf RRD-Q#Vqk)+X2+%48UW5cMh5@@ literal 0 HcmV?d00001 diff --git a/src/engine/Resources/Gif.ts b/src/engine/Resources/Gif.ts index 0e8ffe44a..115e02fb6 100644 --- a/src/engine/Resources/Gif.ts +++ b/src/engine/Resources/Gif.ts @@ -1,11 +1,9 @@ import { Resource } from './Resource'; import { Sprite } from '../Graphics/Sprite'; -import { Color } from '../Color'; import { SpriteSheet } from '../Graphics/SpriteSheet'; import { Animation } from '../Graphics/Animation'; import { Loadable } from '../Interfaces/Index'; import { ImageSource } from '../Graphics/ImageSource'; -import { range } from '../Math/util'; /** * The {@apilink Texture} object allows games built in Excalibur to load image resources. * {@apilink Texture} is an {@apilink Loadable} which means it can be passed to a {@apilink Loader} @@ -25,25 +23,22 @@ export class Gif implements Loadable { public height: number = 0; private _stream?: Stream; - private _gif?: ParseGif; - private _textures: ImageSource[] = []; + private _gif?: GifParser; + private _images: ImageSource[] = []; private _animation?: Animation; - private _transparentColor: Color; public data: ImageSource[] = []; + private _sprites: Sprite[] = []; /** * @param path Path to the image resource - * @param color Optionally set the color to treat as transparent the gif, by default {@apilink Color.Magenta} * @param bustCache Optionally load texture with cache busting */ constructor( public path: string, - public color: Color = Color.Magenta, bustCache = false ) { this._resource = new Resource(path, 'arraybuffer', bustCache); - this._transparentColor = color; } /** @@ -64,12 +59,15 @@ export class Gif implements Loadable { public async load(): Promise { const arraybuffer = await this._resource.load(); this._stream = new Stream(arraybuffer); - this._gif = new ParseGif(this._stream, this._transparentColor); + this._gif = new GifParser(this._stream); const images = this._gif.images.map((i) => new ImageSource(i.src, false)); - // Load all textures await Promise.all(images.map((t) => t.load())); - return (this.data = this._textures = images); + this.data = this._images = images; + this._sprites = this._images.map((image) => { + return image.toSprite(); + }); + return this.data; } public isLoaded() { @@ -81,17 +79,14 @@ export class Gif implements Loadable { * @param id */ public toSprite(id: number = 0): Sprite | null { - const sprite = this._textures[id]?.toSprite(); - return sprite ?? null; + return this._sprites[id] ?? null; } /** * Return the gif as a spritesheet */ public toSpriteSheet(): SpriteSheet | null { - const sprites: Sprite[] = this._textures.map((image) => { - return image.toSprite(); - }); + const sprites: Sprite[] = this._sprites; if (sprites.length) { return new SpriteSheet({ sprites }); } @@ -101,12 +96,22 @@ export class Gif implements Loadable { /** * Transform the GIF into an animation with duration per frame + * @param durationPerFrameMs Optionally override duration per frame */ - public toAnimation(durationPerFrameMs: number): Animation | null { - const spriteSheet = this.toSpriteSheet(); - const length = spriteSheet?.sprites.length; - if (length) { - this._animation = Animation.fromSpriteSheet(spriteSheet, range(0, length), durationPerFrameMs); + public toAnimation(durationPerFrameMs?: number): Animation | null { + const images = this._gif?.images; + if (images?.length) { + const frames = images.map((image, index) => { + return { + graphic: this._sprites[index], + duration: this._gif?.frames[index].delayMs || undefined + }; + }); + this._animation = new Animation({ + frames, + frameDuration: durationPerFrameMs + }); + return this._animation; } return null; @@ -125,12 +130,15 @@ export interface GifFrame { width: number; height: number; lctFlag: boolean; + lctBytes: [number, number, number][]; interlaced: boolean; sorted: boolean; reserved: boolean[]; lctSize: number; lzwMinCodeSize: number; pixels: number[]; + delayTime: number; + delayMs: number; } const bitsToNum = (ba: any) => { @@ -139,16 +147,16 @@ const bitsToNum = (ba: any) => { }, 0); }; -const byteToBitArr = (bite: any) => { +const byteToBitArr = (bite: number) => { const a = []; for (let i = 7; i >= 0; i--) { a.push(!!(bite & (1 << i))); } - return a; + return a as [boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean]; }; export class Stream { - data: any = null; + data: Uint8Array; len: number = 0; position: number = 0; @@ -261,41 +269,72 @@ const lzwDecode = function (minCodeSize: number, data: any) { return output; }; -// The actual parsing; returns an object with properties. -export class ParseGif { +interface GifBlock { + sentinel: number; + type: string; +} + +interface GifHeader { + sig: string; + ver: string; + width: number; + height: number; + colorResolution: number; + globalColorTableSize: number; + gctFlag: boolean; + sortedFlag: boolean; + globalColorTable: [number, number, number][]; + backgroundColorIndex: number; + pixelAspectRatio: number; // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 +} + +interface GCExtBlock extends GifBlock { + type: 'ext'; + label: number; + extType: string; + reserved: boolean[]; + disposalMethod: number; + userInputFlag: boolean; + transparentColorFlag: boolean; + delayTime: number; + transparentColorIndex: number; + terminator: number; +} + +/** + * GifParser for binary format + * + * Roughly based on the documentation https://giflib.sourceforge.net/whatsinagif/index.html + */ +export class GifParser { private _st: Stream; private _handler: any = {}; - private _transparentColor: Color; public frames: GifFrame[] = []; public images: HTMLImageElement[] = []; - public globalColorTable: any[] = []; + private _currentFrameCanvas: HTMLCanvasElement; + private _currentFrameContext: CanvasRenderingContext2D; + public globalColorTableBytes: [number, number, number][] = []; public checkBytes: number[] = []; + private _gce?: GCExtBlock; + private _hdr?: GifHeader; - constructor(stream: Stream, color: Color = Color.Magenta) { + constructor(stream: Stream) { this._st = stream; this._handler = {}; - this._transparentColor = color; + this._currentFrameCanvas = document.createElement('canvas'); + this._currentFrameContext = this._currentFrameCanvas.getContext('2d')!; this.parseHeader(); - this.parseBlock(); + this.parseBlocks(); } - // LZW (GIF-specific) - parseColorTable = (entries: any) => { + parseColorTableBytes = (entries: number) => { // Each entry is 3 bytes, for RGB. - const ct = []; + const colorTable: [number, number, number][] = []; for (let i = 0; i < entries; i++) { - const rgb: number[] = this._st.readBytes(3); - const rgba = - '#' + - rgb - .map((x: any) => { - const hex = x.toString(16); - return hex.length === 1 ? '0' + hex : hex; - }) - .join(''); - ct.push(rgba); + const rgb = this._st.readBytes(3) as [number, number, number]; + colorTable.push(rgb); } - return ct; + return colorTable; }; readSubBlocks = () => { @@ -309,18 +348,18 @@ export class ParseGif { }; parseHeader = () => { - const hdr: any = { - sig: null, - ver: null, - width: null, - height: null, - colorRes: null, - globalColorTableSize: null, - gctFlag: null, - sorted: null, + const hdr: GifHeader = { + sig: '', + ver: '', + width: 0, + height: 0, + colorResolution: 0, + globalColorTableSize: 0, + gctFlag: false, + sortedFlag: false, globalColorTable: [], - bgColor: null, - pixelAspectRatio: null // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 + backgroundColorIndex: 0, + pixelAspectRatio: 0 // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 }; hdr.sig = this._st.read(3); @@ -332,43 +371,47 @@ export class ParseGif { hdr.width = this._st.readUnsigned(); hdr.height = this._st.readUnsigned(); + this._currentFrameCanvas.width = hdr.width; + this._currentFrameCanvas.height = hdr.height; + const bits = byteToBitArr(this._st.readByte()); - hdr.gctFlag = bits.shift(); - hdr.colorRes = bitsToNum(bits.splice(0, 3)); - hdr.sorted = bits.shift(); + hdr.gctFlag = bits.shift()!; + hdr.colorResolution = bitsToNum(bits.splice(0, 3)); + hdr.sortedFlag = bits.shift()!; hdr.globalColorTableSize = bitsToNum(bits.splice(0, 3)); - hdr.bgColor = this._st.readByte(); + hdr.backgroundColorIndex = this._st.readByte(); hdr.pixelAspectRatio = this._st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 if (hdr.gctFlag) { - hdr.globalColorTable = this.parseColorTable(1 << (hdr.globalColorTableSize + 1)); - this.globalColorTable = hdr.globalColorTable; + // hdr.globalColorTable = this.parseColorTable(1 << (hdr.globalColorTableSize + 1)); + this.globalColorTableBytes = this.parseColorTableBytes(1 << (hdr.globalColorTableSize + 1)); } if (this._handler.hdr && this._handler.hdr(hdr)) { this.checkBytes.push(this._handler.hdr); } }; - parseExt = (block: any) => { - const parseGCExt = (block: any) => { + parseExt = (block: GCExtBlock) => { + const parseGCExt = (block: GCExtBlock) => { this.checkBytes.push(this._st.readByte()); // Always 4 const bits = byteToBitArr(this._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.userInputFlag = bits.shift()!; + block.transparentColorFlag = bits.shift()!; block.delayTime = this._st.readUnsigned(); - block.transparencyIndex = this._st.readByte(); + block.transparentColorIndex = this._st.readByte(); - block.terminator = this._st.readByte(); + block.terminator = this._st.readByte(); // always 0 if (this._handler.gce && this._handler.gce(block)) { this.checkBytes.push(this._handler.gce); } + return block; }; const parseComExt = (block: any) => { @@ -430,7 +473,7 @@ export class ParseGif { switch (block.label) { case 0xf9: block.extType = 'gce'; - parseGCExt(block); + this._gce = parseGCExt(block); break; case 0xfe: block.extType = 'com'; @@ -451,8 +494,8 @@ export class ParseGif { } }; - parseImg = (img: any) => { - const deinterlace = (pixels: any, width: number) => { + parseImg = (img: GifFrame) => { + const deinterlace = (pixels: number[], width: number) => { // Of course this defeats the purpose of interlacing. And it's *probably* // the least efficient way it's ever been implemented. But nevertheless... @@ -483,14 +526,14 @@ export class ParseGif { img.height = this._st.readUnsigned(); const bits = byteToBitArr(this._st.readByte()); - img.lctFlag = bits.shift(); - img.interlaced = bits.shift(); - img.sorted = bits.shift(); + 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 = this.parseColorTable(1 << (img.lctSize + 1)); + img.lctBytes = this.parseColorTableBytes(1 << (img.lctSize + 1)); } img.lzwMinCodeSize = this._st.readByte(); @@ -504,15 +547,19 @@ export class ParseGif { img.pixels = deinterlace(img.pixels, img.width); } + if (this._gce?.delayTime) { + img.delayMs = this._gce.delayTime * 10; + } + this.frames.push(img); - this.arrayToImage(img); + this.arrayToImage(img, img.lctFlag ? img.lctBytes : this.globalColorTableBytes); if (this._handler.img && this._handler.img(img)) { this.checkBytes.push(this._handler); } }; - public parseBlock = () => { - const block = { + public parseBlocks = () => { + const block: GifBlock = { sentinel: this._st.readByte(), type: '' }; @@ -520,11 +567,11 @@ export class ParseGif { switch (blockChar) { case '!': block.type = 'ext'; - this.parseExt(block); + this.parseExt(block as GCExtBlock); break; case ',': block.type = 'img'; - this.parseImg(block); + this.parseImg(block as any); break; case ';': block.type = 'eof'; @@ -537,37 +584,51 @@ export class ParseGif { } if (block.type !== 'eof') { - this.parseBlock(); + this.parseBlocks(); } }; - arrayToImage = (frame: GifFrame) => { - let count = 0; - const c = document.createElement('canvas')!; - c.id = count.toString(); - c.width = frame.width; - c.height = frame.height; - count++; - const context = c.getContext('2d')!; - const pixSize = 1; - let y = 0; - let x = 0; - for (let i = 0; i < frame.pixels.length; i++) { - if (x % frame.width === 0) { - y++; - x = 0; - } - if (this.globalColorTable[frame.pixels[i]] === this._transparentColor.toHex()) { - context.fillStyle = `rgba(0, 0, 0, 0)`; + arrayToImage = (frame: GifFrame, colorTable: [number, number, number][]) => { + const canvas = document.createElement('canvas')!; + canvas.width = frame.width; + canvas.height = frame.height; + const context = canvas.getContext('2d')!; + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + + let transparentColorIndex = -1; + if (this._gce?.transparentColorFlag) { + transparentColorIndex = this._gce.transparentColorIndex; + } + + for (let pixel = 0; pixel < frame.pixels.length; pixel++) { + const colorIndex = frame.pixels[pixel]; + const color = colorTable[colorIndex]; + if (colorIndex === transparentColorIndex) { + imageData.data.set([0, 0, 0, 0], pixel * 4); } else { - context.fillStyle = this.globalColorTable[frame.pixels[i]]; + imageData.data.set([...color, 255], pixel * 4); } - - context.fillRect(x, y, pixSize, pixSize); - x++; } + context.putImageData(imageData, 0, 0); + + // A value of 1 which tells the decoder to leave the image in place and draw the next image on top of it. + // A value of 2 would have meant that the canvas should be restored to the background color (as indicated by the logical screen descriptor). + // A value of 3 is defined to mean that the decoder should restore the canvas to its previous state before the current image was drawn. + // The behavior for values 4-7 are yet to be defined. + if (this._gce?.disposalMethod === 1 && this.images.length) { + this._currentFrameContext.drawImage(this.images.at(-1)!, 0, 0); + } else if (this._gce?.disposalMethod === 2 && this._hdr?.gctFlag) { + const bg = colorTable[this._hdr.backgroundColorIndex]; + this._currentFrameContext.fillStyle = `rgb(${bg[0]}, ${bg[1]}, ${bg[2]})`; + this._currentFrameContext.fillRect(0, 0, this._hdr.width, this._hdr.height); + } else { + this._currentFrameContext.clearRect(0, 0, this._currentFrameCanvas.width, this._currentFrameCanvas.height); + } + + this._currentFrameContext.drawImage(canvas, frame.leftPos, frame.topPos, frame.width, frame.height); + const img = new Image(); - img.src = c.toDataURL(); + img.src = this._currentFrameCanvas.toDataURL(); // default is png this.images.push(img); }; } diff --git a/src/spec/GifSpec.ts b/src/spec/GifSpec.ts index ae99bccb5..4fbdf2c52 100644 --- a/src/spec/GifSpec.ts +++ b/src/spec/GifSpec.ts @@ -13,7 +13,7 @@ describe('A Gif', () => { height: 100 }); - gif = new ex.Gif('src/spec/images/GifSpec/sword.gif', ex.Color.Black.clone()); + gif = new ex.Gif('src/spec/images/GifSpec/sword.gif'); }); afterEach(() => { engine.stop(); @@ -29,6 +29,43 @@ describe('A Gif', () => { }); }); + it('should parse gifs that have lct & anim params', async () => { + const sut = new ex.Gif('src/spec/images/GifSpec/loading-screen.gif'); + await sut.load(); + + const sprite = sut.toSprite(20); + expect(sut.isLoaded()).toBe(true); + + sprite.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/loading-frame-20.png'); + }); + + it('should parse gifs that have animations with sub frames', async () => { + const sut = new ex.Gif('src/spec/images/GifSpec/stoplight.gif'); + await sut.load(); + expect(sut.isLoaded()).toBe(true); + + const sprite2 = sut.toSprite(2); + sprite2.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-3.png'); + + const sprite1 = sut.toSprite(1); + sprite1.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-2.png'); + + const sprite0 = sut.toSprite(0); + sprite0.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-1.png'); + }); + it('should load each frame', async () => { await gif.load(); expect(gif).toBeDefined(); diff --git a/src/spec/images/GifSpec/frame1.png b/src/spec/images/GifSpec/frame1.png index b526cafa243561180fef162ef979b8636eaabc1f..421d4ab873fd4489a854c16942ae1a141a6c509d 100644 GIT binary patch delta 529 zcmV+s0`C2T29O4jBMSflb5ch_0Itp)>5(C=e_5RQeEz#l!qsXOa+pIV-DX3oV-V7@ znOwkZHmgmvUa$XOUoMv+hdE@@jv1SlM~Dem{9uH{Gd8V&5ECx{!Bzp&=`;yZRTVwN zQwu^oX*$e>5C@2!Qy|2X<_>d}3fOEm$*PORBJ@@vbn+eexnF`1ccf(#BBYF;`_%|> ze@AMYux|qL-3_jXAjEcn!$%_|9Iq7GVSSu#KPsyKs1@j7@ z9wFg~Z3`g8#w&a@Lc$T-7HD0_&Qe@gf{9w$4vx;(4hQT62cbKbGz-F^aR$VL>p|=X5lkdRK{St(@BQ2W{A!YpBf3HS}J5t+(eG`!H zZg4#WA+`e?J{lq6h;0iX#Ky~gbl(LGu7@DR{fw7Qh>$XV?pGtk9jR@?p$X`H4?&3S z0Edr8NH}8K0tm73GLOE$K1e<(c=z<3JkA-q0J$E5kW@+M4;K-Fkj@Wb-TQj$;qr>O zI6X@8y}W-W-yfbof7JG+{Bu)n{|(&SG#f5Jh&R!~`4CbF(EDvnwmO73QNp-IYR1*@s?#hQNiO|7n1MCS~2;3O8)FCm{<7p2nk1QTL2+8 zUg4t=5{}rmKjIN8C~v6&>r1c&3d_jbW2}F^NLm0$_`~h|okaVeZpo;(i002ovPDHLk FV1gB!`UU_1 diff --git a/src/spec/images/GifSpec/frame2.png b/src/spec/images/GifSpec/frame2.png index 30806c1fba46801302ec74cb7feb7cc36eb31e26..9ca7f1ffc9725a56cfb4c6441b5251eb80faa711 100644 GIT binary patch delta 1807 zcmbu-`9Bkk1HkcV%+VZauFWxz`{f#yAs(aED#GYtB0`QHHWowjY>qwdg{S10t9V$7 zl4~>`*G$dTWFB%nktD|Bmh;;f&DlY;6q7!vm zqCym=-0r$_+EynmhnYs)E6%HFS|yK%pVod3JldRc$yke97rc6Sk`u82+$3_G-6HxM z5?-6(YLAnfCdpbVbu5iQbv7dfPGQu zCLIeW+vh5PGQw+id}5E6Y~9I*hK5azgQYfV3Ol{d(p!hKWIOwDK>$RIig==#NZeRK zNYaK{mAeo)2YKik3XXal(1QBTuFF+@_YsdIu1s14Epr<`38arpt?uz|;3&9My5eH^ z_1**o#274=&fDUo0+U2zmKII+HK>?Rt$~?y0QQNEErw6=aX!F5Fp!AJqtopL0zo^e z4IfS+6CXS|j10Bnfj(~l)%9&`O8Zl+dJwzPlnmxxg1ywLVfi05^r9>5q@$w=|1tDQ zLQN&v65ZXcQM)@>e4cVAJRCuAA-7g9pwsXH0_oT?yCGAq)*7*zD=i*NhVup?; z$Hp+ys#dRT#zsf6c)a1oa%^k#sv|iTq-M(w?ZAzoWW8?Nd1Scqg?omS@(*=c86Je}`1OgkY9@U-HYMl5e8@EvOUhT# z8&1v+FVFgYBY3RSrFy2Zp=oXcWovQRK8zl*pae)cEyp;s-joy;LZL9&25o0&=gpT3 zPt6XZT$#hhC84mm<15?Re`&)nL!Fu;A|kebQ^%%Jzvw~4EeC}%$F1#=+Y>iGnd>s* zkh_(VB0o{PeXH)tLlH~_q`p@C*K@?vW#eX?hxjC<+WW6(a}5s)KDfmUpcExkU$I2E zl*bC!GY!&{<(KzUJPruoc8`3C`f_<=Z@$?DpUxH!Xe2fy%+^S+T~lN{WUSo9=Og+@ z;zy^eTuvl9wwo(9C3ap^D9d#B*tY-krMF(QUmaUT?$+igA8Q^yldGV7m*HzO;7A*u z?626EYD>_yE}Xo2?j;$rAoDWhK)K7$k5b@S(?>=zRg76^l+n%B+hN>KnT;JNiXPD9 z2?FdU?=0^(6sj2aqC=-0oU~*L>G%r66|rLww{>MQo9Wt*y#1S-!)AWow-oDjT8nua zWNYyN|H22BdIkle{d_oqvI(?OLGVDgEza+?!8jv(A35|nqx*j!r`GN;!}{oyW;f@5 z*>3m6spei+uU-v~isGlO)h9-`oK-J`0eiSyuAYt#hrl~9f=FOBW$Wf9b$T$q!;@+q zQPI&=%V1Ua8_KVhWmco(B3(SZ?Pw`Gy*Fda_nYQq;?k1Xm6h6-**m~P%j4F=fBEC% zSAvF0B#SH}db^C0*##vfm9CU3N0A$zo;4bn2j+MmR!q*12L!POh>~g~ZMuR}_p2pw zmI@R;qoUI?Ffd?-0CbRof%o!7>G5gDZ(3WIH8egy%9JOQTnX?3VL*4BMWZaF1CceZ z?&9Q>74i>QB!wE^`5o?!#oDTNtIUm#gmxV|e0l!8Tz+$>Tw$H#Dp1MEErNpbT9Z__Zped2tg7d>(sBB!|^?>)Am_ zEpI2_@eH&+?@73%_*K@g}&QB*y+feI=0#Hq^oSCJpoe}KsUEB+HW0)n|pPjoN;BM=v#^kXev5mj~h@-NQiATC|Q|(UeS3L zhC}%@7MY8ilw;Nlv{9C?j05@XtupIb&{?S*@vELIqP@?D>CVG38l8$cf39KYWP0hy zR(}}H3`^`x?LqH@s(m?(6HL2M-?DpX=)CsJ6D2qR}Yb zyZ46!>*&#=R8&;tf2xc69PjS#a_Rz7L&HJ4jjLC$I^uKNW?gOrf6cnwhMAjp>5Q8( zD+E#oV!~Ld^XdIdj^Mg}P8p5N(XD|(2L{*0z$YfgZ6Ud^pe&@fZhie88xj`?jk20H z7zzW$b=)7rF#F>PlZsPP0+I|2BwkQZKqGZA7dJnbOa?0He}4WOEAdV1_Ym3yX9D-e z6V)}Ha;*PH*YdsEWY+`)yOqk`-qrTus<*HskLGsd5}rj~s@~$dkDy{UV615%2`)0B zti>s70^;Wk%zhN!#Vj{SKzZ$WRoqQ>y~9Yy^0g))UWYIYFAzUSxY5VLd9>?bDb?>< zTHA4LprD+7e_v>#>6|i$Cw4q2-d#^kO%08Wjrqp=`}Zff1htyD3D9hts@srvK=Iax_$Cb0NmO^aHcS}iX~1glQz-#KCtn;4TfyL> z3sAO>SFe;?vDo5{3NZR=>nI$!@5&1fIn=P9W~WAIZf4T??%qI@9!+kgnpZ!H zbBNb9e|`>OF|o|8R%AaQ+>RGsA|RJ8U9z|3aK>6-pud!Xm{1r7D+?$Kv1c|6K33op zUxvYT846(Z4J^)LJQy+*2*%q@6H2eK+uOHqpM4z!pk1G1bQ{oak^Y5ncv&2g7K?0-bpe|y|Flp+lapgNX?_<9g(wn<*m3R?;-fHV?hjzQySr0%29GN(4p|O} z_d7m1sVpy{#>0E#F1^ux+i?iK#KB@yQxhG?`++I~LvuDo+&YU@8VHd;V ze@stL)2UOZ0=3D^r^hvLhJr&v zz~Fokuc)Y?j*bqejkdNn3*rLYqt13`e;MVv7cN||Pk_+|PK;YxTIk7>Cv@e?6{ii< zEi5dwpSJ+R>@yiH64lK(5N{%WP$E+^^wYK5bo!ftRlSIUNM2#ERQ@$r>H9Lv@2K4G zWmcjWV(9Pk<;(w%_aT074NWM3@pEUd>z63$n0f1^jf^@3qG0BFx4(#E*R^MNU!4sD zg?}1kE?etG)Kv1!I1m$x_s0%Jg_|ilz4+?ZEas2sQW@-exHc<5APkOP#_N~5=igp= zm$Z!p1G;1li0KsX58)WfMuU-cAnRcX$#jlcbx5WMyB_*eAi^jqlO6#ZlNbRZlNbRZ glNbRY7#NWM0E2fKp+f#lasU7T07*qoM6N<$f*b8P#{d8T diff --git a/src/spec/images/GifSpec/loading-frame-20.png b/src/spec/images/GifSpec/loading-frame-20.png new file mode 100644 index 0000000000000000000000000000000000000000..00c70ff9d42d5b65e3ab563951527064b9c061f1 GIT binary patch literal 477 zcmeAS@N?(olHy`uVBq!ia0vp^DImtQfByS@N6dcP`pP>`{h9M`nlEh9 zsWWcUIWKNC66->Jjb0_ zIwdp*IHF3#P#_fq|14x!iTbavt# Zah9h`Vx=5<>Vfga;OXk;vd$@?2>{=IrV;=E literal 0 HcmV?d00001 diff --git a/src/spec/images/GifSpec/loading-screen.gif b/src/spec/images/GifSpec/loading-screen.gif new file mode 100644 index 0000000000000000000000000000000000000000..e38abea94f74d2b429936014ea209995acb95131 GIT binary patch literal 16267 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 WeVG~D>}M~V`ut_dd5fpm`1~I(>lNMr literal 0 HcmV?d00001 diff --git a/src/spec/images/GifSpec/stoplight-frame-1.png b/src/spec/images/GifSpec/stoplight-frame-1.png new file mode 100644 index 0000000000000000000000000000000000000000..cea7d03fa0342e481ea1cb6ae98a588d7db82277 GIT binary patch literal 558 zcmeAS@N?(olHy`uVBq!ia0vp^DImYJi;Bl`|oTr zS(126?$U<>&xcb&#XtGm&HlB)fxqo=L-_ICscVI#M0;*^$CLqS3mkiu0GOsy_!!FsAk@T(4@n!-?!fRx1jp}q9dy(Z|AD~^7>aCqhh}B zr|a>u>!dgoQ)W2i{3+$XIH6_M-(`9HKX-^MU(>IBgZ+bylShw(PG7Q)q9M~p3n>V% tO+ZLlpnI`m%Mp$vk1{xhos^;CeWMS4sfAIsdw~gn!PC{xWt~$(69DT(*+KvS literal 0 HcmV?d00001 diff --git a/src/spec/images/GifSpec/stoplight-frame-2.png b/src/spec/images/GifSpec/stoplight-frame-2.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c99cb5ce6645f6ea4ad6516cf226ad85299e8f GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^DImVDj*EaSW-5 zdppx{U6TXD(FdnwTC}rcEEp9h&bqSUMf`VrU+I>Mm9v%|FZr9lug~pY(gW2U3j1eF z{`XuUCHDHqpwpXb_T_|kh&;00&AqAjBb(hKktgwv=aS~QXnHuNFq_Yomye89{H}4? z_KBSAvE45;U%zwQw`YIetD*yZZJQFT>N!5$m~;F2-HN!dB+6?((k|X!sJ;u@*e)%gra3sh>XBd ZU(6hu`gIvc8!&+}c)I$ztaD0e0swFU5;6b) literal 0 HcmV?d00001 diff --git a/src/spec/images/GifSpec/stoplight-frame-3.png b/src/spec/images/GifSpec/stoplight-frame-3.png new file mode 100644 index 0000000000000000000000000000000000000000..8a3ecd43ca4580969b8f13f2342e977cde2a8009 GIT binary patch literal 648 zcmeAS@N?(olHy`uVBq!ia0vp^DImU~=+waSW-5 zdppyYt0{ni?dvJIBiiD+BF38TGYSmX?r)p%@Tc=*1D^hef6{I2b1RP?@Gfv%H)Ha? z&w?q@&mUNnI4u$2{-%54V&F|tjT8HuYIf)4YKbgS*wi@t;dSpY^(S(vyV~z8KKx6& z>eR043G(~b&)y}oLEJgg;AlO|%;%Ma9qsJ<6GEmz`GHGkuwHuP_{O*-pATwEC zB<(zRl62djCWpjtd#XD?Do-^5RlZl;mUu#+11SBw{xHxeR+x5#>7zoha3(P%{+1tCfh;gk{Lk&@8WQa67~pE8XTZz|6jc1l z!jb}{bwCP0+8LM)Ja*l=r@YTYaqa7*rK*=FfA4vsK1=%gLh;#h3qDUX5x#SNt$9`c z_Z_ST8ec5Swx%$F4KV^4!UQyeok0L-h-8KOn$x_eJRff6+I{x>k8|pBNtyE&OE$Sf RRD-Q#Vqk)+X2+%48UW5cMh5@@ literal 0 HcmV?d00001