Skip to content

Commit

Permalink
Merge pull request #9 from Ekenstein/FrameDelay
Browse files Browse the repository at this point in the history
Frame delay
  • Loading branch information
Ekenstein authored Jan 1, 2024
2 parents fb3aa05 + 27c4fd0 commit 1a7d5a9
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 52 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Options:
--width, -w [1000] -> The width of the image. { Int }
--height, -h [1000] -> The height of the image. { Int }
--move-number, -mn [2147483647] -> The move number up to which the animation will run to. { Int }
--delay, -d [2] -> The delay between frames in seconds. { Int }
--delay, -d [2.0] -> The delay between frames in seconds. { Double }
--help -> Usage info
```
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ plugins {
}

group = "com.github.ekenstein"
version = "0.4.2"
version = "0.4.3"

repositories {
mavenCentral()
Expand Down
Binary file modified dist/lib/sgf2gif.jar
Binary file not shown.
52 changes: 10 additions & 42 deletions src/main/kotlin/com/github/ekenstein/sgf2gif/AnimatedGifWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import javax.imageio.metadata.IIOMetadata
import javax.imageio.metadata.IIOMetadataNode
import javax.imageio.stream.ImageOutputStream
import kotlin.time.Duration
import kotlin.time.DurationUnit

interface GifSequenceWriter {
fun addFrame(image: RenderedImage)
Expand Down Expand Up @@ -45,32 +44,18 @@ fun writeGif(outputStream: ImageOutputStream, delay: Duration, loop: Boolean, bl
)

val root = metaData.getAsTree(metaData.nativeMetadataFormatName) as IIOMetadataNode
root.findOrAddNode("GraphicControlExtension").apply {
setAttribute("disposalMethod", "none")
setAttribute("userInputFlag", "FALSE")
setAttribute("transparentColorFlag", "FALSE")
setAttribute("delayTime", (delay.toLong(DurationUnit.MILLISECONDS) / 10).toString())
setAttribute("transparentColorIndex", "0")
}

root.findOrAddNode("CommentExtensions").apply {
setAttribute("CommentExtension", "Created by sgf2gif")
}

if (loop) {
val appExtensionsNode = root.findOrAddNode("ApplicationExtensions")
val child = IIOMetadataNode("ApplicationExtension").apply {
setAttribute("applicationID", "NETSCAPE")
setAttribute("authenticationCode", "2.0")
GifMetadata(root).apply {
graphicControlExtension.apply {
disposalMethod = DisposalMethod.None
userInputFlag = false
transparentColorFlag = false
delayTime = delay
transparentColorIndex = 0
}

child.userObject = byteArrayOf(
0x1,
(0 and 0xFF).toByte(),
(0 shr 8 and 0xFF).toByte()
)

appExtensionsNode.appendChild(child)
if (loop) {
setLooping()
}
}

metaData.setFromTree(metaData.nativeMetadataFormatName, root)
Expand All @@ -80,20 +65,3 @@ fun writeGif(outputStream: ImageOutputStream, delay: Duration, loop: Boolean, bl
writer.dispose()
outputStream.flush()
}

private fun IIOMetadataNode.findOrAddNode(name: String) = nodes.firstOrNull { it.nodeName.equals(name, true) }
?: addNode(name)

private fun IIOMetadataNode.addNode(name: String): IIOMetadataNode {
val node = IIOMetadataNode(name)
appendChild(node)
return node
}

private val IIOMetadataNode.nodes
get() = sequence {
for (i in 0 until length) {
val item = item(i) as IIOMetadataNode
yield(item)
}
}
3 changes: 1 addition & 2 deletions src/main/kotlin/com/github/ekenstein/sgf2gif/BoardTheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.github.ekenstein.sgf.editor.placeStone
import java.awt.Graphics2D
import java.awt.image.BufferedImage
import javax.imageio.stream.ImageOutputStream
import kotlin.time.Duration.Companion.seconds

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

Expand All @@ -23,7 +22,7 @@ fun BoardTheme.render(
outputStream: ImageOutputStream,
options: Options
) {
writeGif(outputStream, options.delay.seconds, options.loop) {
writeGif(outputStream, options.delayBetweenFrames, options.loop) {
val board = options.sgf.goToRootNode().extractBoard()
val boardImage = image(options.width, options.height) { g ->
drawEmptyBoard(g)
Expand Down
193 changes: 193 additions & 0 deletions src/main/kotlin/com/github/ekenstein/sgf2gif/GifMetadata.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package com.github.ekenstein.sgf2gif

import java.io.InputStream
import javax.imageio.ImageIO
import javax.imageio.metadata.IIOMetadataNode
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit

private const val ATTRIBUTE_DELAY_TIME = "delayTime"
private const val ATTRIBUTE_TRANSPARENT_COLOR_INDEX = "transparentColorIndex"
private const val ATTRIBUTE_TRANSPARENT_COLOR_FLAG = "transparentColorFlag"
private const val ATTRIBUTE_USER_INPUT_FLAG = "userInputFlag"
private const val ATTRIBUTE_DISPOSAL_METHOD = "disposalMethod"
private const val ATTRIBUTE_APPLICATION_ID = "applicationID"
private const val ATTRIBUTE_AUTHENTICATION_CODE = "authenticationCode"
private const val NODE_APPLICATION_EXTENSIONS = "ApplicationExtensions"
private const val NODE_GRAPHIC_CONTROL_EXTENSION = "GraphicControlExtension"
private const val NODE_APPLICATION_EXTENSION = "ApplicationExtension"

class GifMetadata(private val rootNode: IIOMetadataNode) {
val applicationExtensions: ApplicationExtensions
get() = ApplicationExtensions(findOrAddNode(NODE_APPLICATION_EXTENSIONS))

val graphicControlExtension: GraphicControlExtension
get() = GraphicControlExtension(findOrAddNode(NODE_GRAPHIC_CONTROL_EXTENSION))

fun setLooping() {
applicationExtensions.addApplicationExtension {
applicationId = "NETSCAPE"
authenticationCode = "2.0"
userObject = byteArrayOf(
0x1,
(0 and 0xFF).toByte(),
(0 shr 8 and 0xFF).toByte()
)
}
}

private fun findOrAddNode(name: String) = getNodes().firstOrNull { it.nodeName.equals(name, true) }
?: addNode(name)

private fun addNode(name: String): IIOMetadataNode {
val node = IIOMetadataNode(name)
rootNode.appendChild(node)
return node
}

private fun getNodes() = sequence {
for (i in 0 until rootNode.length) {
val item = rootNode.item(i) as IIOMetadataNode
yield(item)
}
}

companion object {
fun fromInputStream(inputStream: InputStream): GifMetadata {
val imageReader = ImageIO.getImageReadersBySuffix("gif").next()
?: error("Failed to get an image reader for GIF")

imageReader.input = ImageIO.createImageInputStream(inputStream)

val imageMetadata = imageReader.getImageMetadata(0)
val node = imageMetadata.getAsTree(imageMetadata.nativeMetadataFormatName) as IIOMetadataNode
return GifMetadata(node)
}
}
}

class ApplicationExtensions(private val node: IIOMetadataNode) {
fun addApplicationExtension(block: ApplicationExtension.() -> Unit) {
val child = IIOMetadataNode(NODE_APPLICATION_EXTENSION)
ApplicationExtension(child).apply(block)
node.appendChild(child)
}

fun getApplicationExtensions() = node.children.map(::ApplicationExtension)
}

class ApplicationExtension(private val node: IIOMetadataNode) {
var applicationId: String
get() = node.getAttribute(ATTRIBUTE_APPLICATION_ID)
set(value) {
node.setAttribute(ATTRIBUTE_APPLICATION_ID, value)
}

var authenticationCode: String
get() = node.getAttribute(ATTRIBUTE_AUTHENTICATION_CODE)
set(value) {
node.setAttribute(ATTRIBUTE_AUTHENTICATION_CODE, value)
}

var userObject: ByteArray?
get() = node.userObject as? ByteArray
set(value) {
node.userObject = value
}
}

class GraphicControlExtension(private val node: IIOMetadataNode) {
/**
* The time to delay between frames
*/
var delayTime: Duration
get() {
val stringValue = node.getAttribute(ATTRIBUTE_DELAY_TIME)
val longValue = stringValue.toLongOrNull()
?: 0

val milliseconds = longValue * 10
return milliseconds.milliseconds
}
set(value) {
val valueInMs = value.toLong(DurationUnit.MILLISECONDS) / 10
node.setAttribute(ATTRIBUTE_DELAY_TIME, valueInMs.toString())
}

/**
* True if the frame should be advanced based on user input
*/
var userInputFlag: Boolean
get() = node.getAttribute(ATTRIBUTE_USER_INPUT_FLAG).toBooleanStrictOrNull()
?: false
set(value) {
node.setAttribute(ATTRIBUTE_USER_INPUT_FLAG, value.toString().uppercase())
}

/**
* True if a transparent color exists
*/
var transparentColorFlag: Boolean
get() = node.getAttribute(ATTRIBUTE_TRANSPARENT_COLOR_FLAG).toBooleanStrictOrNull()
?: false
set(value) {
node.setAttribute(ATTRIBUTE_TRANSPARENT_COLOR_FLAG, value.toString().uppercase())
}

/**
* The transparent color, if transparentColorFlag is true.
* Min value: 0 (inclusive)
* Max value: 255 (inclusive)
*/
var transparentColorIndex: Int
get() = node.getAttribute(ATTRIBUTE_TRANSPARENT_COLOR_INDEX).toIntOrNull()
?: 0
set(value) {
node.setAttribute(ATTRIBUTE_TRANSPARENT_COLOR_INDEX, value.toString())
}

/**
* The disposal method for this frame
*/
var disposalMethod: DisposalMethod
get() {
val allDisposalMethods = DisposalMethod.entries.associateBy { it.asString }
val value = node.getAttribute(ATTRIBUTE_DISPOSAL_METHOD)
return allDisposalMethods[value]
?: DisposalMethod.None
}
set(value) {
node.setAttribute(ATTRIBUTE_DISPOSAL_METHOD, value.asString)
}
}

enum class DisposalMethod {
None,
DoNotDispose,
RestoreToBackgroundColor,
RestoreToPrevious,
UndefinedDisposalMethod4,
UndefinedDisposalMethod5,
UndefinedDisposalMethod6,
UndefinedDisposalMethod7
}

private val DisposalMethod.asString
get() = when (this) {
DisposalMethod.None -> "none"
DisposalMethod.DoNotDispose -> "doNotDispose"
DisposalMethod.RestoreToBackgroundColor -> "restoreToBackgroundColor"
DisposalMethod.RestoreToPrevious -> "restoreToPrevious"
DisposalMethod.UndefinedDisposalMethod4 -> "undefinedDisposalMethod4"
DisposalMethod.UndefinedDisposalMethod7 -> "undefinedDisposalMethod7"
DisposalMethod.UndefinedDisposalMethod5 -> "undefinedDisposalMethod5"
DisposalMethod.UndefinedDisposalMethod6 -> "undefinedDisposalMethod6"
}

private val IIOMetadataNode.children get() = sequence {
for (i in 0 until childNodes.length) {
val childNode = childNodes.item(i) as IIOMetadataNode
yield(childNode)
}
}
11 changes: 8 additions & 3 deletions src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import kotlinx.cli.default
import java.io.File
import java.io.InputStream
import java.nio.file.InvalidPathException
import kotlin.time.Duration.Companion.seconds

const val DEFAULT_WIDTH = 1000
const val DEFAULT_HEIGHT = 1000
const val DEFAULT_DELAY_IN_SECONDS = 2
const val DEFAULT_DELAY_IN_SECONDS = 2.0
const val DEFAULT_SHOW_MARKER = false
const val DEFAULT_LOOP = false

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

val delay by parser.option(
type = ArgType.Int,
private val delay by parser.option(
type = ArgType.Double,
fullName = "delay",
shortName = "d",
description = "The delay between frames in seconds."
).default(DEFAULT_DELAY_IN_SECONDS)

val delayBetweenFrames by lazy {
delay.seconds
}

val sgf by lazy {
val sgf = when (val file = inputFile) {
null -> readSgf(System.`in`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ class GenerationTest {

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

data.writeTo(file)
}

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

data.writeTo(file)
Expand Down
31 changes: 31 additions & 0 deletions src/test/kotlin/com/github/ekenstein/sgf2gif/GifImage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.github.ekenstein.sgf2gif

import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.imageio.ImageIO
import javax.imageio.ImageReader
import javax.imageio.metadata.IIOMetadataNode

class GifImage private constructor(private val imageReader: ImageReader) {
private val imageMetaData by lazy {
val imageMetadata = imageReader.getImageMetadata(0)
val node = imageMetadata.getAsTree(imageMetadata.nativeMetadataFormatName) as IIOMetadataNode
GifMetadata(node)
}

val numberOfFrames: Int by lazy {
imageReader.getNumImages(true)
}

companion object {
fun fromStream(inputStream: InputStream): GifImage {
val imageReader = ImageIO.getImageReadersBySuffix("gif").next()
?: error("Failed to get an image reader for GIF")

imageReader.input = ImageIO.createImageInputStream(inputStream)
return GifImage(imageReader)
}

fun fromByteArray(bytes: ByteArray) = fromStream(ByteArrayInputStream(bytes))
}
}
Loading

0 comments on commit 1a7d5a9

Please sign in to comment.