├── .gitignore
├── GIF_DEMO_IN-APP.gif
├── images
├── frozen1.gif
├── frozen2.gif
├── frozen3.gif
├── frozen4.gif
├── frozen5.gif
├── frozen6.gif
├── frozen7.gif
├── frozen8.gif
├── frozen9.gif
└── channel_store_icon-hd.jpg
├── source
└── main.brs
├── components
├── FrameAnimator.xml
├── AppScene.xml
├── GIFDecoder.xml
├── FrameAnimator.brs
├── AppScene.brs
└── GIFDecoder.brs
├── manifest
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | out/
3 |
--------------------------------------------------------------------------------
/GIF_DEMO_IN-APP.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/GIF_DEMO_IN-APP.gif
--------------------------------------------------------------------------------
/images/frozen1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen1.gif
--------------------------------------------------------------------------------
/images/frozen2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen2.gif
--------------------------------------------------------------------------------
/images/frozen3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen3.gif
--------------------------------------------------------------------------------
/images/frozen4.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen4.gif
--------------------------------------------------------------------------------
/images/frozen5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen5.gif
--------------------------------------------------------------------------------
/images/frozen6.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen6.gif
--------------------------------------------------------------------------------
/images/frozen7.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen7.gif
--------------------------------------------------------------------------------
/images/frozen8.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen8.gif
--------------------------------------------------------------------------------
/images/frozen9.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/frozen9.gif
--------------------------------------------------------------------------------
/images/channel_store_icon-hd.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/acotilla91/Animated-GIF-Roku-Demo/HEAD/images/channel_store_icon-hd.jpg
--------------------------------------------------------------------------------
/source/main.brs:
--------------------------------------------------------------------------------
1 | sub main(args as dynamic)
2 | screen = CreateObject("roSGScreen")
3 | scene = screen.createScene("AppScene")
4 | screen.show()
5 |
6 | while true
7 | end while
8 | end sub
--------------------------------------------------------------------------------
/components/FrameAnimator.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/components/AppScene.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/manifest:
--------------------------------------------------------------------------------
1 | title=Animated GIF Roku Demo
2 | major_version=1
3 | minor_version=0
4 | build_version=0
5 | splash_min_time=0
6 | splash_rsg_optimization=1
7 | mm_icon_focus_hd=pkg:/images/channel_store_icon-hd.jpg
8 | ui_resolutions=fhd
9 | uri_resolution_autosub=$$RES$$,sd,hd,fhd
10 |
--------------------------------------------------------------------------------
/components/GIFDecoder.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Animated GIFs support on Roku with SceneGraph
3 |
4 | This demo app showcases how to decode and display animated GIF files on Roku using the SceneGraph SDK.
5 |
6 | 
7 |
8 | ## Running the app
9 | 1. Clone this repo
10 | ```
11 | $ git clone https://github.com/acotilla91/Animated-GIF-Roku-Demo.git
12 | ```
13 | 2. Open project.
14 | 3. Side-load the app.
15 | 4. Use the remote control arrows to move through the grid.
16 | 5. The focused poster should start animating.
17 |
--------------------------------------------------------------------------------
/components/FrameAnimator.brs:
--------------------------------------------------------------------------------
1 | sub init()
2 | m.animator = CreateObject("roSGNode", "Timer")
3 | m.animator.ObserveField("fire", "displayNextFrame")
4 | m.animator.repeat = true
5 |
6 | m.frames = []
7 | m.frameIndex = -1
8 | m.poster = invalid
9 | end sub
10 |
11 | function start(frames as Object, fps as Float, poster as Object)
12 | m.frames = frames
13 | m.poster = poster
14 |
15 | m.animator.duration = fps
16 | m.animator.control = "start"
17 | end function
18 |
19 | function finish()
20 | m.animator.control = "stop"
21 |
22 | ' Restore first frame
23 | if m.frames.count() > 0 m.poster.uri = m.frames[0]
24 |
25 | m.frameIndex = -1
26 | m.frames = []
27 | m.poster = invalid
28 | end function
29 |
30 | sub displayNextFrame()
31 | m.frameIndex++
32 | if m.frameIndex >= m.frames.count()
33 | m.frameIndex = 0
34 | end if
35 |
36 | m.poster.uri = m.frames[m.frameIndex]
37 | end sub
38 |
--------------------------------------------------------------------------------
/components/AppScene.brs:
--------------------------------------------------------------------------------
1 | sub init()
2 | m.animator = createObject("roSGNode", "FrameAnimator")
3 | m.decoder = createObject("roSGNode", "GIFDecoder")
4 | m.decoder.delegate = m.top
5 |
6 | m.top.setFocus(true)
7 | m.top.backgroundURI = ""
8 | m.top.backgroundColor = "#292C34"
9 |
10 | setupPosterGrid()
11 | setupFocusBorder()
12 | focusItem(0)
13 | end sub
14 |
15 | sub setupPosterGrid()
16 | m.posterGrid = m.top.createChild("Group")
17 |
18 | ' Define layout properties
19 | m.itemsPerRow = 3
20 | m.itemsPerColumn = 3
21 | m.totalItems = m.itemsPerRow * m.itemsPerColumn
22 | m.itemWidth = 528
23 | m.itemHeight = 221
24 | itemSpacing = 25
25 |
26 | ' Create all the posters
27 | for i = 0 to m.totalItems - 1
28 | row = Int(i / m.itemsPerRow)
29 | column = i - (m.itemsPerRow * row)
30 | x = m.itemWidth * column + itemSpacing * column
31 | y = m.itemHeight * row + itemSpacing * row
32 |
33 | poster = m.posterGrid.createChild("Poster")
34 | poster.loadSync = true
35 | poster.loadDisplayMode = "scaleToFit"
36 | poster.uri = getGIFUrl(i)
37 | poster.translation = [x, y]
38 | poster.width = m.itemWidth
39 | poster.height = m.itemHeight
40 | end for
41 |
42 | ' Center in the screen
43 | totalWidth = m.itemWidth * m.itemsPerRow + itemSpacing * (m.itemsPerRow - 1)
44 | totalHeight = m.itemHeight * m.itemsPerColumn + itemSpacing * (m.itemsPerColumn - 1)
45 | m.posterGrid.translation = [1920/2 - totalWidth/2, 1080/2 - totalHeight/2]
46 | end sub
47 |
48 | sub setupFocusBorder()
49 | m.focusBorder = createObject("roSGNode", "Rectangle")
50 | m.top.insertChild(m.focusBorder, 0)
51 | m.focusBorder.color = "#ffffff"
52 | m.focusBorder.width = m.itemWidth + 9
53 | m.focusBorder.height = m.itemHeight + 9
54 | end sub
55 |
56 | function onKeyEvent(key as String, press as Boolean) as Boolean
57 | if key = "left" and not press
58 | focusItem(m.focusedItem - 1)
59 | else if key = "up" and not press
60 | focusItem(m.focusedItem - m.itemsPerRow)
61 | else if key = "right" and not press
62 | focusItem(m.focusedItem + 1)
63 | else if key = "down" and not press
64 | focusItem(m.focusedItem + m.itemsPerRow)
65 | end if
66 | return true
67 | end function
68 |
69 | sub focusItem(item as Integer)
70 | decoderReady = m.decoder.state = "init" or m.decoder.state = "stop"
71 | if item < 0 or item >= m.totalItems or m.focusedItem = item or not decoderReady
72 | return
73 | end if
74 |
75 | ' Re-position focus border
76 | m.focusedItem = item
77 | alignNodeToNodeCenter(m.focusBorder, m.posterGrid.getChild(item))
78 |
79 | ' Start decoder
80 | m.decoder.callFunc("decodeGIF", getGIFUrl(item))
81 |
82 | ' Stop previous poster animation
83 | m.animator.callFunc("finish")
84 | end sub
85 |
86 | sub alignNodeToNodeCenter(node, sibling)
87 | siblingRect = sibling.sceneBoundingRect()
88 | node.translation = [siblingRect.x + sibling.width/2 - node.width/2, siblingRect.y + sibling.height/2 - node.height/2]
89 | end sub
90 |
91 | function getGIFUrl(posterIndex as Integer) as String
92 | return "pkg:/images/frozen" + (posterIndex + 1).toStr() + ".gif"
93 | end function
94 |
95 | sub gifDecoderDidFinish(frames as Object, fps as Float)
96 | m.animator.callFunc("start", frames, fps, m.posterGrid.getChild(m.focusedItem))
97 | end sub
98 |
--------------------------------------------------------------------------------
/components/GIFDecoder.brs:
--------------------------------------------------------------------------------
1 | ' This file takes in a gif, extracts individual frames from the gif and saves each frame in memory.
2 | ' NOTE: Gif files that use inter-frame coalescence will not work, only overlay (overwrite graphic) will work.
3 |
4 | ' Anatomy of a gif file:
5 | ' Header And Global Color Info:
6 | ' Header (Type of gif format)
7 | ' Logical Screen Descriptor (width, height, color resolution, background color, aspect ratio, color table size)
8 | ' Global Color Table (color palette used for the entire file)
9 | ' Extension Information:
10 | ' Comment Extension (text field for a comment)
11 | ' Application Extension (app id (text), number of iterations)
12 | ' Graphic Control Extension - comes before each frame and can be customized for each frame. (transparent color flag, user input flag, disposal method, delay time of frame, transparent color in palette)
13 | ' Image Frame(n) Info:
14 | ' Local Image Descriptor (margin left, margin top, width, height, interlaced flag, color table sorted flag, minimum code size)
15 | ' Local Color Table (same as global color table, but for just that frame)
16 | ' Image Data (non-meta data bytes to draw image)
17 | ' Extension Information (after all the image frames)
18 | ' Comment Section (text field for a comment)
19 | ' Plain-Text Section - text to be rendered on top of the image (margin left, margin top, text width, text height, text color, text bg color, text)
20 | ' Trailer (indicating EOF)
21 |
22 | ' Useful links:
23 | ' https://www.cs.albany.edu/~sdc/CSI333/Fal07/Lect/L18/Summary.html
24 | ' http://web.cecs.pdx.edu/~harry/compilers/ASCIIChart.pdf
25 | ' https://github.com/TheNeoBurn/GifWrapper - program that will help look at meta info of blocks
26 |
27 | sub decodeGIF(gifPath as String)
28 | m.top.functionName = "runDecoder"
29 |
30 | m.gifPath = gifPath
31 | m.gifName = CreateObject("roPath", m.gifPath).split().basename
32 | m.top.control = "RUN"
33 | end sub
34 |
35 | sub runDecoder()
36 | ' Load the main GIF into memory.
37 | ' @NOTE: For big GIFs, the bytes of the GIF should be read as needed using:
38 | ' `ReadFile(path as String, start_pos as Integer, length as Integer) As Boolean`.
39 | gifBytes = CreateObject("roByteArray")
40 | gifBytes.readFile(m.gifPath)
41 |
42 | ' Read the header block bytes. The header block always has a length of 6 bytes.
43 | ' Ensure the file is a supported gif file.
44 | headerBytes = subByteArrayFrom(gifBytes, 0, 6)
45 | header = headerBytes.toAsciiString()
46 | if header <> "GIF89a"
47 | ' Handle unsupported file errors.
48 | return
49 | end if
50 |
51 | ' Get the logical screen descriptor.
52 | logicalScreenDescriptorBytes = subByteArrayFrom(gifBytes, 6, 7)
53 |
54 | ' Get the global color table bytes (if available)
55 | globalColorTableBytes = invalid
56 | globalColorTableSize = colorTableSize(gifBytes, 6, true)
57 | if globalColorTableSize
58 | ' The global color table follows the logical screen descriptor
59 | globalColorTableBytes = subByteArrayFrom(gifBytes, 13, globalColorTableSize)
60 | end if
61 |
62 | ' Concatenate common bytes
63 | gifFrameCommonBytes = createObject("roByteArray")
64 | gifFrameCommonBytes.append(headerBytes)
65 | gifFrameCommonBytes.append(logicalScreenDescriptorBytes)
66 |
67 | ' The trailer byte that will be always appended to each individual GIF.
68 | trailerByte = createObject("roByteArray")
69 | trailerByte.FromHexString("3b")
70 |
71 | ' Capture all the frames in the GIF and store them as individual GIFs.
72 | frames = []
73 | frameNumber = 0
74 | totalDuration = 0.0
75 | byteIndex = 13 + globalColorTableSize
76 | while byteIndex < gifBytes.count()
77 | increment = 1
78 |
79 | hexVal = StrI(gifBytes[byteIndex], 16)
80 | if hexVal = "21" ' Extension block introducer
81 | extensionLabel = StrI(gifBytes[byteIndex + 1], 16)
82 | if extensionLabel = "ff" ' Application extension (will be ignored)
83 | ' Skip the application extension block.
84 | ' The application extension block has a fixed size of 19
85 | increment = 19
86 | else if extensionLabel = "fe" ' Comment extension (will be ignored)
87 | ' Skip the comment extension block.
88 | ' The comment extension block ends when a zero-value byte is found.
89 | commentExtensionLastByteIndex = byteIndex + 2
90 | while (gifBytes[commentExtensionLastByteIndex] > 0)
91 | commentExtensionLastByteIndex+= gifBytes[commentExtensionLastByteIndex] + 1
92 | end while
93 | increment = commentExtensionLastByteIndex - byteIndex + 1
94 | else if extensionLabel = "01" ' Plain text extension (will be ignored)
95 | ' @TODO: skip all the plain text extension bytes
96 | exit while
97 | else if extensionLabel = "f9" ' Graphic control extension
98 | ' The fith byte in the graphic control extension block has the delay time of the next frame.
99 | ' This value is represented as hundredths (1/100) of a second.
100 | ' @NOTE: The gif spec refers to the fith and the sixth byte but in all the examples only the fith
101 | ' value is taken into account, not the sixth.
102 | delayTime = gifBytes[byteIndex + 4] / 100.0
103 | totalDuration+= delayTime
104 |
105 | graphicControlBytes = subByteArrayFrom(gifBytes, byteIndex, 8)
106 |
107 | ' Setting the background to transparent, disposal method to RestoreToBGColor (00001001)
108 | graphicControlBytes[3] = 9
109 |
110 | ' The graphic control extension block has a fixed size of 8
111 | increment = 8
112 | else
113 | ' Handle invalid extension label error.
114 | exit while
115 | end if
116 | else if hexVal = "2c" ' Image descriptor block
117 | ' Get the local color table size so that we can know where the image data starts
118 | localColorTableInfoByteIndex = byteIndex + 9 ' The packed field with the local table info is always in the 10th byte.
119 | localColorTableSize = colorTableSize(gifBytes, byteIndex)
120 |
121 | ' Determine the image descriptor + the image data size
122 | imageDataByteStartIndex = localColorTableInfoByteIndex + localColorTableSize + 1
123 | imageDataByteEndIndex = imageDataByteStartIndex + 1
124 | while (gifBytes[imageDataByteEndIndex] > 0)
125 | imageDataByteEndIndex+= gifBytes[imageDataByteEndIndex] + 1
126 | end while
127 | imageDescriptorAndDataSize = imageDataByteEndIndex - byteIndex + 1
128 |
129 | ' Create the new gif file for this frame with the common bytes
130 | gifFramePath = "tmp:/" + m.gifName + "_" + frameNumber.toStr() + ".gif"
131 | gifFrameCommonBytes.writeFile(gifFramePath)
132 |
133 | ' Append the global color table only if there's no local color table for this frame
134 | if localColorTableSize = 0 and globalColorTableBytes <> invalid
135 | globalColorTableBytes.appendFile(gifFramePath, 0, globalColorTableSize)
136 | end if
137 |
138 | ' Append the graphic control extension
139 | if graphicControlBytes <> invalid then graphicControlBytes.appendFile(gifFramePath, 0, 8)
140 |
141 | ' Append the image data of this frame
142 | gifBytes.appendFile(gifFramePath, byteIndex, imageDescriptorAndDataSize)
143 | trailerByte.appendFile(gifFramePath, 0, 1)
144 |
145 | ' Save new gif url
146 | frames.push(gifFramePath)
147 | frameNumber++
148 |
149 | ' Go to the next block of data in the next interation
150 | increment = imageDataByteEndIndex - byteIndex + 1
151 | else if hexVal = "3b" ' Trailer (should be the last byte in a gif file)
152 | exit while
153 | end if
154 |
155 | byteIndex+= increment
156 | end while
157 |
158 | ' Notify delegate
159 | fps = totalDuration / frames.count()
160 | m.top.delegate.callFunc("gifDecoderDidFinish", frames, fps)
161 | end sub
162 |
163 | ' Locates the color table based on the descriptor information and returns its size (if a color table is present).
164 | function colorTableSize(gifBytes as Object, descriptorLocation as Integer, global = false as Boolean) as Integer
165 | size = 0
166 |
167 | ' For the case of image descriptors the packed field with the table info is always in the 10th byte
168 | ' and for the case of the logical screen descriptor is always in the 5th byte.
169 | packedFieldLocation = descriptorLocation + 9
170 | if global packedFieldLocation = descriptorLocation + 4
171 |
172 | ' The color table information is meant to be interpret in is binary (8-bit) representation.
173 | colorTableInfoBits = decimalTo8Bit(gifBytes[packedFieldLocation])
174 |
175 | ' Check if there is a color table by checking the first bit of the color table info bits.
176 | if colorTableInfoBits.left(1) = "1"
177 | ' The bits 1...3 represent the number of bits used for each color table entry minus one.
178 | bitsPerEntry = Val(colorTableInfoBits.right(3), 2) + 1
179 |
180 | ' The number of colors in the table can be calculated as: `2^(bitsPerEntry)`.
181 | ' Which means that the size of the table would be 3*2^(bitsPerEntry).
182 | size = 3 * pow(2, bitsPerEntry)
183 | end if
184 |
185 | return size
186 | end function
187 |
188 | ' Returns a slice of the given array
189 | function subByteArrayFrom(byteArray as Object, location as Integer, length as Integer) as Object
190 | newArray = CreateObject("roByteArray")
191 | for i = location to location + length - 1
192 | newArray.push(byteArray[i])
193 | end for
194 | return newArray
195 | end function
196 |
197 | ' Converts the given decimal to its 8-bit representation
198 | function decimalTo8Bit(decimal as Integer) as String
199 | return ("0000000" + StrI(decimal, 2)).right(8)
200 | end function
201 |
202 | ' Returns a number raised to a given power.
203 | function pow(x as Float, y as Integer) as Float
204 | if y = 0 then return 1
205 |
206 | temp = pow(x, y/2)
207 | if y mod 2 = 0 then return temp * temp
208 | if y > 0 then return x * temp * temp
209 |
210 | return (temp * temp) / x
211 | end function
212 |
--------------------------------------------------------------------------------