Last active
January 8, 2025 07:42
-
-
Save ArvidSilverlock/38e25c9ddba8d63be3314580cd08c28d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --!native | |
| --!optimize 2 | |
| type Buffer = { | |
| buffer: buffer, | |
| offset: number, | |
| } | |
| export type GIF = { | |
| width: number, | |
| height: number, | |
| size: Vector2, | |
| backgroundIndex: number, | |
| globalPalette: buffer, | |
| frames: { Frame }, | |
| } | |
| export type Frame = { | |
| left: number, | |
| top: number, | |
| position: Vector2, | |
| width: number, | |
| height: number, | |
| size: Vector2, | |
| isInterlaced: boolean, | |
| localPalette: buffer, | |
| palette: buffer, | |
| paletteIndexes: buffer, | |
| frameDelay: number, | |
| transparentIndex: number, | |
| disposalMethod: number, | |
| } | |
| local bitreader | |
| local DecompressLZW | |
| do -- bitreader | |
| bitreader = {} | |
| bitreader.__index = bitreader | |
| local function nextChunk(reader, width) | |
| if width > 24 then | |
| reader._chunk += math.ldexp(buffer.readu32(reader._buffer, reader._byte), reader._bit) | |
| reader._byte += 4 | |
| reader._bit += 32 | |
| elseif width > 16 then | |
| reader._chunk += math.ldexp( | |
| buffer.readu8(reader._buffer, reader._byte) + buffer.readu16(reader._buffer, reader._byte + 1) * 2 ^ 8, | |
| reader._bit | |
| ) | |
| reader._byte += 3 | |
| reader._bit += 24 | |
| elseif width > 8 then | |
| reader._chunk += math.ldexp(buffer.readu16(reader._buffer, reader._byte), reader._bit) | |
| reader._byte += 2 | |
| reader._bit += 16 | |
| else | |
| reader._chunk += math.ldexp(buffer.readu8(reader._buffer, reader._byte), reader._bit) | |
| reader._byte += 1 | |
| reader._bit += 8 | |
| end | |
| end | |
| function bitreader.new(b: buffer) | |
| return setmetatable({ | |
| _buffer = b, | |
| _byte = 0, | |
| _bit = 0, | |
| _chunk = 0, | |
| }, bitreader) | |
| end | |
| function bitreader:UInt(width: number): number | |
| if self._bit < width then nextChunk(self, width - self._bit) end | |
| local value = self._chunk % 2 ^ width | |
| self._chunk = self._chunk // 2 ^ width | |
| self._bit -= width | |
| return value | |
| end | |
| function bitreader:SetByteOffset(byte: number) | |
| self._chunk = 0 | |
| self._bit = 0 | |
| self._byte = byte | |
| end | |
| function bitreader:GetByteOffset(byte: number) | |
| return self._byte | |
| end | |
| end | |
| do -- DecompressLZW | |
| local AVERAGE_STACK_HEIGHT = 7 | |
| local AVERAGE_COMPRESSION = 1.4 | |
| local TRUE_MAX_VOCABULARY = 2 ^ 12 | |
| function DecompressLZW(b, initialCodeSize) | |
| local clearCode = bit32.lshift(1, initialCodeSize - 1) | |
| local endCode = clearCode + 1 | |
| -- kind of a linked listed, but in reverse | |
| local vocabulary1 = table.create(TRUE_MAX_VOCABULARY) | |
| local vocabulary2 = table.create(TRUE_MAX_VOCABULARY) | |
| local codeSize, vocabularyMax | |
| local nextCode, previousCode | |
| local output, length = table.create(math.floor(buffer.len(b) / AVERAGE_COMPRESSION)), 0 | |
| local function resetVariables() | |
| codeSize = initialCodeSize | |
| vocabularyMax = bit32.lshift(1, codeSize) | |
| nextCode = clearCode + 2 | |
| previousCode = nextCode - 1 | |
| end | |
| resetVariables() | |
| local reader = bitreader.new(b) | |
| repeat | |
| local code = reader:UInt(codeSize) | |
| if code == clearCode then | |
| table.clear(vocabulary1) | |
| table.clear(vocabulary2) | |
| resetVariables() | |
| elseif code ~= endCode then | |
| local valueStack | |
| local stackIndex = 1 | |
| local value = code | |
| if value > clearCode then | |
| valueStack = table.create(AVERAGE_STACK_HEIGHT) | |
| repeat | |
| value, valueStack[stackIndex] = vocabulary1[value], vocabulary2[value] | |
| stackIndex += 1 | |
| until value < clearCode | |
| valueStack[stackIndex] = value | |
| else | |
| valueStack = { code } | |
| end | |
| if vocabulary1[previousCode] and vocabulary2[previousCode] == nil then | |
| vocabulary2[previousCode] = value | |
| if code == previousCode then valueStack[1] = value end | |
| end | |
| if nextCode < TRUE_MAX_VOCABULARY then | |
| vocabulary1[nextCode] = code | |
| if nextCode == vocabularyMax then | |
| codeSize = math.min(codeSize + 1, 12) | |
| vocabularyMax = bit32.lshift(1, codeSize) | |
| end | |
| previousCode = nextCode | |
| nextCode += 1 | |
| end | |
| table.insert(output, valueStack) | |
| length += stackIndex | |
| end | |
| until code == endCode | |
| local outputBuffer, outputOffset = buffer.create(length), 0 | |
| for _, value in output do | |
| for index = 0, #value - 1 do | |
| buffer.writeu8(outputBuffer, outputOffset + index, value[#value - index]) | |
| end | |
| outputOffset += #value | |
| end | |
| return outputBuffer | |
| end | |
| end | |
| local GIF_IDENTIFIER = "GIF89a" | |
| local IMAGE_DESCRIPTOR = 44 | |
| local EXTENSION = 33 | |
| local TRAILER = 59 | |
| local function readBlock(b: Buffer, gif: GIF) | |
| local blockTable = {} | |
| while true do | |
| local blockSize = buffer.readu8(b.buffer, b.offset) | |
| b.offset += 1 | |
| if blockSize > 0 then | |
| table.insert(blockTable, buffer.readstring(b.buffer, b.offset, blockSize)) | |
| b.offset += blockSize | |
| else | |
| break | |
| end | |
| end | |
| return buffer.fromstring(table.concat(blockTable)) | |
| end | |
| local function skipBlock(b: Buffer, gif: GIF) | |
| repeat | |
| local blockSize = buffer.readu8(b.buffer, b.offset) | |
| b.offset += blockSize + 1 | |
| until blockSize == 0 | |
| end | |
| local function getPalette(b: Buffer, gif: GIF, paletteSize: number, colourResolution: number) | |
| local palette = buffer.create(paletteSize * 3) | |
| local reader = bitreader.new(b.buffer) | |
| reader:SetByteOffset(b.offset) | |
| local max = bit32.lshift(1, colourResolution) - 1 | |
| for index = 0, paletteSize * 3 - 1 do | |
| buffer.writeu8(palette, index, math.floor(reader:UInt(colourResolution) / max * 255)) | |
| end | |
| b.offset = reader:GetByteOffset() | |
| return palette | |
| end | |
| local function parseImage(b: Buffer, gif: GIF, frame: Frame) | |
| frame.left = buffer.readu16(b.buffer, b.offset) | |
| frame.top = buffer.readu16(b.buffer, b.offset + 2) | |
| frame.width = buffer.readu16(b.buffer, b.offset + 4) | |
| frame.height = buffer.readu16(b.buffer, b.offset + 6) | |
| local field = buffer.readu8(b.buffer, b.offset + 8) | |
| b.offset += 9 | |
| frame.position = Vector2.new(frame.left, frame.top) | |
| frame.size = Vector2.new(frame.width, frame.height) | |
| frame.isInterlaced = bit32.extract(field, 6, 1) == 1 | |
| local localPalettePresent = bit32.extract(field, 7, 1) == 1 | |
| if localPalettePresent then | |
| local paletteSize = 2 ^ (bit32.extract(field, 0, 3) + 1) | |
| frame.localPalette = getPalette(b, gif, paletteSize, 8) | |
| end | |
| local lzwCodeSize = buffer.readu8(b.buffer, b.offset) + 1 | |
| b.offset += 1 | |
| frame.paletteIndexes = DecompressLZW(readBlock(b, gif), lzwCodeSize) | |
| end | |
| local function runExtensions(b: Buffer, gif: GIF, frame) | |
| local label = buffer.readu8(b.buffer, b.offset) | |
| b.offset += 1 | |
| if label == 1 or label == 254 or label == 255 then -- Plain Text, Graphic Control, Comment, Application | |
| skipBlock(b, gif) | |
| else | |
| local block = readBlock(b, gif) | |
| if label == 249 then | |
| local packedFields = buffer.readu8(block, 0) | |
| frame.frameDelay = buffer.readu16(block, 1) / 100 | |
| frame.transparentIndex = if bit32.extract(packedFields, 0, 1) == 1 then buffer.readu8(block, 3) else nil | |
| frame.disposalMethod = bit32.extract(packedFields, 2, 3) | |
| else | |
| error(`unrecognized extension label '{label}'`) | |
| end | |
| end | |
| end | |
| local function DecodeGif(gifData: string): (GIF, number) | |
| local gif: GIF = {} :: any | |
| local b: Buffer = { buffer = buffer.fromstring(gifData), offset = 0 } | |
| assert(buffer.readstring(b.buffer, 0, 6) == GIF_IDENTIFIER, "wrong file type") | |
| b.offset += 6 | |
| gif.width = buffer.readu16(b.buffer, b.offset) | |
| gif.height = buffer.readu16(b.buffer, b.offset + 2) | |
| gif.size = Vector2.new(gif.width, gif.height) | |
| b.offset += 4 | |
| local field = buffer.readu8(b.buffer, b.offset) | |
| gif.backgroundIndex = buffer.readu8(b.buffer, b.offset + 1) * 3 | |
| b.offset += 3 | |
| local globalPalettePresent = bit32.extract(field, 7, 1) == 1 | |
| if globalPalettePresent then | |
| local paletteSize = 2 ^ (bit32.extract(field, 0, 3) + 1) | |
| local colourResolution = bit32.extract(field, 4, 3) + 1 | |
| gif.globalPalette = getPalette(b, gif, paletteSize, colourResolution) | |
| end | |
| local lastWait = os.clock() | |
| local yieldTime = 0 | |
| local currentFrame: Frame = {} :: any | |
| gif.frames = {} | |
| while true do | |
| local blockType = buffer.readu8(b.buffer, b.offset) | |
| b.offset += 1 | |
| if os.clock() - lastWait > 1 / 60 then | |
| yieldTime += task.wait(1 / 60) | |
| lastWait = os.clock() | |
| end | |
| if blockType == IMAGE_DESCRIPTOR then | |
| parseImage(b, gif, currentFrame) | |
| table.insert(gif.frames, currentFrame) | |
| currentFrame = {} :: any | |
| elseif blockType == EXTENSION then | |
| runExtensions(b, gif, currentFrame) | |
| elseif blockType == TRAILER then | |
| break | |
| else | |
| warn(`unrecognized block type '{blockType}'`) | |
| end | |
| end | |
| return gif, yieldTime | |
| end | |
| return DecodeGif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment