animated gif support
This commit is contained in:
parent
550bec6117
commit
d544926ef9
|
@ -49,7 +49,7 @@ proc decodeImage*(data: string): Image {.raises: [PixieError].} =
|
|||
(data.readStr(0, 5) == xmlSignature or data.readStr(0, 4) == svgSignature):
|
||||
newImage(parseSvg(data))
|
||||
elif data.len > 6 and data.readStr(0, 6) in gifSignatures:
|
||||
decodeGif(data)
|
||||
newImage(decodeGif(data))
|
||||
elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature:
|
||||
decodeQoi(data).convertToImage()
|
||||
elif data.len > 9 and data.readStr(0, 2) in ppmSignatures:
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
import chroma, flatty/binny, math, pixie/common, pixie/images, zippy/bitstreams
|
||||
import chroma, flatty/binny, pixie/common, pixie/images, std/math, std/strutils,
|
||||
vmath, zippy/bitstreams
|
||||
|
||||
# See: https://www.w3.org/Graphics/GIF/spec-gif89a.txt
|
||||
|
||||
const gifSignatures* = @["GIF87a", "GIF89a"]
|
||||
|
||||
# See: https://en.wikipedia.org/wiki/GIF
|
||||
type
|
||||
Gif* = ref object
|
||||
frames*: seq[Image]
|
||||
intervals*: seq[float32] # Floating point seconds
|
||||
duration*: float32
|
||||
|
||||
ControlExtension = object
|
||||
fields: uint8
|
||||
delayTime: uint16
|
||||
transparentColorIndex: uint8
|
||||
|
||||
template failInvalid() =
|
||||
raise newException(PixieError, "Invalid GIF buffer, unable to load")
|
||||
|
@ -10,173 +22,353 @@ template failInvalid() =
|
|||
when defined(release):
|
||||
{.push checks: off.}
|
||||
|
||||
proc decodeGif*(data: string): Image {.raises: [PixieError].} =
|
||||
## Decodes GIF data into an Image.
|
||||
proc decodeGif*(data: string): Gif {.raises: [PixieError].} =
|
||||
## Decodes GIF data.
|
||||
if data.len < 13:
|
||||
failInvalid()
|
||||
|
||||
if data.len <= 13: failInvalid()
|
||||
let version = data[0 .. 5]
|
||||
if version notin gifSignatures:
|
||||
if data[0 .. 5] notin gifSignatures:
|
||||
raise newException(PixieError, "Invalid GIF file signature")
|
||||
|
||||
result = Gif()
|
||||
|
||||
let
|
||||
# Read information about the image.
|
||||
width = data.readInt16(6).int
|
||||
height = data.readInt16(8).int
|
||||
flags = data.readUint8(10).int
|
||||
hasColorTable = (flags and 0x80) != 0
|
||||
originalDepth = ((flags and 0x70) shr 4) + 1
|
||||
colorTableSorted = (flags and 0x8) != 0
|
||||
colorTableSize = 2 ^ ((flags and 0x7) + 1)
|
||||
bgColorIndex = data.readUint8(11)
|
||||
pixelAspectRatio = data.readUint8(11)
|
||||
screenWidth = data.readInt16(6).int
|
||||
screenHeight = data.readInt16(8).int
|
||||
globalFlags = data.readUint8(10).int
|
||||
hasGlobalColorTable = (globalFlags and 0b10000000) != 0
|
||||
globalColorTableSize = 2 ^ ((globalFlags and 0b00000111) + 1)
|
||||
bgColorIndex = data.readUint8(11).int
|
||||
pixelAspectRatio = data.readUint8(12)
|
||||
|
||||
result = newImage(width, height)
|
||||
if bgColorIndex > globalColorTableSize:
|
||||
failInvalid()
|
||||
|
||||
if pixelAspectRatio != 0:
|
||||
raise newException(PixieError, "Unsupported GIF, pixel aspect ratio")
|
||||
|
||||
var pos = 13
|
||||
|
||||
if pos + globalColorTableSize * 3 > data.len:
|
||||
failInvalid()
|
||||
|
||||
# Read the main color table.
|
||||
var
|
||||
colors: seq[ColorRGBA]
|
||||
i = 13
|
||||
if hasColorTable:
|
||||
if i + colorTableSize * 3 >= data.len: failInvalid()
|
||||
for c in 0 ..< colorTableSize:
|
||||
let
|
||||
r = data.readUint8(i + 0)
|
||||
g = data.readUint8(i + 1)
|
||||
b = data.readUint8(i + 2)
|
||||
colors.add(rgba(r, g, b, 255))
|
||||
i += 3
|
||||
|
||||
# Read the image blocks.
|
||||
while true:
|
||||
let blockType = data.readUint8(i)
|
||||
i += 1
|
||||
case blockType:
|
||||
of 0x2c: # Read IMAGE block.
|
||||
if i + 9 >= data.len: failInvalid()
|
||||
let
|
||||
left = data.readUint16(i + 0)
|
||||
top = data.readUint16(i + 2)
|
||||
w = data.readUint16(i + 4).int
|
||||
h = data.readUint16(i + 6).int
|
||||
flags = data.readUint8(i + 8)
|
||||
|
||||
hasColorTable = (flags and 0x80) != 0
|
||||
interlace = (flags and 0x40) != 0
|
||||
colorTableSorted = (flags and 0x8) != 0
|
||||
colorTableSize = 2 ^ ((flags and 0x7) + 1)
|
||||
|
||||
i += 9
|
||||
|
||||
# Make sure we support the GIF features.
|
||||
if left != 0 or top != 0 or w != result.width or h != result.height:
|
||||
raise newException(PixieError, "GIF block offsets not supported")
|
||||
|
||||
if hasColorTable:
|
||||
raise newException(
|
||||
PixieError, "GIF color table per block not yet supported"
|
||||
globalColorTable: seq[ColorRGBX]
|
||||
bgColor: ColorRGBX
|
||||
if hasGlobalColorTable:
|
||||
globalColorTable.setLen(globalColorTableSize)
|
||||
for i in 0 ..< globalColorTable.len:
|
||||
globalColorTable[i] = rgbx(
|
||||
data.readUint8(pos + 0),
|
||||
data.readUint8(pos + 1),
|
||||
data.readUint8(pos + 2),
|
||||
255
|
||||
)
|
||||
pos += 3
|
||||
bgColor = globalColorTable[bgColorIndex]
|
||||
|
||||
if interlace:
|
||||
raise newException(PixieError, "Interlaced GIF not yet supported")
|
||||
proc skipSubBlocks() =
|
||||
while true: # Skip data sub-blocks
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
# Read the lzw data chunks.
|
||||
if i >= data.len: failInvalid()
|
||||
let lzwMinBitSize = data.readUint8(i)
|
||||
i += 1
|
||||
var lzwData = ""
|
||||
while true:
|
||||
if i >= data.len: failInvalid()
|
||||
let lzwEncodedLen = data.readUint8(i)
|
||||
i += 1
|
||||
if lzwEncodedLen == 0:
|
||||
# Stop reading when chunk len is 0.
|
||||
let subBlockSize = data.readUint8(pos).int
|
||||
inc pos
|
||||
|
||||
if subBlockSize == 0:
|
||||
break
|
||||
if i + lzwEncodedLen.int > data.len: failInvalid()
|
||||
lzwData.add data[i ..< i + lzwEncodedLen.int]
|
||||
i += lzwEncodedLen.int
|
||||
|
||||
pos += subBlockSize
|
||||
|
||||
var controlExtension: ControlExtension
|
||||
while true:
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let blockType = data.readUint8(pos)
|
||||
inc pos
|
||||
|
||||
case blockType:
|
||||
of 0x2c: # Image
|
||||
if pos + 9 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let
|
||||
clearCode = 1 shl lzwMinBitSize
|
||||
imageLeftPos = data.readUint16(pos + 0).int
|
||||
imageTopPos = data.readUint16(pos + 2).int
|
||||
imageWidth = data.readUint16(pos + 4).int
|
||||
imageHeight = data.readUint16(pos + 6).int
|
||||
imageFlags = data.readUint16(pos + 8)
|
||||
hasLocalColorTable = (imageFlags and 0b10000000) != 0
|
||||
interlaced = (imageFlags and 0b01000000) != 0
|
||||
localColorTableSize = 2 ^ ((imageFlags and 0b00000111) + 1)
|
||||
|
||||
pos += 9
|
||||
|
||||
var localColorTable: seq[ColorRGBX]
|
||||
if hasLocalColorTable:
|
||||
localColorTable.setLen(localColorTableSize)
|
||||
for i in 0 ..< localColorTable.len:
|
||||
localColorTable[i] = rgbx(
|
||||
data.readUint8(pos + 0),
|
||||
data.readUint8(pos + 1),
|
||||
data.readUint8(pos + 2),
|
||||
255
|
||||
)
|
||||
pos += 3
|
||||
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let minCodeSize = data.readUint8(pos).int
|
||||
inc pos
|
||||
|
||||
if minCodeSize > 11:
|
||||
failInvalid()
|
||||
|
||||
# The image data is contained in a sequence of sub-blocks
|
||||
var lzwDataBlocks: seq[(int, int)] # (offset, len)
|
||||
while true:
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let subBlockSize = data.readUint8(pos).int
|
||||
inc pos
|
||||
|
||||
if subBlockSize == 0:
|
||||
break
|
||||
|
||||
if pos + subBlockSize > data.len:
|
||||
failInvalid()
|
||||
|
||||
lzwDataBlocks.add((pos, subBlockSize))
|
||||
|
||||
pos += subBlockSize
|
||||
|
||||
var lzwDataLen: int
|
||||
for (_, len) in lzwDataBlocks:
|
||||
lzwDataLen += len
|
||||
|
||||
var
|
||||
lzwData = newString(lzwDataLen)
|
||||
i: int
|
||||
for (offset, len) in lzwDataBlocks:
|
||||
copyMem(lzwData[i].addr, data[offset].unsafeAddr, len)
|
||||
i += len
|
||||
|
||||
let
|
||||
clearCode = 1 shl minCodeSize
|
||||
endCode = clearCode + 1
|
||||
|
||||
# Turn full lzw data into bit stream.
|
||||
var
|
||||
bs = BitStreamReader(
|
||||
src: cast[ptr UncheckedArray[uint8]](lzwData[0].addr),
|
||||
b = BitStreamReader(
|
||||
src: cast[ptr UncheckedArray[uint8]](lzwData.cstring),
|
||||
len: lzwData.len
|
||||
)
|
||||
bitSize = lzwMinBitSize + 1
|
||||
currentCodeTableMax = (1 shl (bitSize)) - 1
|
||||
codeLast: int = -1
|
||||
codeTable: seq[seq[int]]
|
||||
colorIndexes: seq[int]
|
||||
codeSize = minCodeSize + 1
|
||||
table = newSeq[(int, int)](endCode + 1)
|
||||
prev: (int, int)
|
||||
|
||||
# Main decode loop.
|
||||
while codeLast != endCode:
|
||||
if bs.pos + bitSize.int > bs.len * 8: failInvalid()
|
||||
var
|
||||
# Read variable bits out of the table.
|
||||
codeId = bs.readBits(bitSize.int).int
|
||||
# Some time we need to carry over table information.
|
||||
carryOver: seq[int]
|
||||
|
||||
if codeId == clearCode:
|
||||
# Clear and re-init the tables.
|
||||
bitSize = lzwMinBitSize + 1
|
||||
currentCodeTableMax = (1 shl (bitSize)) - 1
|
||||
codeLast = -1
|
||||
codeTable.setLen(0)
|
||||
for x in 0 ..< endCode + 1:
|
||||
codeTable.add(@[x])
|
||||
|
||||
elif codeId == endCode:
|
||||
# Exit we are done.
|
||||
while true:
|
||||
let code = b.readBits(codeSize).int
|
||||
if b.bitsBuffered < 0:
|
||||
failInvalid()
|
||||
if code == endCode:
|
||||
break
|
||||
|
||||
elif codeId < codeTable.len and codeTable[codeId].len > 0:
|
||||
# Its in the current table, use it.
|
||||
let current = codeTable[codeId]
|
||||
colorIndexes.add(current)
|
||||
carryOver = @[current[0]]
|
||||
if code == clearCode:
|
||||
codeSize = minCodeSize + 1
|
||||
table.setLen(endCode + 1)
|
||||
prev = (0, 0)
|
||||
continue
|
||||
|
||||
elif codeLast notin [-1, clearCode, endCode]:
|
||||
# Its in the current table use it.
|
||||
if codeLast >= codeTable.len: failInvalid()
|
||||
var previous = codeTable[codeLast]
|
||||
carryOver = @[previous[0]]
|
||||
colorIndexes.add(previous & carryOver)
|
||||
# Increase the code size if needed
|
||||
if table.len == (1 shl codeSize) - 1 and codeSize < 12:
|
||||
inc codeSize
|
||||
|
||||
if codeTable.len == currentCodeTableMax and bitSize < 12:
|
||||
# We need to expand the codeTable max and the bit size.
|
||||
inc bitSize
|
||||
currentCodeTableMax = (1 shl (bitSize)) - 1
|
||||
|
||||
if codeLast notin [-1, clearCode, endCode]:
|
||||
# We had some left over and need to expand table.
|
||||
if codeLast >= codeTable.len: failInvalid()
|
||||
codeTable.add(codeTable[codeLast] & carryOver)
|
||||
|
||||
codeLast = codeId
|
||||
|
||||
# Convert color indexes into real colors.
|
||||
for j, idx in colorIndexes:
|
||||
if idx >= colors.len or j >= result.data.len: failInvalid()
|
||||
result.data[j] = colors[idx].rgbx()
|
||||
|
||||
of 0x21: # Read EXTENSION block.
|
||||
# Skip over all extensions (mostly animation information).
|
||||
let extentionType = data.readUint8(i)
|
||||
inc i
|
||||
let byteLen = data.readUint8(i)
|
||||
inc i
|
||||
i += byteLen.int
|
||||
doAssert data.readUint8(i) == 0
|
||||
inc i
|
||||
of 0x3b: # Read TERMINAL block.
|
||||
# Exit block byte - we are done.
|
||||
return
|
||||
let start = colorIndexes.len
|
||||
if code < table.len: # If we have seen the code before
|
||||
if code < clearCode:
|
||||
colorIndexes.add(code)
|
||||
if prev[1] > 0:
|
||||
table.add((prev[0], prev[1] + 1))
|
||||
prev = (start, 1)
|
||||
else:
|
||||
raise newException(PixieError, "Invalid GIF block type")
|
||||
let (offset, len) = table[code]
|
||||
for i in 0 ..< len:
|
||||
colorIndexes.add(colorIndexes[offset + i])
|
||||
table.add((prev[0], prev[1] + 1))
|
||||
prev = (start, len)
|
||||
else:
|
||||
if prev[1] == 0:
|
||||
failInvalid()
|
||||
for i in 0 ..< prev[1]:
|
||||
colorIndexes.add(colorIndexes[prev[0] + i])
|
||||
colorIndexes.add(colorIndexes[prev[0]])
|
||||
table.add((start, prev[1] + 1))
|
||||
prev = (start, prev[1] + 1)
|
||||
|
||||
if colorIndexes.len != imageWidth * imageHeight:
|
||||
failInvalid()
|
||||
|
||||
let image = newImage(imageWidth, imageHeight)
|
||||
|
||||
var transparentColorIndex = -1
|
||||
if (controlExtension.fields and 1) != 0: # Transparent index flag
|
||||
transparentColorIndex = controlExtension.transparentColorIndex.int
|
||||
|
||||
let disposalMethod = (controlExtension.fields and 0b00011100) shr 2
|
||||
if disposalMethod == 2:
|
||||
let frame = newImage(screenWidth, screenHeight)
|
||||
frame.fill(bgColor)
|
||||
result.frames.add(frame)
|
||||
else:
|
||||
if hasLocalColorTable:
|
||||
for i, colorIndex in colorIndexes:
|
||||
if colorIndex >= localColorTable.len:
|
||||
# failInvalid()
|
||||
continue
|
||||
if colorIndex != transparentColorIndex:
|
||||
image.data[i] = localColorTable[colorIndex]
|
||||
else:
|
||||
for i, colorIndex in colorIndexes:
|
||||
if colorIndex >= globalColorTable.len:
|
||||
# failInvalid()
|
||||
continue
|
||||
if colorIndex != transparentColorIndex:
|
||||
image.data[i] = globalColorTable[colorIndex]
|
||||
|
||||
if interlaced:
|
||||
let deinterlaced = newImage(image.width, image.height)
|
||||
var
|
||||
y: int
|
||||
i: int
|
||||
while i < image.height:
|
||||
copyMem(
|
||||
deinterlaced.data[deinterlaced.dataIndex(0, i)].addr,
|
||||
image.data[image.dataIndex(0, y)].addr,
|
||||
image.width * 4
|
||||
)
|
||||
i += 8
|
||||
inc y
|
||||
i = 4
|
||||
while i < image.height:
|
||||
copyMem(
|
||||
deinterlaced.data[deinterlaced.dataIndex(0, i)].addr,
|
||||
image.data[image.dataIndex(0, y)].addr,
|
||||
image.width * 4
|
||||
)
|
||||
i += 8
|
||||
inc y
|
||||
i = 2
|
||||
while i < image.height:
|
||||
copyMem(
|
||||
deinterlaced.data[deinterlaced.dataIndex(0, i)].addr,
|
||||
image.data[image.dataIndex(0, y)].addr,
|
||||
image.width * 4
|
||||
)
|
||||
i += 4
|
||||
inc y
|
||||
i = 1
|
||||
while i < image.height:
|
||||
copyMem(
|
||||
deinterlaced.data[deinterlaced.dataIndex(0, i)].addr,
|
||||
image.data[image.dataIndex(0, y)].addr,
|
||||
image.width * 4
|
||||
)
|
||||
i += 2
|
||||
inc y
|
||||
|
||||
image.data = move deinterlaced.data
|
||||
|
||||
if imageWidth != screenWidth or imageHeight != screenHeight or
|
||||
imageTopPos != 0 or imageLeftPos != 0:
|
||||
let frame = newImage(screenWidth, screenHeight)
|
||||
frame.draw(
|
||||
image,
|
||||
translate(vec2(imageLeftPos.float32, imageTopPos.float32))
|
||||
)
|
||||
result.frames.add(frame)
|
||||
else:
|
||||
result.frames.add(image)
|
||||
|
||||
result.intervals.add(controlExtension.delayTime.float32 / 100)
|
||||
|
||||
# Reset the control extension since it only applies to one image
|
||||
controlExtension = ControlExtension()
|
||||
|
||||
of 0x21: # Extension
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let extensionType = data.readUint8(pos + 0)
|
||||
inc pos
|
||||
|
||||
case extensionType:
|
||||
of 0xf9:
|
||||
# Graphic Control Extension
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let blockSize = data.readUint8(pos).int
|
||||
inc pos
|
||||
|
||||
if blockSize != 4:
|
||||
failInvalid()
|
||||
|
||||
if pos + blockSize > data.len:
|
||||
failInvalid()
|
||||
|
||||
controlExtension.fields = data.readUint8(pos + 0)
|
||||
controlExtension.delayTime = data.readUint16(pos + 1)
|
||||
controlExtension.transparentColorIndex = data.readUint8(pos + 3)
|
||||
|
||||
pos += blockSize
|
||||
inc pos # Block terminator
|
||||
|
||||
of 0xfe:
|
||||
# Comment
|
||||
skipSubBlocks()
|
||||
|
||||
# of 0x01:
|
||||
# # Plain Text
|
||||
|
||||
of 0xff:
|
||||
# Application Specific
|
||||
if pos + 1 > data.len:
|
||||
failInvalid()
|
||||
|
||||
let blockSize = data.readUint8(pos).int
|
||||
inc pos
|
||||
|
||||
if blockSize != 11:
|
||||
failInvalid()
|
||||
|
||||
if pos + blockSize > data.len:
|
||||
failInvalid()
|
||||
|
||||
pos += blockSize
|
||||
|
||||
skipSubBlocks()
|
||||
|
||||
else:
|
||||
raise newException(
|
||||
PixieError,
|
||||
"Unexpected GIF extension type " & toHex(extensionType)
|
||||
)
|
||||
|
||||
of 0x3b: # Trailer
|
||||
break
|
||||
|
||||
else:
|
||||
raise newException(
|
||||
PixieError,
|
||||
"Unexpected GIF block type " & toHex(blockType)
|
||||
)
|
||||
|
||||
for interval in result.intervals:
|
||||
result.duration += interval
|
||||
|
||||
proc decodeGifDimensions*(
|
||||
data: string
|
||||
|
@ -191,5 +383,8 @@ proc decodeGifDimensions*(
|
|||
result.width = data.readInt16(6).int
|
||||
result.height = data.readInt16(8).int
|
||||
|
||||
proc newImage*(gif: Gif): Image {.raises: [].} =
|
||||
gif.frames[0]
|
||||
|
||||
when defined(release):
|
||||
{.pop.}
|
||||
|
|
BIN
tests/fileformats/gif/newtons_cradle.gif
Normal file
BIN
tests/fileformats/gif/newtons_cradle.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 301 KiB |
|
@ -14,14 +14,14 @@ for i in 0 ..< 10_000:
|
|||
data[pos] = value
|
||||
echo &"{i} {pos} {value}"
|
||||
try:
|
||||
let img = decodeGif(data)
|
||||
let img = newImage(decodeGif(data))
|
||||
doAssert img.height > 0 and img.width > 0
|
||||
except PixieError:
|
||||
discard
|
||||
|
||||
data = data[0 ..< pos]
|
||||
try:
|
||||
let img = decodeGif(data)
|
||||
let img = newImage(decodeGif(data))
|
||||
doAssert img.height > 0 and img.width > 0
|
||||
except PixieError:
|
||||
discard
|
||||
|
|
|
@ -3,7 +3,7 @@ import pixie, pixie/fileformats/gif
|
|||
block:
|
||||
let
|
||||
path = "tests/fileformats/gif/3x5.gif"
|
||||
image = decodeGIF(readFile(path))
|
||||
image = readImage(path)
|
||||
dimensions = decodeGifDimensions(readFile(path))
|
||||
image.writeFile("tests/fileformats/gif/3x5.png")
|
||||
doAssert image.width == dimensions.width
|
||||
|
@ -12,7 +12,7 @@ block:
|
|||
block:
|
||||
let
|
||||
path = "tests/fileformats/gif/audrey.gif"
|
||||
image = decodeGIF(readFile(path))
|
||||
image = readImage(path)
|
||||
dimensions = decodeGifDimensions(readFile(path))
|
||||
image.writeFile("tests/fileformats/gif/audrey.png")
|
||||
doAssert image.width == dimensions.width
|
||||
|
@ -21,7 +21,7 @@ block:
|
|||
block:
|
||||
let
|
||||
path = "tests/fileformats/gif/sunflower.gif"
|
||||
image = decodeGIF(readFile(path))
|
||||
image = readImage(path)
|
||||
dimensions = decodeGifDimensions(readFile(path))
|
||||
image.writeFile("tests/fileformats/gif/sunflower.png")
|
||||
doAssert image.width == dimensions.width
|
||||
|
@ -30,8 +30,17 @@ block:
|
|||
block:
|
||||
let
|
||||
path = "tests/fileformats/gif/sunflower.gif"
|
||||
image = decodeGIF(readFile(path))
|
||||
image = readImage(path)
|
||||
dimensions = decodeGifDimensions(readFile(path))
|
||||
image.writeFile("tests/fileformats/gif/sunflower.png")
|
||||
doAssert image.width == dimensions.width
|
||||
doAssert image.height == dimensions.height
|
||||
|
||||
block:
|
||||
let img4 = readImage("tests/fileformats/gif/newtons_cradle.gif")
|
||||
img4.writeFile("tests/fileformats/gif/newtons_cradle.png")
|
||||
|
||||
let animatedGif =
|
||||
decodeGif(readFile("tests/fileformats/gif/newtons_cradle.gif"))
|
||||
doAssert animatedGif.frames.len == 36
|
||||
doAssert animatedGif.intervals.len == animatedGif.frames.len
|
||||
|
|
Loading…
Reference in a new issue