MotionRecording.kt
src/jvmMain/kotlin/afield/animation/mocap/MotionRecording.kt · 156 lines
@file:afield.runtime.completion.CompletionCategory("Animation")
package afield.animation.mocap
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
class MotionRecorder(val directory: Path, val skeleton: MotionSkeleton, configuration: JSONObject,
Portable .f4motion directory: JSON metadata/events and chunked little-endian float32 poses. No native Python objects or pickle. The manifest appears only after a clean close. Requested/applied/superseded commands and reset/termination diagnostics belong in event.
class MotionRecorder(val directory: Path, val skeleton: MotionSkeleton, configuration: JSONObject,
val chunkFrames: Int = 256) : AutoCloseable {
private val metadata = JSONObject(configuration.toString())
private val chunks = ArrayList<String>()
private var output: DataOutputStream? = null
private var inChunk = 0
private var count = 0L
private var last: MotionFrame? = null
private var closed = false
private val events: java.io.BufferedWriter
init
init {
require(chunkFrames in 1..100000)
Files.createDirectory(directory) // Existing recordings are never overwritten.
events = Files.newBufferedWriter(directory.resolve("events.jsonl"))
metadata.put("schema", 1).put("format", "field.motion.v1").put("skeleton", skeleton.json())
Files.writeString(directory.resolve("incomplete.json"), metadata.toString(2))
}
@Synchronized fun event(event: JSONObject)
@Synchronized fun event(event: JSONObject) {
check(!closed)
events.write(event.toString()); events.newLine(); events.flush()
}
@Synchronized fun append(frame: MotionFrame)
@Synchronized fun append(frame: MotionFrame) {
check(!closed)
require(frame.executed.size == skeleton.size)
last?.let { require(frame.time >= it.time && frame.index > it.index && frame.episode >= it.episode) }
if (output == null || inChunk == chunkFrames) {
output?.close()
val name = "chunk-%06d.bin".format(chunks.size)
chunks.add(name); output = DataOutputStream(Files.newOutputStream(directory.resolve(name))); inChunk = 0
}
val record = MotionBinary.encode(frame)
output!!.writeInt(record.size); output!!.write(record)
count++; inChunk++; last = frame
}
@Synchronized override fun close()
@Synchronized override fun close() {
if (closed) return
closed = true
try { output?.close() } finally { events.close() }
metadata.put("chunks", chunks).put("frameCount", count)
val temp = directory.resolve("manifest.tmp")
Files.writeString(temp, metadata.toString(2))
Files.move(temp, directory.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE)
Files.delete(directory.resolve("incomplete.json"))
}
}
class MotionRecording(val directory: Path)
Streaming read keeps long recordings off the live buffer; clip is an explicit in-memory load.
val metadata: JSONObject
val skeleton: MotionSkeleton
private val chunks: List<String>
init
init {
val path = directory.resolve("manifest.json")
require(Files.size(path) <= 4 * 1024 * 1024) { "Manifest too large" }
metadata = JSONObject(Files.readString(path))
require(metadata.getInt("schema") == 1 && metadata.getString("format") == "field.motion.v1")
skeleton = MotionSkeleton.fromJson(metadata.getJSONObject("skeleton"))
chunks = metadata.getJSONArray("chunks").strings()
require(chunks.size <= 100000 && chunks.toSet().size == chunks.size)
chunks.forEachIndexed { i, name -> require(name == "chunk-%06d.bin".format(i)) }
}
fun frames(): Sequence<MotionFrame>
fun frames(): Sequence<MotionFrame> = sequence {
var count = 0L
var last: MotionFrame? = null
for (name in chunks) {
val path = directory.resolve(name)
require(!Files.isSymbolicLink(path)) { "Recording chunk must not be a symlink" }
DataInputStream(Files.newInputStream(path).buffered()).use { input ->
while (true) {
val first = input.read()
if (first == -1) break
val length = (first shl 24) or (input.readUnsignedByte() shl 16) or
(input.readUnsignedByte() shl 8) or input.readUnsignedByte()
require(length in 4..MotionBinary.MAX_BYTES) { "Invalid motion record size: $length" }
val bytes = ByteArray(length); input.readFully(bytes)
val frame = MotionBinary.decode(bytes, skeleton.size)
last?.let { require(frame.time >= it.time && frame.index > it.index && frame.episode >= it.episode) }
last = frame; count++
yield(frame)
}
}
}
require(count == metadata.getLong("frameCount")) { "Recording frame count mismatch" }
}
fun clip() = MotionClip(skeleton, frames().toList())
fun events(): Sequence<JSONObject>
fun events(): Sequence<JSONObject> = sequence {
Files.newBufferedReader(directory.resolve("events.jsonl")).use { reader ->
while (true) {
val line = reader.readLine() ?: break
require(line.length <= MotionBinary.MAX_BYTES)
yield(JSONObject(line))
}
}
}
}
internal object MotionBinary
const val MAX_BYTES = 16 * 1024 * 1024
fun encode(frame: MotionFrame): ByteArray
fun encode(frame: MotionFrame): ByteArray {
val header = frame.json().removePoses().toString().toByteArray(Charsets.UTF_8)
val values = ArrayList<Double>()
fun pose(p: MotionPose) { values.addAll(p.root); values.addAll(p.localRotations); values.addAll(p.worldPositions) }
pose(frame.executed); frame.intended?.let(::pose); values.addAll(frame.contacts)
val bytes = ByteBuffer.allocate(4 + header.size + values.size * 4).order(ByteOrder.BIG_ENDIAN)
require(bytes.capacity() <= MAX_BYTES)
bytes.putInt(header.size).put(header).order(ByteOrder.LITTLE_ENDIAN)
values.forEach { bytes.putFloat(it.toFloat()) }
return bytes.array()
}
private fun JSONObject.removePoses(): JSONObject {
val reference = !isNull("intended")
val referenceRotations = reference && getJSONObject("intended").getJSONArray("localRotations").length() > 0
remove("executed"); remove("intended")
val count = getJSONArray("contacts").length(); remove("contacts")
return put("hasIntended", reference).put("intendedHasRotations", referenceRotations).put("contactCount", count)
}
fun decode(bytes: ByteArray, joints: Int): MotionFrame
fun decode(bytes: ByteArray, joints: Int): MotionFrame {
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
val length = buffer.int
require(length in 2..(bytes.size - 4))
val header = ByteArray(length); buffer.get(header)
val json = JSONObject(String(header, Charsets.UTF_8))
val contacts = json.getInt("contactCount")
require(contacts in 0..1024)
val poses = if (json.getBoolean("hasIntended")) 2 else 1
val referenceRotations = json.optBoolean("intendedHasRotations", true)
val floats = 3 + joints * 7 + (if (poses == 2) 3 + joints * (if (referenceRotations) 7 else 3) else 0) + contacts
require(buffer.remaining() == floats * 4) { "Pose payload length mismatch" }
buffer.order(ByteOrder.LITTLE_ENDIAN)
fun values(n: Int) = List(n) { buffer.float.toDouble() }
fun pose(rotations: Boolean = true) = MotionPose(values(3), values(if (rotations) joints * 4 else 0), values(joints * 3))
val executed = pose(); val intended = if (poses == 2) pose(referenceRotations) else null
val d = json.getJSONObject("diagnostics")
return MotionFrame(json.getDouble("time"), json.getLong("index"), json.getLong("episode"), json.getLong("revision"),
executed, intended, values(contacts), json.getString("contactProvenance"), d.keySet().associateWith { d.getDouble(it) })
}
}