MotionProcess.kt
src/jvmMain/kotlin/afield/animation/mocap/MotionProcess.kt · 181 lines
@file:afield.runtime.completion.CompletionCategory("Animation")
package afield.animation.mocap
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
data class MotionProcessConfig
data class MotionProcessConfig(
val backend: String = "closd",
val home: Path = Path.of(System.getenv("FIELD_MOTION_HOME") ?: "${System.getProperty("user.home")}/.field4/motion"),
val python: Path = home.resolve("environments/$backend/bin/python"),
val source: Path = home.resolve(if (backend == "closd") "upstream/CLoSD" else "upstream/ardy"),
val worker: Path = Path.of("optional/motion/worker.py").toAbsolutePath(),
val overrides: List<String> = emptyList(),
val startupTimeoutMs: Int = 180_000,
val requestTimeoutMs: Int = 120_000,
val offlineAssets: Boolean = true,
val embeddingCache: Path = home.resolve("embeddings"),
val encoderUrl: String = System.getenv("FIELD_MOTION_ENCODER_URL") ?: "http://127.0.0.1:9550/",
val seed: Int = 0,
val denoisingSteps: Int = 4,
) {
init
init { require(backend in setOf("closd", "ardy") && startupTimeoutMs > 0 && requestTimeoutMs > 0 && denoisingSteps > 0) }
}
object CLoSD
Starts a process, authenticates its capability/skeleton reply, and owns it through box stop/re-run.
fun openAsync(config: MotionProcessConfig = MotionProcessConfig()): CompletableFuture<MotionSession>
fun openAsync(config: MotionProcessConfig = MotionProcessConfig()): CompletableFuture<MotionSession> {
require(config.backend == "closd")
return openMotionProcess(config)
}
}
object ARDY
fun openAsync(config: MotionProcessConfig = MotionProcessConfig(backend = "ardy")): CompletableFuture<MotionSession>
body — 132 lines, from line 49
fun openAsync(config: MotionProcessConfig = MotionProcessConfig(backend = "ardy")): CompletableFuture<MotionSession> {
require(config.backend == "ardy")
return openMotionProcess(config)
}
}
private fun openMotionProcess(config: MotionProcessConfig): CompletableFuture<MotionSession> {
val process = AtomicReference<Process?>()
val socket = AtomicReference<Socket?>()
val session = AtomicReference<MotionSession?>()
val cancelled = AtomicBoolean()
val future = CompletableFuture<MotionSession>()
fun cancel() {
cancelled.set(true); session.get()?.close()
runCatching { socket.get()?.close() }
process.get()?.destroy()
future.cancel(false)
}
RunScope.current()?.own(AutoCloseable { cancel() }, ReleasePolicy.RUNNING)
Thread({
try {
check(!cancelled.get()) { "Motion opening cancelled" }
val backend = MotionProcessBackend(config, process, socket, cancelled)
val opened = MotionSession(backend)
session.set(opened)
if (cancelled.get() || !future.complete(opened)) opened.close()
} catch (e: Throwable) {
runCatching { socket.get()?.close() }
process.get()?.let { p -> p.destroy(); if (!p.waitFor(2, TimeUnit.SECONDS)) p.destroyForcibly() }
future.completeExceptionally(e)
}
}, "field-${config.backend}-open").apply { isDaemon = true; start() }
future.whenComplete { _, _ -> if (future.isCancelled) cancel() }
return future
}
private class MotionProcessBackend(private val config: MotionProcessConfig, processRef: AtomicReference<Process?>,
socketRef: AtomicReference<Socket?>, cancelled: AtomicBoolean) : MotionBackend {
private val token = UUID.randomUUID().toString()
private val log = Files.createTempFile("field-${config.backend}-", ".log")
private val process: Process
private val socket: Socket
private val input: DataInputStream
private val output: DataOutputStream
private var sequence = 0L
private val closed = AtomicBoolean()
override val skeleton: MotionSkeleton
override val capabilities: MotionCapabilities
init {
require(Files.isExecutable(config.python) && Files.isRegularFile(config.worker)) {
"Missing ${config.backend} interpreter/worker. See optional/motion/README.md and set FIELD_MOTION_HOME."
}
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { server ->
server.soTimeout = 250
val command = listOf(config.python.toAbsolutePath().toString(), config.worker.toAbsolutePath().toString(),
"--backend", config.backend, "--source", config.source.toAbsolutePath().toString(), "--connect", server.localPort.toString(),
"--embedding-cache", config.embeddingCache.toAbsolutePath().toString(), "--encoder-url", config.encoderUrl,
"--seed", config.seed.toString(), "--steps", config.denoisingSteps.toString()) +
config.overrides.flatMap { listOf("--override=$it") }
process = ProcessBuilder(command).directory(config.source.toFile()).redirectErrorStream(true)
.redirectOutput(log.toFile()).apply {
environment()["FIELD_MOTION_TOKEN"] = token
// Managed Python 3.8 has its shared library next to the real interpreter, not the venv.
val lib = config.python.toRealPath().parent.parent.resolve("lib").toString()
environment()["LD_LIBRARY_PATH"] = lib + (environment()["LD_LIBRARY_PATH"]?.let { ":$it" } ?: "")
if (config.offlineAssets) { environment()["HF_HUB_OFFLINE"] = "1"; environment()["TRANSFORMERS_OFFLINE"] = "1" }
}.start()
processRef.set(process)
if (cancelled.get()) { process.destroyForcibly(); error("Motion opening cancelled") }
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(config.startupTimeoutMs.toLong())
var connected: Socket? = null
while (connected == null) {
check(!cancelled.get() && process.isAlive) { "${config.backend} exited/cancelled before connection: $log\n${tail()}" }
if (System.nanoTime() > deadline) throw SocketTimeoutException("${config.backend} connection timeout: $log")
try { connected = server.accept() } catch (_: SocketTimeoutException) { }
}
socket = connected; socketRef.set(socket)
}
socket.soTimeout = config.startupTimeoutMs
input = DataInputStream(socket.getInputStream())
output = DataOutputStream(socket.getOutputStream())
val hello = call("hello", JSONObject())
require(hello.getInt("schema") == 1)
skeleton = MotionSkeleton.fromJson(hello.getJSONObject("skeleton"))
capabilities = MotionCapabilities.fromJson(hello.getJSONObject("capabilities"))
require(capabilities.backend == config.backend)
socket.soTimeout = config.requestTimeoutMs
}
private fun call(operation: String, body: JSONObject): JSONObject {
check(!closed.get()) { "Motion process closed" }
val identity = ++sequence
val bytes = body.put("op", operation).put("id", identity).put("token", token).toString().toByteArray(Charsets.UTF_8)
require(bytes.size <= MAX_BYTES)
try {
output.writeInt(bytes.size); output.write(bytes); output.flush()
val size = input.readInt(); require(size in 2..MAX_BYTES)
val response = ByteArray(size); input.readFully(response)
val json = JSONObject(String(response, Charsets.UTF_8))
require(json.getLong("id") == identity) { "Motion reply ID mismatch" }
if (!json.getBoolean("ok")) throw IllegalArgumentException(json.getString("error"))
return json.getJSONObject("result")
} catch (e: Exception) {
throw IllegalStateException("${config.backend} $operation failed: ${e.message}. Worker log: $log\n${tail()}", e)
}
}
override fun command(command: MotionCommand): MotionApplied {
val result = call("command", JSONObject().put("command", JSONObject().put("operation", command.operation.name)
.put("revision", command.revision).put("requestedTime", command.requestedTime).put("payload", command.payload)))
return MotionApplied(if (result.isNull("appliedTime")) null else result.getDouble("appliedTime"),
if (result.isNull("frameIndex")) null else result.getLong("frameIndex"), result.getJSONObject("details"))
}
override fun advance(seconds: Double): MotionUpdate {
val result = call("advance", JSONObject().put("seconds", seconds))
val frames = result.getJSONArray("frames"); val events = result.getJSONArray("events")
return MotionUpdate(List(frames.length()) { MotionFrame.fromJson(frames.getJSONObject(it)) },
List(events.length()) { events.getJSONObject(it) })
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
runCatching { socket.close() } // Unblocks an in-flight call; worker releases its simulator on EOF.
if (!process.waitFor(3, TimeUnit.SECONDS)) process.destroy()
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
}
private fun tail(): String = runCatching {
Files.newByteChannel(log).use { channel ->
channel.position((channel.size() - 4096).coerceAtLeast(0))
val buffer = java.nio.ByteBuffer.allocate(4096); channel.read(buffer)
String(buffer.array(), 0, buffer.position(), Charsets.UTF_8)
}
}.getOrDefault("")
companion object { private const val MAX_BYTES = 4 * 1024 * 1024 }
}