Skip to content

Commit 1a7d5a9

Browse files
authored
Merge pull request #9 from Ekenstein/FrameDelay
Frame delay
2 parents fb3aa05 + 27c4fd0 commit 1a7d5a9

File tree

11 files changed

+335
-52
lines changed

11 files changed

+335
-52
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Options:
1414
--width, -w [1000] -> The width of the image. { Int }
1515
--height, -h [1000] -> The height of the image. { Int }
1616
--move-number, -mn [2147483647] -> The move number up to which the animation will run to. { Int }
17-
--delay, -d [2] -> The delay between frames in seconds. { Int }
17+
--delay, -d [2.0] -> The delay between frames in seconds. { Double }
1818
--help -> Usage info
1919
```
2020

build.gradle.kts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ plugins {
1919
}
2020

2121
group = "com.github.ekenstein"
22-
version = "0.4.2"
22+
version = "0.4.3"
2323

2424
repositories {
2525
mavenCentral()

dist/lib/sgf2gif.jar

17.6 KB
Binary file not shown.

src/main/kotlin/com/github/ekenstein/sgf2gif/AnimatedGifWriter.kt

+10-42
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import javax.imageio.metadata.IIOMetadata
1010
import javax.imageio.metadata.IIOMetadataNode
1111
import javax.imageio.stream.ImageOutputStream
1212
import kotlin.time.Duration
13-
import kotlin.time.DurationUnit
1413

1514
interface GifSequenceWriter {
1615
fun addFrame(image: RenderedImage)
@@ -45,32 +44,18 @@ fun writeGif(outputStream: ImageOutputStream, delay: Duration, loop: Boolean, bl
4544
)
4645

4746
val root = metaData.getAsTree(metaData.nativeMetadataFormatName) as IIOMetadataNode
48-
root.findOrAddNode("GraphicControlExtension").apply {
49-
setAttribute("disposalMethod", "none")
50-
setAttribute("userInputFlag", "FALSE")
51-
setAttribute("transparentColorFlag", "FALSE")
52-
setAttribute("delayTime", (delay.toLong(DurationUnit.MILLISECONDS) / 10).toString())
53-
setAttribute("transparentColorIndex", "0")
54-
}
55-
56-
root.findOrAddNode("CommentExtensions").apply {
57-
setAttribute("CommentExtension", "Created by sgf2gif")
58-
}
59-
60-
if (loop) {
61-
val appExtensionsNode = root.findOrAddNode("ApplicationExtensions")
62-
val child = IIOMetadataNode("ApplicationExtension").apply {
63-
setAttribute("applicationID", "NETSCAPE")
64-
setAttribute("authenticationCode", "2.0")
47+
GifMetadata(root).apply {
48+
graphicControlExtension.apply {
49+
disposalMethod = DisposalMethod.None
50+
userInputFlag = false
51+
transparentColorFlag = false
52+
delayTime = delay
53+
transparentColorIndex = 0
6554
}
6655

67-
child.userObject = byteArrayOf(
68-
0x1,
69-
(0 and 0xFF).toByte(),
70-
(0 shr 8 and 0xFF).toByte()
71-
)
72-
73-
appExtensionsNode.appendChild(child)
56+
if (loop) {
57+
setLooping()
58+
}
7459
}
7560

7661
metaData.setFromTree(metaData.nativeMetadataFormatName, root)
@@ -80,20 +65,3 @@ fun writeGif(outputStream: ImageOutputStream, delay: Duration, loop: Boolean, bl
8065
writer.dispose()
8166
outputStream.flush()
8267
}
83-
84-
private fun IIOMetadataNode.findOrAddNode(name: String) = nodes.firstOrNull { it.nodeName.equals(name, true) }
85-
?: addNode(name)
86-
87-
private fun IIOMetadataNode.addNode(name: String): IIOMetadataNode {
88-
val node = IIOMetadataNode(name)
89-
appendChild(node)
90-
return node
91-
}
92-
93-
private val IIOMetadataNode.nodes
94-
get() = sequence {
95-
for (i in 0 until length) {
96-
val item = item(i) as IIOMetadataNode
97-
yield(item)
98-
}
99-
}

src/main/kotlin/com/github/ekenstein/sgf2gif/BoardTheme.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import com.github.ekenstein.sgf.editor.placeStone
99
import java.awt.Graphics2D
1010
import java.awt.image.BufferedImage
1111
import javax.imageio.stream.ImageOutputStream
12-
import kotlin.time.Duration.Companion.seconds
1312

1413
data class Stone(val point: SgfPoint, val color: SgfColor)
1514

@@ -23,7 +22,7 @@ fun BoardTheme.render(
2322
outputStream: ImageOutputStream,
2423
options: Options
2524
) {
26-
writeGif(outputStream, options.delay.seconds, options.loop) {
25+
writeGif(outputStream, options.delayBetweenFrames, options.loop) {
2726
val board = options.sgf.goToRootNode().extractBoard()
2827
val boardImage = image(options.width, options.height) { g ->
2928
drawEmptyBoard(g)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package com.github.ekenstein.sgf2gif
2+
3+
import java.io.InputStream
4+
import javax.imageio.ImageIO
5+
import javax.imageio.metadata.IIOMetadataNode
6+
import kotlin.time.Duration
7+
import kotlin.time.Duration.Companion.milliseconds
8+
import kotlin.time.DurationUnit
9+
10+
private const val ATTRIBUTE_DELAY_TIME = "delayTime"
11+
private const val ATTRIBUTE_TRANSPARENT_COLOR_INDEX = "transparentColorIndex"
12+
private const val ATTRIBUTE_TRANSPARENT_COLOR_FLAG = "transparentColorFlag"
13+
private const val ATTRIBUTE_USER_INPUT_FLAG = "userInputFlag"
14+
private const val ATTRIBUTE_DISPOSAL_METHOD = "disposalMethod"
15+
private const val ATTRIBUTE_APPLICATION_ID = "applicationID"
16+
private const val ATTRIBUTE_AUTHENTICATION_CODE = "authenticationCode"
17+
private const val NODE_APPLICATION_EXTENSIONS = "ApplicationExtensions"
18+
private const val NODE_GRAPHIC_CONTROL_EXTENSION = "GraphicControlExtension"
19+
private const val NODE_APPLICATION_EXTENSION = "ApplicationExtension"
20+
21+
class GifMetadata(private val rootNode: IIOMetadataNode) {
22+
val applicationExtensions: ApplicationExtensions
23+
get() = ApplicationExtensions(findOrAddNode(NODE_APPLICATION_EXTENSIONS))
24+
25+
val graphicControlExtension: GraphicControlExtension
26+
get() = GraphicControlExtension(findOrAddNode(NODE_GRAPHIC_CONTROL_EXTENSION))
27+
28+
fun setLooping() {
29+
applicationExtensions.addApplicationExtension {
30+
applicationId = "NETSCAPE"
31+
authenticationCode = "2.0"
32+
userObject = byteArrayOf(
33+
0x1,
34+
(0 and 0xFF).toByte(),
35+
(0 shr 8 and 0xFF).toByte()
36+
)
37+
}
38+
}
39+
40+
private fun findOrAddNode(name: String) = getNodes().firstOrNull { it.nodeName.equals(name, true) }
41+
?: addNode(name)
42+
43+
private fun addNode(name: String): IIOMetadataNode {
44+
val node = IIOMetadataNode(name)
45+
rootNode.appendChild(node)
46+
return node
47+
}
48+
49+
private fun getNodes() = sequence {
50+
for (i in 0 until rootNode.length) {
51+
val item = rootNode.item(i) as IIOMetadataNode
52+
yield(item)
53+
}
54+
}
55+
56+
companion object {
57+
fun fromInputStream(inputStream: InputStream): GifMetadata {
58+
val imageReader = ImageIO.getImageReadersBySuffix("gif").next()
59+
?: error("Failed to get an image reader for GIF")
60+
61+
imageReader.input = ImageIO.createImageInputStream(inputStream)
62+
63+
val imageMetadata = imageReader.getImageMetadata(0)
64+
val node = imageMetadata.getAsTree(imageMetadata.nativeMetadataFormatName) as IIOMetadataNode
65+
return GifMetadata(node)
66+
}
67+
}
68+
}
69+
70+
class ApplicationExtensions(private val node: IIOMetadataNode) {
71+
fun addApplicationExtension(block: ApplicationExtension.() -> Unit) {
72+
val child = IIOMetadataNode(NODE_APPLICATION_EXTENSION)
73+
ApplicationExtension(child).apply(block)
74+
node.appendChild(child)
75+
}
76+
77+
fun getApplicationExtensions() = node.children.map(::ApplicationExtension)
78+
}
79+
80+
class ApplicationExtension(private val node: IIOMetadataNode) {
81+
var applicationId: String
82+
get() = node.getAttribute(ATTRIBUTE_APPLICATION_ID)
83+
set(value) {
84+
node.setAttribute(ATTRIBUTE_APPLICATION_ID, value)
85+
}
86+
87+
var authenticationCode: String
88+
get() = node.getAttribute(ATTRIBUTE_AUTHENTICATION_CODE)
89+
set(value) {
90+
node.setAttribute(ATTRIBUTE_AUTHENTICATION_CODE, value)
91+
}
92+
93+
var userObject: ByteArray?
94+
get() = node.userObject as? ByteArray
95+
set(value) {
96+
node.userObject = value
97+
}
98+
}
99+
100+
class GraphicControlExtension(private val node: IIOMetadataNode) {
101+
/**
102+
* The time to delay between frames
103+
*/
104+
var delayTime: Duration
105+
get() {
106+
val stringValue = node.getAttribute(ATTRIBUTE_DELAY_TIME)
107+
val longValue = stringValue.toLongOrNull()
108+
?: 0
109+
110+
val milliseconds = longValue * 10
111+
return milliseconds.milliseconds
112+
}
113+
set(value) {
114+
val valueInMs = value.toLong(DurationUnit.MILLISECONDS) / 10
115+
node.setAttribute(ATTRIBUTE_DELAY_TIME, valueInMs.toString())
116+
}
117+
118+
/**
119+
* True if the frame should be advanced based on user input
120+
*/
121+
var userInputFlag: Boolean
122+
get() = node.getAttribute(ATTRIBUTE_USER_INPUT_FLAG).toBooleanStrictOrNull()
123+
?: false
124+
set(value) {
125+
node.setAttribute(ATTRIBUTE_USER_INPUT_FLAG, value.toString().uppercase())
126+
}
127+
128+
/**
129+
* True if a transparent color exists
130+
*/
131+
var transparentColorFlag: Boolean
132+
get() = node.getAttribute(ATTRIBUTE_TRANSPARENT_COLOR_FLAG).toBooleanStrictOrNull()
133+
?: false
134+
set(value) {
135+
node.setAttribute(ATTRIBUTE_TRANSPARENT_COLOR_FLAG, value.toString().uppercase())
136+
}
137+
138+
/**
139+
* The transparent color, if transparentColorFlag is true.
140+
* Min value: 0 (inclusive)
141+
* Max value: 255 (inclusive)
142+
*/
143+
var transparentColorIndex: Int
144+
get() = node.getAttribute(ATTRIBUTE_TRANSPARENT_COLOR_INDEX).toIntOrNull()
145+
?: 0
146+
set(value) {
147+
node.setAttribute(ATTRIBUTE_TRANSPARENT_COLOR_INDEX, value.toString())
148+
}
149+
150+
/**
151+
* The disposal method for this frame
152+
*/
153+
var disposalMethod: DisposalMethod
154+
get() {
155+
val allDisposalMethods = DisposalMethod.entries.associateBy { it.asString }
156+
val value = node.getAttribute(ATTRIBUTE_DISPOSAL_METHOD)
157+
return allDisposalMethods[value]
158+
?: DisposalMethod.None
159+
}
160+
set(value) {
161+
node.setAttribute(ATTRIBUTE_DISPOSAL_METHOD, value.asString)
162+
}
163+
}
164+
165+
enum class DisposalMethod {
166+
None,
167+
DoNotDispose,
168+
RestoreToBackgroundColor,
169+
RestoreToPrevious,
170+
UndefinedDisposalMethod4,
171+
UndefinedDisposalMethod5,
172+
UndefinedDisposalMethod6,
173+
UndefinedDisposalMethod7
174+
}
175+
176+
private val DisposalMethod.asString
177+
get() = when (this) {
178+
DisposalMethod.None -> "none"
179+
DisposalMethod.DoNotDispose -> "doNotDispose"
180+
DisposalMethod.RestoreToBackgroundColor -> "restoreToBackgroundColor"
181+
DisposalMethod.RestoreToPrevious -> "restoreToPrevious"
182+
DisposalMethod.UndefinedDisposalMethod4 -> "undefinedDisposalMethod4"
183+
DisposalMethod.UndefinedDisposalMethod7 -> "undefinedDisposalMethod7"
184+
DisposalMethod.UndefinedDisposalMethod5 -> "undefinedDisposalMethod5"
185+
DisposalMethod.UndefinedDisposalMethod6 -> "undefinedDisposalMethod6"
186+
}
187+
188+
private val IIOMetadataNode.children get() = sequence {
189+
for (i in 0 until childNodes.length) {
190+
val childNode = childNodes.item(i) as IIOMetadataNode
191+
yield(childNode)
192+
}
193+
}

src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt

+8-3
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import kotlinx.cli.default
1919
import java.io.File
2020
import java.io.InputStream
2121
import java.nio.file.InvalidPathException
22+
import kotlin.time.Duration.Companion.seconds
2223

2324
const val DEFAULT_WIDTH = 1000
2425
const val DEFAULT_HEIGHT = 1000
25-
const val DEFAULT_DELAY_IN_SECONDS = 2
26+
const val DEFAULT_DELAY_IN_SECONDS = 2.0
2627
const val DEFAULT_SHOW_MARKER = false
2728
const val DEFAULT_LOOP = false
2829

@@ -81,13 +82,17 @@ class Options private constructor(parser: ArgParser) {
8182
description = "The move number up to which the animation will run to."
8283
).default(Int.MAX_VALUE)
8384

84-
val delay by parser.option(
85-
type = ArgType.Int,
85+
private val delay by parser.option(
86+
type = ArgType.Double,
8687
fullName = "delay",
8788
shortName = "d",
8889
description = "The delay between frames in seconds."
8990
).default(DEFAULT_DELAY_IN_SECONDS)
9091

92+
val delayBetweenFrames by lazy {
93+
delay.seconds
94+
}
95+
9196
val sgf by lazy {
9297
val sgf = when (val file = inputFile) {
9398
null -> readSgf(System.`in`)

src/test/kotlin/com/github/ekenstein/sgf2gif/GenerationTest.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ class GenerationTest {
2121

2222
@Test
2323
fun `generate NES themed gif`() {
24-
val data = generate(arrayOf("--theme", "NES", "--show-marker"))
24+
val data = generate(arrayOf("--theme", "NES", "--show-marker", "--loop"))
2525
val file = FileOutputStream("C:\\temp\\nes.gif")
2626

2727
data.writeTo(file)
2828
}
2929

3030
@Test
3131
fun `generate classic themed gif`() {
32-
val data = generate(arrayOf("--theme", "classic", "--show-marker"))
32+
val data = generate(arrayOf("--theme", "classic", "--show-marker", "--delay", "0.1"))
3333
val file = FileOutputStream("C:\\temp\\classic.gif")
3434

3535
data.writeTo(file)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.github.ekenstein.sgf2gif
2+
3+
import java.io.ByteArrayInputStream
4+
import java.io.InputStream
5+
import javax.imageio.ImageIO
6+
import javax.imageio.ImageReader
7+
import javax.imageio.metadata.IIOMetadataNode
8+
9+
class GifImage private constructor(private val imageReader: ImageReader) {
10+
private val imageMetaData by lazy {
11+
val imageMetadata = imageReader.getImageMetadata(0)
12+
val node = imageMetadata.getAsTree(imageMetadata.nativeMetadataFormatName) as IIOMetadataNode
13+
GifMetadata(node)
14+
}
15+
16+
val numberOfFrames: Int by lazy {
17+
imageReader.getNumImages(true)
18+
}
19+
20+
companion object {
21+
fun fromStream(inputStream: InputStream): GifImage {
22+
val imageReader = ImageIO.getImageReadersBySuffix("gif").next()
23+
?: error("Failed to get an image reader for GIF")
24+
25+
imageReader.input = ImageIO.createImageInputStream(inputStream)
26+
return GifImage(imageReader)
27+
}
28+
29+
fun fromByteArray(bytes: ByteArray) = fromStream(ByteArrayInputStream(bytes))
30+
}
31+
}

0 commit comments

Comments
 (0)