diff --git a/group.gradle.properties b/group.gradle.properties index e8dd53d47..e013b4ede 100644 --- a/group.gradle.properties +++ b/group.gradle.properties @@ -1,5 +1,5 @@ group=us.ihmc -version=17-0.21.1 +version=17-0.22.2 vcsUrl=https://github.com/ihmcrobotics/simulation-construction-set-2 openSource=true maintainer="Sylvain Bertrand (sbertrand@ihmc.org)" diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAP.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAP.java deleted file mode 100644 index c46f43c6c..000000000 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAP.java +++ /dev/null @@ -1,1945 +0,0 @@ -package us.ihmc.scs2.session.mcap; - -import gnu.trove.map.hash.TLongObjectHashMap; -import us.ihmc.euclid.tools.EuclidCoreIOTools; -import us.ihmc.scs2.session.mcap.input.MCAPDataInput; -import us.ihmc.scs2.session.mcap.input.MCAPDataInput.Compression; - -import java.lang.ref.WeakReference; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * MCAP is a modular container format and logging library for pub/sub messages with arbitrary - * message serialization. It is primarily intended for use in robotics applications, and works well - * under various workloads, resource constraints, and durability requirements. Time values - * (`log_time`, `publish_time`, `create_time`) are represented in nanoseconds since a - * user-understood epoch (i.e. Unix epoch, robot boot time, etc.) - * - * @see Source - */ -public class MCAP -{ - /** - * Stream object that this MCAP was parsed from. - */ - protected MCAPDataInput dataInput; - - public enum Opcode - { - HEADER(1), - FOOTER(2), - SCHEMA(3), - CHANNEL(4), - MESSAGE(5), - CHUNK(6), - MESSAGE_INDEX(7), - CHUNK_INDEX(8), - ATTACHMENT(9), - ATTACHMENT_INDEX(10), - STATISTICS(11), - METADATA(12), - METADATA_INDEX(13), - SUMMARY_OFFSET(14), - DATA_END(15); - - private final long id; - - Opcode(long id) - { - this.id = id; - } - - public long id() - { - return id; - } - - private static final TLongObjectHashMap byId = new TLongObjectHashMap<>(15); - - static - { - for (Opcode e : Opcode.values()) - byId.put(e.id(), e); - } - - public static Opcode byId(long id) - { - return byId.get(id); - } - } - - private final Magic headerMagic; - private final List records; - private final Magic footerMagic; - - private Record footer; - - public MCAP(FileChannel fileChannel) - { - dataInput = MCAPDataInput.wrap(fileChannel); - - long currentPos = 0; - headerMagic = new Magic(dataInput, currentPos); - currentPos += headerMagic.getElementLength(); - records = new ArrayList<>(); - Record lastRecord; - - do - { - lastRecord = new Record(dataInput, currentPos); - if (lastRecord.getElementLength() < 0) - throw new IllegalArgumentException("Invalid record length: " + lastRecord.getElementLength()); - currentPos += lastRecord.getElementLength(); - records.add(lastRecord); - } - while (!(lastRecord.op() == Opcode.FOOTER)); - - footerMagic = new Magic(dataInput, currentPos); - } - - public MCAPDataInput getDataInput() - { - return dataInput; - } - - public Magic headerMagic() - { - return headerMagic; - } - - public List records() - { - return records; - } - - public Magic footerMagic() - { - return footerMagic; - } - - public Record footer() - { - if (footer == null) - { - footer = new Record(dataInput, computeOffsetFooter(dataInput)); - } - return footer; - } - - public static class Chunk implements MCAPElement - { - private final MCAPDataInput dataInput; - /** - * Earliest message log_time in the chunk. Zero if the chunk has no messages. - */ - private final long messageStartTime; - /** - * Latest message log_time in the chunk. Zero if the chunk has no messages. - */ - private final long messageEndTime; - /** - * Uncompressed size of the records field. - */ - private final long recordsUncompressedLength; - /** - * CRC32 checksum of uncompressed records field. A value of zero indicates that CRC validation - * should not be performed. - */ - private final long uncompressedCrc32; - /** - * compression algorithm. i.e. zstd, lz4, "". An empty string indicates no compression. Refer to - * well-known compression formats. - */ - private final String compression; - /** - * Offset position of the records in either in the {@code ByteBuffer} or {@code FileChannel}, - * depending on how this chunk was created. - */ - private final long recordsOffset; - /** - * Length of the records in bytes. - */ - private final long recordsCompressedLength; - /** - * The decompressed records. - */ - private WeakReference recordsRef; - - public Chunk(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - messageStartTime = checkPositiveLong(dataInput.getLong(), "messageStartTime"); - messageEndTime = checkPositiveLong(dataInput.getLong(), "messageEndTime"); - recordsUncompressedLength = checkPositiveLong(dataInput.getLong(), "uncompressedSize"); - uncompressedCrc32 = dataInput.getUnsignedInt(); - compression = dataInput.getString(); - recordsCompressedLength = checkPositiveLong(dataInput.getLong(), "recordsLength"); - recordsOffset = dataInput.position(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 3 * Long.BYTES + 2 * Integer.BYTES + compression.length() + Long.BYTES + (int) recordsCompressedLength; - } - - public long messageStartTime() - { - return messageStartTime; - } - - public long messageEndTime() - { - return messageEndTime; - } - - public long uncompressedSize() - { - return recordsUncompressedLength; - } - - /** - * CRC-32 checksum of uncompressed `records` field. A value of zero indicates that CRC validation - * should not be performed. - */ - public long uncompressedCrc32() - { - return uncompressedCrc32; - } - - public String compression() - { - return compression; - } - - public long recordsLength() - { - return recordsCompressedLength; - } - - public Records records() - { - Records records = recordsRef == null ? null : recordsRef.get(); - - if (records != null) - return records; - - if (compression.equalsIgnoreCase("")) - { - records = new Records(dataInput, recordsOffset, (int) recordsCompressedLength); - } - else - { - ByteBuffer decompressedBuffer = dataInput.getDecompressedByteBuffer(recordsOffset, - (int) recordsCompressedLength, - (int) recordsUncompressedLength, - Compression.fromString(compression), - false); - records = new Records(MCAPDataInput.wrap(decompressedBuffer), 0, (int) recordsUncompressedLength); - } - - recordsRef = new WeakReference<>(records); - return records; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-messageStartTime = " + messageStartTime; - out += "\n\t-messageEndTime = " + messageEndTime; - out += "\n\t-compression = " + compression; - out += "\n\t-recordsCompressedLength = " + recordsCompressedLength; - out += "\n\t-recordsUncompressedLength = " + recordsUncompressedLength; - out += "\n\t-uncompressedCrc32 = " + uncompressedCrc32; - return out; - } - } - - public static class DataEnd implements MCAPElement - { - private final long dataSectionCrc32; - - public DataEnd(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - dataInput.position(elementPosition); - dataSectionCrc32 = dataInput.getUnsignedInt(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return Integer.BYTES; - } - - /** - * CRC-32 of all bytes in the data section. A value of 0 indicates the CRC-32 is not available. - */ - public long dataSectionCrc32() - { - return dataSectionCrc32; - } - - @Override - public String toString() - { - return getClass().getSimpleName() + ":\n\t-dataSectionCrc32 = " + dataSectionCrc32; - } - } - - public static class Channel implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long elementLength; - private final int id; - private final int schemaId; - private final String topic; - private final String messageEncoding; - private WeakReference> metadataRef; - private final long metadataOffset; - private final long metadataLength; - - public Channel(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - this.elementLength = elementLength; - - dataInput.position(elementPosition); - id = dataInput.getUnsignedShort(); - schemaId = dataInput.getUnsignedShort(); - topic = dataInput.getString(); - messageEncoding = dataInput.getString(); - metadataLength = dataInput.getUnsignedInt(); - metadataOffset = dataInput.position(); - } - - @Override - public long getElementLength() - { - return elementLength; - } - - public int id() - { - return id; - } - - public int schemaId() - { - return schemaId; - } - - public String topic() - { - return topic; - } - - public String messageEncoding() - { - return messageEncoding; - } - - public List metadata() - { - List metadata = metadataRef == null ? null : metadataRef.get(); - - if (metadata == null) - { - metadata = parseList(dataInput, TupleStrStr::new, metadataOffset, metadataLength); - metadataRef = new WeakReference<>(metadata); - } - - return metadata; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-id = " + id; - out += "\n\t-schemaId = " + schemaId; - out += "\n\t-topic = " + topic; - out += "\n\t-messageEncoding = " + messageEncoding; - out += "\n\t-metadata = [%s]".formatted(metadata().toString()); - return out; - } - } - - public static class MessageIndex implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long elementLength; - private final int channelId; - private WeakReference> messageIndexEntriesRef; - private final long messageIndexEntriesOffset; - private final long messageIndexEntriesLength; - - public MessageIndex(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - this.elementLength = elementLength; - - dataInput.position(elementPosition); - channelId = dataInput.getUnsignedShort(); - messageIndexEntriesLength = dataInput.getUnsignedInt(); - messageIndexEntriesOffset = dataInput.position(); - } - - @Override - public long getElementLength() - { - return elementLength; - } - - public static class MessageIndexEntry implements MCAPElement - { - /** - * Time at which the message was recorded. - */ - private final long logTime; - - /** - * Offset is relative to the start of the uncompressed chunk data. - */ - private final long offset; - - public MessageIndexEntry(MCAPDataInput dataInput, long elementPosition) - { - dataInput.position(elementPosition); - logTime = checkPositiveLong(dataInput.getLong(), "logTime"); - offset = checkPositiveLong(dataInput.getLong(), "offset"); - } - - @Override - public long getElementLength() - { - return 2 * Long.BYTES; - } - - public long logTime() - { - return logTime; - } - - public long offset() - { - return offset; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-logTime = " + logTime; - out += "\n\t-offset = " + offset; - return indent(out, indent); - } - } - - public int channelId() - { - return channelId; - } - - public List messageIndexEntries() - { - List messageIndexEntries = messageIndexEntriesRef == null ? null : messageIndexEntriesRef.get(); - - if (messageIndexEntries == null) - { - messageIndexEntries = parseList(dataInput, MessageIndexEntry::new, messageIndexEntriesOffset, messageIndexEntriesLength); - messageIndexEntriesRef = new WeakReference<>(messageIndexEntries); - } - - return messageIndexEntries; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-channelId = " + channelId; - List messageIndexEntries = messageIndexEntries(); - out += "\n\t-messageIndexEntries = " + (messageIndexEntries == null ? - "null" : - "\n" + EuclidCoreIOTools.getCollectionString("\n", messageIndexEntries, e -> e.toString(indent + 1))); - return indent(out, indent); - } - } - - public static class Statistics implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long elementLength; - private final long messageCount; - private final int schemaCount; - private final long channelCount; - private final long attachmentCount; - private final long metadataCount; - private final long chunkCount; - private final long messageStartTime; - private final long messageEndTime; - private WeakReference> channelMessageCountsRef; - private final long channelMessageCountsOffset; - private final long channelMessageCountsLength; - - public Statistics(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - this.elementLength = elementLength; - - dataInput.position(elementPosition); - messageCount = checkPositiveLong(dataInput.getLong(), "messageCount"); - schemaCount = dataInput.getUnsignedShort(); - channelCount = dataInput.getUnsignedInt(); - attachmentCount = dataInput.getUnsignedInt(); - metadataCount = dataInput.getUnsignedInt(); - chunkCount = dataInput.getUnsignedInt(); - messageStartTime = checkPositiveLong(dataInput.getLong(), "messageStartTime"); - messageEndTime = checkPositiveLong(dataInput.getLong(), "messageEndTime"); - channelMessageCountsLength = dataInput.getUnsignedInt(); - channelMessageCountsOffset = dataInput.position(); - } - - @Override - public long getElementLength() - { - return elementLength; - } - - public static class ChannelMessageCount implements MCAPElement - { - private final int channelId; - private final long messageCount; - - public ChannelMessageCount(MCAPDataInput dataInput, long elementPosition) - { - dataInput.position(elementPosition); - channelId = dataInput.getUnsignedShort(); - messageCount = dataInput.getLong(); - } - - @Override - public long getElementLength() - { - return Short.BYTES + Long.BYTES; - } - - public int channelId() - { - return channelId; - } - - public long messageCount() - { - return messageCount; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-channelId = " + channelId; - out += "\n\t-messageCount = " + messageCount; - return indent(out, indent); - } - } - - public long messageCount() - { - return messageCount; - } - - public int schemaCount() - { - return schemaCount; - } - - public long channelCount() - { - return channelCount; - } - - public long attachmentCount() - { - return attachmentCount; - } - - public long metadataCount() - { - return metadataCount; - } - - public long chunkCount() - { - return chunkCount; - } - - public long messageStartTime() - { - return messageStartTime; - } - - public long messageEndTime() - { - return messageEndTime; - } - - public List channelMessageCounts() - { - List channelMessageCounts = channelMessageCountsRef == null ? null : channelMessageCountsRef.get(); - - if (channelMessageCounts == null) - { - channelMessageCounts = parseList(dataInput, ChannelMessageCount::new, channelMessageCountsOffset, channelMessageCountsLength); - channelMessageCountsRef = new WeakReference<>(channelMessageCounts); - } - - return channelMessageCounts; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-messageCount = " + messageCount; - out += "\n\t-schemaCount = " + schemaCount; - out += "\n\t-channelCount = " + channelCount; - out += "\n\t-attachmentCount = " + attachmentCount; - out += "\n\t-metadataCount = " + metadataCount; - out += "\n\t-chunkCount = " + chunkCount; - out += "\n\t-messageStartTime = " + messageStartTime; - out += "\n\t-messageEndTime = " + messageEndTime; - out += "\n\t-channelMessageCounts = \n" + EuclidCoreIOTools.getCollectionString("\n", channelMessageCounts(), e -> e.toString(1)); - return out; - } - } - - public static class AttachmentIndex implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long attachmentOffset; - private final long attachmentLength; - private final long logTime; - private final long createTime; - private final long dataSize; - private final String name; - private final String mediaType; - - private WeakReference attachmentRef; - - private AttachmentIndex(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - attachmentOffset = checkPositiveLong(dataInput.getLong(), "attachmentOffset"); - attachmentLength = checkPositiveLong(dataInput.getLong(), "attachmentLength"); - logTime = checkPositiveLong(dataInput.getLong(), "logTime"); - createTime = checkPositiveLong(dataInput.getLong(), "createTime"); - dataSize = checkPositiveLong(dataInput.getLong(), "dataSize"); - name = dataInput.getString(); - mediaType = dataInput.getString(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 5 * Long.BYTES + 2 * Integer.BYTES + name.length() + mediaType.length(); - } - - public Record attachment() - { - Record attachment = attachmentRef == null ? null : attachmentRef.get(); - - if (attachment == null) - { - attachment = new Record(dataInput, attachmentOffset); - attachmentRef = new WeakReference<>(attachment); - } - - return attachment; - } - - public long attachmentOffset() - { - return attachmentOffset; - } - - public long attachmentLength() - { - return attachmentLength; - } - - public long logTime() - { - return logTime; - } - - public long createTime() - { - return createTime; - } - - public long dataSize() - { - return dataSize; - } - - public String name() - { - return name; - } - - public String mediaType() - { - return mediaType; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-attachmentOffset = " + attachmentOffset; - out += "\n\t-attachmentLength = " + attachmentLength; - out += "\n\t-logTime = " + logTime; - out += "\n\t-createTime = " + createTime; - out += "\n\t-dataSize = " + dataSize; - out += "\n\t-name = " + name; - out += "\n\t-mediaType = " + mediaType; - return out; - } - } - - public static class Schema implements MCAPElement - { - private final MCAPDataInput dataInput; - private final int id; - private final String name; - private final String encoding; - private final long dataLength; - private final long dataOffset; - private WeakReference dataRef; - - public Schema(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - id = dataInput.getUnsignedShort(); - name = dataInput.getString(); - encoding = dataInput.getString(); - dataLength = dataInput.getUnsignedInt(); - dataOffset = dataInput.position(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return Short.BYTES + 3 * Integer.BYTES + name.length() + encoding.length() + (int) dataLength; - } - - public int id() - { - return id; - } - - public String name() - { - return name; - } - - public String encoding() - { - return encoding; - } - - public ByteBuffer data() - { - ByteBuffer data = this.dataRef == null ? null : this.dataRef.get(); - - if (data == null) - { - data = dataInput.getByteBuffer(dataOffset, (int) dataLength, false); - dataRef = new WeakReference<>(data); - } - return data; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-id = " + id; - out += "\n\t-name = " + name; - out += "\n\t-encoding = " + encoding; - out += "\n\t-dataLength = " + dataLength; - out += "\n\t-data = " + Arrays.toString(data().array()); - return out; - } - } - - public static class SummaryOffset implements MCAPElement - { - private final MCAPDataInput dataInput; - private final Opcode groupOpcode; - private final long offsetGroup; - private final long lengthGroup; - - private WeakReference groupRef; - - public SummaryOffset(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - groupOpcode = Opcode.byId(dataInput.getUnsignedByte()); - offsetGroup = checkPositiveLong(dataInput.getLong(), "offsetGroup"); - lengthGroup = checkPositiveLong(dataInput.getLong(), "lengthGroup"); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return Byte.BYTES + 2 * Long.BYTES; - } - - public Records group() - { - Records group = groupRef == null ? null : groupRef.get(); - - if (group == null) - { - group = new Records(dataInput, offsetGroup, (int) lengthGroup); - groupRef = new WeakReference<>(group); - } - return group; - } - - public Opcode groupOpcode() - { - return groupOpcode; - } - - public long offsetGroup() - { - return offsetGroup; - } - - public long lengthGroup() - { - return lengthGroup; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-groupOpcode = " + groupOpcode; - out += "\n\t-offsetGroup = " + offsetGroup; - out += "\n\t-lengthGroup = " + lengthGroup; - return out; - } - } - - public static class Attachment implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long logTime; - private final long createTime; - private final String name; - private final String mediaType; - private final long lengthData; - private final long offsetData; - private WeakReference dataRef; - private final long crc32; - private final long crc32InputStart; - private final int crc32InputLength; - private WeakReference crc32InputRef; - - private Attachment(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - crc32InputStart = elementPosition; - logTime = checkPositiveLong(dataInput.getLong(), "logTime"); - createTime = checkPositiveLong(dataInput.getLong(), "createTime"); - name = dataInput.getString(); - mediaType = dataInput.getString(); - lengthData = checkPositiveLong(dataInput.getLong(), "lengthData"); - offsetData = dataInput.position(); - dataInput.skip(lengthData); - crc32InputLength = (int) (dataInput.position() - elementPosition); - crc32 = dataInput.getUnsignedInt(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 3 * Long.BYTES + 3 * Integer.BYTES + name.length() + mediaType.length() + (int) lengthData; - } - - public ByteBuffer crc32Input() - { - ByteBuffer crc32Input = this.crc32InputRef == null ? null : this.crc32InputRef.get(); - - if (crc32Input == null) - { - crc32Input = dataInput.getByteBuffer(crc32InputStart, crc32InputLength, false); - crc32InputRef = new WeakReference<>(crc32Input); - } - - return crc32Input; - } - - public long logTime() - { - return logTime; - } - - public long createTime() - { - return createTime; - } - - public String name() - { - return name; - } - - public String mediaType() - { - return mediaType; - } - - public long lenData() - { - return lengthData; - } - - public ByteBuffer data() - { - ByteBuffer data = this.dataRef == null ? null : this.dataRef.get(); - - if (data == null) - { - data = dataInput.getByteBuffer(offsetData, (int) lengthData, false); - dataRef = new WeakReference<>(data); - } - return data; - } - - public void unloadData() - { - dataRef = null; - } - - /** - * CRC-32 checksum of preceding fields in the record. A value of zero indicates that CRC validation - * should not be performed. - */ - public long crc32() - { - return crc32; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-logTime = " + logTime; - out += "\n\t-createTime = " + createTime; - out += "\n\t-name = " + name; - out += "\n\t-mediaType = " + mediaType; - out += "\n\t-lengthData = " + lengthData; - // out += "\n\t-data = " + data; - out += "\n\t-crc32 = " + crc32; - return out; - } - } - - public static class Metadata implements MCAPElement - { - private final String name; - private final List metadata; - private final int metadataLength; - - private Metadata(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - dataInput.position(elementPosition); - name = dataInput.getString(); - long start = dataInput.position(); - metadata = parseList(dataInput, TupleStrStr::new); // TODO Looks into postponing the loading of the metadata. - metadataLength = (int) (dataInput.position() - start); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return Integer.BYTES + name.length() + metadataLength; - } - - public String name() - { - return name; - } - - public List metadata() - { - return metadata; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-name = " + name; - out += "\n\t-metadata = " + EuclidCoreIOTools.getCollectionString(", ", metadata, e -> e.key()); - return out; - } - } - - public static class Header implements MCAPElement - { - private final String profile; - private final String library; - - public Header(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - dataInput.position(elementPosition); - profile = dataInput.getString(); - library = dataInput.getString(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 2 * Integer.BYTES + profile.length() + library.length(); - } - - public String profile() - { - return profile; - } - - public String library() - { - return library; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-profile = " + profile; - out += "\n\t-library = " + library; - return out; - } - } - - public static class Message implements MCAPElement - { - private final MCAPDataInput dataInput; - private int channelId; - private long sequence; - private long logTime; - private long publishTime; - private long dataOffset; - private int dataLength; - private WeakReference messageBufferRef; - private WeakReference messageDataRef; - - public static Message createSpoofMessageForTesting(int channelId, byte[] data) - { - return new Message() - { - @Override - public int channelId() - { - return channelId; - } - - @Override - public long dataOffset() - { - return 0; - } - - @Override - public int dataLength() - { - return data.length; - } - - @Override - public ByteBuffer messageBuffer() - { - return ByteBuffer.wrap(data); - } - - @Override - public byte[] messageData() - { - return data; - } - }; - } - - private Message() - { - dataInput = null; - } - - private Message(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - channelId = dataInput.getUnsignedShort(); - sequence = dataInput.getUnsignedInt(); - logTime = checkPositiveLong(dataInput.getLong(), "logTime"); - publishTime = checkPositiveLong(dataInput.getLong(), "publishTime"); - dataOffset = dataInput.position(); - dataLength = (int) (elementLength - (Short.BYTES + Integer.BYTES + 2 * Long.BYTES)); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return dataLength + Short.BYTES + Integer.BYTES + 2 * Long.BYTES; - } - - public int channelId() - { - return channelId; - } - - public long sequence() - { - return sequence; - } - - public long logTime() - { - return logTime; - } - - public long publishTime() - { - return publishTime; - } - - /** - * Returns the offset of the data portion of this message in the buffer returned by - * {@link #messageBuffer()}. - * - * @return the offset of the data portion of this message. - */ - public long dataOffset() - { - return dataOffset; - } - - /** - * Returns the length of the data portion of this message. - * - * @return the length of the data portion of this message. - */ - public int dataLength() - { - return dataLength; - } - - /** - * Returns the buffer containing this message, the data AND the header. Use {@link #dataOffset()} - * and {@link #dataLength()} to get the data portion. - * - * @return the buffer containing this message. - */ - public ByteBuffer messageBuffer() - { - ByteBuffer messageBuffer = messageBufferRef == null ? null : messageBufferRef.get(); - if (messageBuffer == null) - { - messageBuffer = dataInput.getByteBuffer(dataOffset, dataLength, false); - messageBufferRef = new WeakReference<>(messageBuffer); - } - return messageBuffer; - } - - public byte[] messageData() - { - byte[] messageData = messageDataRef == null ? null : messageDataRef.get(); - - if (messageData == null) - { - messageData = dataInput.getBytes(dataOffset, dataLength); - messageDataRef = new WeakReference<>(messageData); - } - return messageData; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-channelId = " + channelId; - out += "\n\t-sequence = " + sequence; - out += "\n\t-logTime = " + logTime; - out += "\n\t-publishTime = " + publishTime; - // out += "\n\t-data = " + data; - return out; - } - } - - public static class TupleStrStr implements MCAPElement - { - private final String key; - private final String value; - - public TupleStrStr(MCAPDataInput dataInput, long elementPosition) - { - dataInput.position(elementPosition); - key = dataInput.getString(); - value = dataInput.getString(); - } - - @Override - public long getElementLength() - { - return key.length() + value.length() + 2 * Integer.BYTES; - } - - public String key() - { - return key; - } - - public String value() - { - return value; - } - - @Override - public String toString() - { - return (key + ": " + value).replace("\n", ""); - } - } - - public static class MetadataIndex implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long metadataOffset; - private final long metadataLength; - private final String name; - private WeakReference metadataRef; - - private MetadataIndex(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - metadataOffset = checkPositiveLong(dataInput.getLong(), "metadataOffset"); - metadataLength = checkPositiveLong(dataInput.getLong(), "metadataLength"); - name = dataInput.getString(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 2 * Long.BYTES + Integer.BYTES + name.length(); - } - - public Record metadata() - { - Record metadata = metadataRef == null ? null : metadataRef.get(); - - if (metadata == null) - { - metadata = new Record(dataInput, metadataOffset); - metadataRef = new WeakReference<>(metadata); - } - return metadata; - } - - public long metadataOffset() - { - return metadataOffset; - } - - public long metadataLength() - { - return metadataLength; - } - - public String name() - { - return name; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ": "; - out += "\n\t-metadataOffset = " + metadataOffset; - out += "\n\t-metadataLength = " + metadataLength; - out += "\n\t-name = " + name; - return out; - } - } - - public static class Magic implements MCAPElement - { - public static final int MAGIC_SIZE = 8; - public static final byte[] MAGIC_BYTES = {-119, 77, 67, 65, 80, 48, 13, 10}; - - private final byte[] magic; - - public Magic(MCAPDataInput dataInput, long elementPosition) - { - dataInput.position(elementPosition); - magic = dataInput.getBytes(MAGIC_SIZE); - if (!(Arrays.equals(magic, MAGIC_BYTES))) - throw new IllegalArgumentException("Invalid magic bytes: " + Arrays.toString(magic) + ". Expected: " + Arrays.toString(MAGIC_BYTES)); - } - - @Override - public long getElementLength() - { - return MAGIC_SIZE; - } - - public byte[] magic() - { - return magic; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-magic = " + Arrays.toString(magic); - return indent(out, indent); - } - } - - public static class Records extends ArrayList - { - public Records(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - parseList(dataInput, Record::new, elementPosition, elementLength, this); - } - - @Override - public String toString() - { - return toString(0); - } - - public String toString(int indent) - { - if (isEmpty()) - return indent(getClass().getSimpleName() + ": []", indent); - - String out = getClass().getSimpleName() + "[\n"; - out += EuclidCoreIOTools.getCollectionString("\n", this, r -> r.toString(indent + 1)); - return indent(out, indent); - } - } - - public static class Footer implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long ofsSummarySection; - private final long ofsSummaryOffsetSection; - private final long summaryCrc32; - private Integer ofsSummaryCrc32Input; - private Records summaryOffsetSection; - private Records summarySection; - - public Footer(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - ofsSummarySection = checkPositiveLong(dataInput.getLong(), "ofsSummarySection"); - ofsSummaryOffsetSection = checkPositiveLong(dataInput.getLong(), "ofsSummaryOffsetSection"); - summaryCrc32 = dataInput.getUnsignedInt(); - checkLength(elementLength, getElementLength()); - } - - @Override - public long getElementLength() - { - return 2 * Long.BYTES + Integer.BYTES; - } - - public Records summarySection() - { - if (summarySection == null && ofsSummarySection != 0) - { - long length = ((ofsSummaryOffsetSection != 0 ? ofsSummaryOffsetSection : computeOffsetFooter(dataInput)) - ofsSummarySection); - summarySection = new Records(dataInput, ofsSummarySection, (int) length); - } - return summarySection; - } - - public Records summaryOffsetSection() - { - if (summaryOffsetSection == null && ofsSummaryOffsetSection != 0) - { - summaryOffsetSection = new Records(dataInput, ofsSummaryOffsetSection, (int) (computeOffsetFooter(dataInput) - ofsSummaryOffsetSection)); - } - return summaryOffsetSection; - } - - public Integer ofsSummaryCrc32Input() - { - if (ofsSummaryCrc32Input == null) - { - ofsSummaryCrc32Input = (int) ((ofsSummarySection() != 0 ? ofsSummarySection() : computeOffsetFooter(dataInput))); - } - return ofsSummaryCrc32Input; - } - - private byte[] summaryCrc32Input; - - public byte[] summaryCrc32Input() - { - if (summaryCrc32Input == null) - { - long length = dataInput.size() - ofsSummaryCrc32Input() - 8 - 4; - summaryCrc32Input = dataInput.getBytes(ofsSummaryCrc32Input(), (int) length); - } - return summaryCrc32Input; - } - - public long ofsSummarySection() - { - return ofsSummarySection; - } - - public long ofsSummaryOffsetSection() - { - return ofsSummaryOffsetSection; - } - - /** - * A CRC-32 of all bytes from the start of the Summary section up through and including the end of - * the previous field (summary_offset_start) in the footer record. A value of 0 indicates the CRC-32 - * is not available. - */ - public long summaryCrc32() - { - return summaryCrc32; - } - - @Override - public String toString() - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-ofsSummarySection = " + ofsSummarySection; - out += "\n\t-ofsSummaryOffsetSection = " + ofsSummaryOffsetSection; - out += "\n\t-summaryCrc32 = " + summaryCrc32; - return out; - } - } - - public static class Record implements MCAPElement - { - public static final int RECORD_HEADER_LENGTH = 9; - - private final MCAPDataInput dataInput; - - private final Opcode op; - private final long bodyLength; - private final long bodyOffset; - private WeakReference bodyRef; - - public Record(MCAPDataInput dataInput) - { - this(dataInput, dataInput.position()); - } - - public Record(MCAPDataInput dataInput, long elementPosition) - { - this.dataInput = dataInput; - - dataInput.position(elementPosition); - op = Opcode.byId(dataInput.getUnsignedByte()); - bodyLength = checkPositiveLong(dataInput.getLong(), "bodyLength"); - bodyOffset = dataInput.position(); - checkLength(getElementLength(), (int) (bodyLength + RECORD_HEADER_LENGTH)); - } - - public Opcode op() - { - return op; - } - - public long bodyLength() - { - return bodyLength; - } - - public Object body() - { - Object body = bodyRef == null ? null : bodyRef.get(); - - if (body == null) - { - if (op == null) - { - body = dataInput.getBytes(bodyOffset, (int) bodyLength); - } - else - { - body = switch (op) - { - case MESSAGE -> new Message(dataInput, bodyOffset, bodyLength); - case METADATA_INDEX -> new MetadataIndex(dataInput, bodyOffset, bodyLength); - case CHUNK -> new Chunk(dataInput, bodyOffset, bodyLength); - case SCHEMA -> new Schema(dataInput, bodyOffset, bodyLength); - case CHUNK_INDEX -> new ChunkIndex(dataInput, bodyOffset, bodyLength); - case DATA_END -> new DataEnd(dataInput, bodyOffset, bodyLength); - case ATTACHMENT_INDEX -> new AttachmentIndex(dataInput, bodyOffset, bodyLength); - case STATISTICS -> new Statistics(dataInput, bodyOffset, bodyLength); - case MESSAGE_INDEX -> new MessageIndex(dataInput, bodyOffset, bodyLength); - case CHANNEL -> new Channel(dataInput, bodyOffset, bodyLength); - case METADATA -> new Metadata(dataInput, bodyOffset, bodyLength); - case ATTACHMENT -> new Attachment(dataInput, bodyOffset, bodyLength); - case HEADER -> new Header(dataInput, bodyOffset, bodyLength); - case FOOTER -> new Footer(dataInput, bodyOffset, bodyLength); - case SUMMARY_OFFSET -> new SummaryOffset(dataInput, bodyOffset, bodyLength); - }; - } - - bodyRef = new WeakReference<>(body); - } - return body; - } - - @Override - public long getElementLength() - { - return RECORD_HEADER_LENGTH + bodyLength; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-op = " + op; - out += "\n\t-bodyLength = " + bodyLength; - out += "\n\t-bodyOffset = " + bodyOffset; - Object body = body(); - out += "\n\t-body = " + (body == null ? "null" : "\n" + ((MCAPElement) body).toString(indent + 2)); - return indent(out, indent); - } - } - - public static class ChunkIndex implements MCAPElement - { - private final MCAPDataInput dataInput; - private final long elementLength; - /** - * Earliest message log_time in the chunk. Zero if the chunk has no messages. - */ - private final long messageStartTime; - /** - * Latest message log_time in the chunk. Zero if the chunk has no messages. - */ - private final long messageEndTime; - /** - * Offset to the chunk record from the start of the file. - */ - private final long chunkOffset; - /** - * Byte length of the chunk record, including opcode and length prefix. - */ - private final long chunkLength; - private final long messageIndexOffsetsOffset; - /** - * Total length in bytes of the message index records after the chunk. - */ - private final long messageIndexOffsetsLength; - /** - * Mapping from channel ID to the offset of the message index record for that channel after the - * chunk, from the start of the file. An empty map indicates no message indexing is available. - */ - private WeakReference messageIndexOffsetsRef; - /** - * Total length in bytes of the message index records after the chunk. - */ - private final long messageIndexLength; - /** - * The compression used within the chunk. Refer to well-known compression formats. This field should - * match the the value in the corresponding Chunk record. - */ - private final String compression; - /** - * The size of the chunk records field. - */ - private final long compressedSize; - /** - * The uncompressed size of the chunk records field. This field should match the value in the - * corresponding Chunk record. - */ - private final long uncompressedSize; - - private ChunkIndex(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.dataInput = dataInput; - this.elementLength = elementLength; - - dataInput.position(elementPosition); - messageStartTime = checkPositiveLong(dataInput.getLong(), "messageStartTime"); - messageEndTime = checkPositiveLong(dataInput.getLong(), "messageEndTime"); - chunkOffset = checkPositiveLong(dataInput.getLong(), "chunkOffset"); - chunkLength = checkPositiveLong(dataInput.getLong(), "chunkLength"); - messageIndexOffsetsLength = dataInput.getUnsignedInt(); - messageIndexOffsetsOffset = dataInput.position(); - dataInput.skip(messageIndexOffsetsLength); - messageIndexLength = checkPositiveLong(dataInput.getLong(), "messageIndexLength"); - compression = dataInput.getString(); - compressedSize = checkPositiveLong(dataInput.getLong(), "compressedSize"); - uncompressedSize = checkPositiveLong(dataInput.getLong(), "uncompressedSize"); - } - - @Override - public long getElementLength() - { - return elementLength; - } - - public static class MessageIndexOffset implements MCAPElement - { - /** - * Channel ID. - */ - private final int channelId; - /** - * Offset of the message index record for that channel after the chunk, from the start of the file. - */ - private final long offset; - - public MessageIndexOffset(MCAPDataInput dataInput, long elementPosition) - { - dataInput.position(elementPosition); - channelId = dataInput.getUnsignedShort(); - offset = checkPositiveLong(dataInput.getLong(), "offset"); - } - - @Override - public long getElementLength() - { - return Short.BYTES + Long.BYTES; - } - - public int channelId() - { - return channelId; - } - - public long offset() - { - return offset; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-channelId = " + channelId; - out += "\n\t-offset = " + offset; - return indent(out, indent); - } - } - - public static class MessageIndexOffsets implements MCAPElement - { - private final List entries; - private final long elementLength; - - public MessageIndexOffsets(MCAPDataInput dataInput, long elementPosition, long elementLength) - { - this.elementLength = elementLength; - - entries = new ArrayList<>(); - - long currentPos = elementPosition; - long remaining = elementLength; - - while (remaining > 0) - { - MessageIndexOffset entry = new MessageIndexOffset(dataInput, currentPos); - entries.add(entry); - currentPos += entry.getElementLength(); - remaining -= entry.getElementLength(); - } - - if (remaining != 0) - throw new IllegalArgumentException( - "Invalid element length. Expected: " + elementLength + ", remaining: " + remaining + ", entries: " + entries.size()); - } - - @Override - public long getElementLength() - { - return elementLength; - } - - public List entries() - { - return entries; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-entries = " + (entries == null ? "null" : "\n" + EuclidCoreIOTools.getCollectionString("\n", entries, e -> e.toString(indent + 1))); - return indent(out, indent); - } - } - - private WeakReference chunkRef; - - public Record chunk() - { - Record chunk = chunkRef == null ? null : chunkRef.get(); - - if (chunk == null) - { - chunk = new Record(dataInput, chunkOffset); - chunkRef = new WeakReference<>(chunk); - } - return chunkRef.get(); - } - - public long messageStartTime() - { - return messageStartTime; - } - - public long messageEndTime() - { - return messageEndTime; - } - - public long chunkOffset() - { - return chunkOffset; - } - - public long chunkLength() - { - return chunkLength; - } - - public long messageIndexOffsetsLength() - { - return messageIndexOffsetsLength; - } - - public MessageIndexOffsets messageIndexOffsets() - { - MessageIndexOffsets messageIndexOffsets = messageIndexOffsetsRef == null ? null : messageIndexOffsetsRef.get(); - - if (messageIndexOffsets == null) - { - messageIndexOffsets = new MessageIndexOffsets(dataInput, messageIndexOffsetsOffset, messageIndexOffsetsLength); - messageIndexOffsetsRef = new WeakReference<>(messageIndexOffsets); - } - - return messageIndexOffsets; - } - - public long messageIndexLength() - { - return messageIndexLength; - } - - public String compression() - { - return compression; - } - - public long compressedSize() - { - return compressedSize; - } - - public long uncompressedSize() - { - return uncompressedSize; - } - - @Override - public String toString() - { - return toString(0); - } - - @Override - public String toString(int indent) - { - String out = getClass().getSimpleName() + ":"; - out += "\n\t-messageStartTime = " + messageStartTime; - out += "\n\t-messageEndTime = " + messageEndTime; - out += "\n\t-chunkOffset = " + chunkOffset; - out += "\n\t-chunkLength = " + chunkLength; - out += "\n\t-messageIndexOffsetsLength = " + messageIndexOffsetsLength; - // out += "\n\t-messageIndexOffsets = " + (messageIndexOffsets == null ? "null" : "\n" + messageIndexOffsets.toString(indent + 1)); - out += "\n\t-messageIndexLength = " + messageIndexLength; - out += "\n\t-compression = " + compression; - out += "\n\t-compressedSize = " + compressedSize; - out += "\n\t-uncompressedSize = " + uncompressedSize; - return indent(out, indent); - } - } - - public interface MCAPElement - { - long getElementLength(); - - default String toString(int indent) - { - return indent(toString(), indent); - } - } - - public static long computeOffsetFooter(MCAPDataInput dataInput) - { - return (((((dataInput.size() - 1L) - 8L) - 20L) - 8L)); - } - - public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser) - { - return parseList(dataInput, elementParser, dataInput.getUnsignedInt()); - } - - public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser, long length) - { - return parseList(dataInput, elementParser, dataInput.position(), length); - } - - public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser, long offset, long length) - { - return parseList(dataInput, elementParser, offset, length, null); - } - - public static List parseList(MCAPDataInput dataInput, - MCAPDataReader elementParser, - long offset, - long length, - List listToPack) - { - long position = offset; - long limit = position + length; - if (listToPack == null) - listToPack = new ArrayList<>(); - - while (position < limit) - { - T parsed = elementParser.parse(dataInput, position); - listToPack.add(parsed); - position += parsed.getElementLength(); - } - - return listToPack; - } - - public interface MCAPDataReader - { - T parse(MCAPDataInput dataInput, long position); - } - - private static String indent(String stringToIndent, int indent) - { - if (indent <= 0) - return stringToIndent; - String indentStr = "\t".repeat(indent); - return indentStr + stringToIndent.replace("\n", "\n" + indentStr); - } - - private static int checkPositiveInt(int value, String name) - { - if (value < 0) - throw new IllegalArgumentException(name + " must be positive. Value: " + value); - return value; - } - - private static long checkPositiveLong(long value, String name) - { - if (value < 0) - throw new IllegalArgumentException(name + " must be positive. Value: " + value); - return value; - } - - private static void checkLength(long expectedLength, long actualLength) - { - if (actualLength != expectedLength) - throw new IllegalArgumentException("Unexpected length: expected= " + expectedLength + ", actual= " + actualLength); - } -} \ No newline at end of file diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPBufferedChunk.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPBufferedChunk.java index 420baa306..c5034f084 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPBufferedChunk.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPBufferedChunk.java @@ -3,13 +3,15 @@ import gnu.trove.map.hash.TLongObjectHashMap; import us.ihmc.commons.thread.ThreadTools; import us.ihmc.log.LogTools; -import us.ihmc.scs2.session.mcap.MCAP.Chunk; -import us.ihmc.scs2.session.mcap.MCAP.ChunkIndex; -import us.ihmc.scs2.session.mcap.MCAP.Message; -import us.ihmc.scs2.session.mcap.MCAP.Opcode; -import us.ihmc.scs2.session.mcap.MCAP.Record; -import us.ihmc.scs2.session.mcap.MCAP.Records; import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Chunk; +import us.ihmc.scs2.session.mcap.specs.records.ChunkIndex; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.RecordDataInputBacked; +import us.ihmc.scs2.session.mcap.specs.records.Records; import java.io.IOException; import java.nio.ByteBuffer; @@ -55,7 +57,7 @@ public MCAPBufferedChunk(MCAP mcap, long desiredLogDT) { Chunk chunk = (Chunk) record.body(); numberOfChunks++; - long chunkSize = chunk.recordsLength(); + long chunkSize = chunk.recordsCompressedLength(); minChunkSize = Math.min(minChunkSize, chunkSize); maxChunkSize = Math.max(maxChunkSize, chunkSize); totalChunkSize += chunkSize; @@ -325,7 +327,7 @@ private void loadChunkNow() throws IOException if (chunkRecords == null) { ByteBuffer chunkBuffer = mcap.getDataInput().getByteBuffer(chunkIndex.chunkOffset(), (int) chunkIndex.chunkLength(), true); - chunkRecords = ((Chunk) new Record(MCAPDataInput.wrap(chunkBuffer), 0).body()).records(); + chunkRecords = ((Chunk) new RecordDataInputBacked(MCAPDataInput.wrap(chunkBuffer), 0).body()).records(); } if (!loadedChunkBundles.contains(this)) @@ -354,15 +356,15 @@ public void loadMessagesNow() throws IOException try { + if (bundledMessages == null) + bundledMessages = new TLongObjectHashMap<>(); + for (Record record : chunkRecords) { if (record.op() != Opcode.MESSAGE) continue; - if (bundledMessages == null) - bundledMessages = new TLongObjectHashMap<>(); - - Message message = (Message) record.body(); + Message message = record.body(); List messages = bundledMessages.get(round(message.logTime(), desiredLogDT)); if (messages == null) { diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPConsoleLogManager.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPConsoleLogManager.java index 257c5ab0d..233965c0e 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPConsoleLogManager.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPConsoleLogManager.java @@ -1,12 +1,14 @@ package us.ihmc.scs2.session.mcap; import us.ihmc.log.LogTools; -import us.ihmc.scs2.session.mcap.MCAP.Channel; -import us.ihmc.scs2.session.mcap.MCAP.Message; -import us.ihmc.scs2.session.mcap.MCAP.Opcode; -import us.ihmc.scs2.session.mcap.MCAP.Record; -import us.ihmc.scs2.session.mcap.MCAP.Schema; import us.ihmc.scs2.session.mcap.MCAPBufferedChunk.ChunkBundle; +import us.ihmc.scs2.session.mcap.encoding.CDRDeserializer; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Channel; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.Schema; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; import us.ihmc.scs2.simulation.SpyList; import java.io.IOException; diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPFrameTransformManager.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPFrameTransformManager.java index d170f8b0f..3517b73cf 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPFrameTransformManager.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPFrameTransformManager.java @@ -9,11 +9,15 @@ import us.ihmc.scs2.definition.yoGraphic.YoGraphicDefinition; import us.ihmc.scs2.definition.yoGraphic.YoGraphicDefinitionFactory; import us.ihmc.scs2.definition.yoGraphic.YoGraphicGroupDefinition; -import us.ihmc.scs2.session.mcap.MCAP.Message; -import us.ihmc.scs2.session.mcap.MCAP.Opcode; -import us.ihmc.scs2.session.mcap.MCAP.Record; import us.ihmc.scs2.session.mcap.MCAPBufferedChunk.ChunkBundle; import us.ihmc.scs2.session.mcap.MCAPSchema.MCAPSchemaField; +import us.ihmc.scs2.session.mcap.encoding.CDRDeserializer; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Channel; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.Schema; import us.ihmc.yoVariables.euclid.YoPoint3D; import us.ihmc.yoVariables.euclid.YoPose3D; import us.ihmc.yoVariables.euclid.YoQuaternion; @@ -63,7 +67,7 @@ public class MCAPFrameTransformManager private final Set unattachedRootNames = new LinkedHashSet<>(); private final YoGraphicGroupDefinition yoGraphicGroupDefinition = new YoGraphicGroupDefinition("FoxgloveFrameTransforms"); - private MCAP.Schema mcapSchema; + private Schema mcapSchema; public MCAPFrameTransformManager(ReferenceFrame inertialFrame) { @@ -72,12 +76,12 @@ public MCAPFrameTransformManager(ReferenceFrame inertialFrame) public void initialize(MCAP mcap, MCAPBufferedChunk chunkBuffer) throws IOException { - for (MCAP.Record record : mcap.records()) + for (Record record : mcap.records()) { if (record.op() != Opcode.SCHEMA) continue; - mcapSchema = (MCAP.Schema) record.body(); + mcapSchema = (Schema) record.body(); if (mcapSchema.name().equalsIgnoreCase("foxglove::FrameTransform")) { if (mcapSchema.encoding().equalsIgnoreCase("ros2msg")) @@ -121,11 +125,11 @@ else if (mcapSchema.encoding().equalsIgnoreCase("omgidl")) } TIntObjectHashMap channelIdToTopicMap = new TIntObjectHashMap<>(); - for (MCAP.Record record : mcap.records()) + for (Record record : mcap.records()) { if (record.op() == Opcode.CHANNEL) { - MCAP.Channel channel = (MCAP.Channel) record.body(); + Channel channel = (Channel) record.body(); if (channel.schemaId() == foxgloveFrameTransformSchema.getId()) { channelIdToTopicMap.put(channel.id(), channel.topic()); @@ -200,7 +204,7 @@ else if (mcapSchema.encoding().equalsIgnoreCase("omgidl")) } } - private void processRecord(MCAP.Record record, TIntObjectHashMap channelIdToTopicMap, Map allTransforms) + private void processRecord(Record record, TIntObjectHashMap channelIdToTopicMap, Map allTransforms) { Message message = (Message) record.body(); String topic = channelIdToTopicMap.get(message.channelId()); @@ -377,7 +381,7 @@ public boolean hasMCAPFrameTransforms() return foxgloveFrameTransformSchema != null; } - public MCAP.Schema getMCAPSchema() + public Schema getMCAPSchema() { return mcapSchema; } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogCropper.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogCropper.java new file mode 100644 index 000000000..5e75361f3 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogCropper.java @@ -0,0 +1,270 @@ +package us.ihmc.scs2.session.mcap; + +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Attachment; +import us.ihmc.scs2.session.mcap.specs.records.Channel; +import us.ihmc.scs2.session.mcap.specs.records.Chunk; +import us.ihmc.scs2.session.mcap.specs.records.Footer; +import us.ihmc.scs2.session.mcap.specs.records.Magic; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.MutableRecord; +import us.ihmc.scs2.session.mcap.specs.records.MutableStatistics; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.Schema; +import us.ihmc.scs2.session.mcap.specs.records.SummaryOffset; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class MCAPLogCropper +{ + private final MCAP mcap; + private long startTimestamp; + private long endTimestamp; + private OutputFormat outputFormat; + + public enum OutputFormat + { + MCAP + } + + public MCAPLogCropper(MCAP mcap) + { + this.mcap = mcap; + } + + public void setStartTimestamp(long startTimestamp) + { + this.startTimestamp = startTimestamp; + } + + public void setEndTimestamp(long endTimestamp) + { + this.endTimestamp = endTimestamp; + } + + public void setOutputFormat(OutputFormat outputFormat) + { + this.outputFormat = outputFormat; + } + + public void crop(FileOutputStream outputStream) throws IOException + { + MCAPDataOutput dataOutput = MCAPDataOutput.wrap(outputStream.getChannel()); + dataOutput.putBytes(Magic.MAGIC_BYTES); // header magic + + // Used to store the records from one chunk to the next. + // Some chunks may not have any message left after cropping, so we'll move the schemas and channels to the next chunk when that happens. + List recordsForNextChunk = null; + + // Creating groups in the following order. There will be right after DATA_END + Map schemas = new LinkedHashMap<>(); // Schemas in a map to avoid duplicates + Map channels = new LinkedHashMap<>(); // Channels in a map to avoid duplicates + List chunkIndices = new ArrayList<>(); + List attachmentIndices = new ArrayList<>(); + List metadataIndices = new ArrayList<>(); + MutableStatistics statistics = new MutableStatistics(); + + for (Record record : mcap.records()) + { + switch (record.op()) + { + case CHUNK_INDEX: + case MESSAGE_INDEX: + case ATTACHMENT_INDEX: + case METADATA_INDEX: + case FOOTER: + { + // We re-build the indices and footer from scratch down there. + continue; + } + case HEADER: + { + record.write(dataOutput); + break; + } + case DATA_END: // TODO The CRC32 should probably be recalculated + { + record.write(dataOutput); + // Now we can write the groups + long summarySectionOffset = dataOutput.position(); + long schemaOffset = dataOutput.position(); + List schemaList = new ArrayList<>(schemas.values()); + schemaList.forEach(r -> r.write(dataOutput)); + long channelOffset = dataOutput.position(); + List channelList = new ArrayList<>(channels.values()); + channelList.forEach(r -> r.write(dataOutput)); + long chunkIndexOffset = dataOutput.position(); + chunkIndices.forEach(r -> r.write(dataOutput)); + long attachmentIndexOffset = dataOutput.position(); + attachmentIndices.forEach(r -> r.write(dataOutput)); + long metadataIndexOffset = dataOutput.position(); + metadataIndices.forEach(r -> r.write(dataOutput)); + long statisticsOffset = dataOutput.position(); + MutableRecord statisticsRecord = new MutableRecord(statistics); + statisticsRecord.write(dataOutput); + + List summarySectionRecords = new ArrayList<>(); + summarySectionRecords.addAll(schemaList); + summarySectionRecords.addAll(channelList); + summarySectionRecords.addAll(chunkIndices); + summarySectionRecords.addAll(attachmentIndices); + summarySectionRecords.addAll(metadataIndices); + summarySectionRecords.add(statisticsRecord); + + List summaryOffsetSectionRecords = new ArrayList<>(); + if (!schemas.isEmpty()) + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(schemaOffset, schemaList)); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + if (!channels.isEmpty()) + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(channelOffset, channelList)); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + if (!chunkIndices.isEmpty()) + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(chunkIndexOffset, chunkIndices)); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + if (!attachmentIndices.isEmpty()) + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(attachmentIndexOffset, attachmentIndices)); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + if (!metadataIndices.isEmpty()) + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(metadataIndexOffset, metadataIndices)); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + { + MutableRecord summaryOffset = new MutableRecord(new SummaryOffset(statisticsOffset, Collections.singletonList(statisticsRecord))); + summaryOffsetSectionRecords.add(summaryOffset); + summaryOffset.write(dataOutput); + } + + MutableRecord footer = new MutableRecord(new Footer(summarySectionOffset, summarySectionRecords, summaryOffsetSectionRecords)); + footer.write(dataOutput); + break; + } + case SCHEMA: + { + Schema schema = record.body(); + if (schemas.put(schema.id(), record) == null) + statistics.incrementCount(Opcode.SCHEMA); + break; + } + case CHANNEL: + { + Channel channel = record.body(); + if (channels.put(channel.topic(), record) == null) + statistics.incrementCount(Opcode.CHANNEL); + break; + } + case CHUNK: + { + Chunk chunk = record.body(); + if (chunk.messageStartTime() > endTimestamp) + { + continue; + } + + // We need to possibly crop the chunk and likely re-build the various indices. + long chunkOffset = dataOutput.position(); + + Chunk croppedChunk = chunk.crop(startTimestamp, endTimestamp); + if (croppedChunk == null) + continue; + + MutableRecord croppedChunkRecord = new MutableRecord(croppedChunk); + + if (croppedChunk.records().stream().noneMatch(r -> r.op() == Opcode.MESSAGE)) + { + if (recordsForNextChunk != null) + recordsForNextChunk.addAll(croppedChunk.records()); + else + recordsForNextChunk = new ArrayList<>(croppedChunk.records()); + continue; // We don't want to write a chunk with no message. The schemas and channels will be moved to the next chunk. + } + + if (recordsForNextChunk != null) + { + croppedChunk.records().addAll(0, recordsForNextChunk); + recordsForNextChunk = null; + } + + for (Record insideCroppedChunkRecord : croppedChunk.records()) + { + switch (insideCroppedChunkRecord.op()) + { + case MESSAGE: + { + statistics.incrementCount(insideCroppedChunkRecord.op()); + statistics.incrementChannelMessageCount(((Message) insideCroppedChunkRecord.body()).channelId()); + break; + } + case SCHEMA: + { + Schema schema = insideCroppedChunkRecord.body(); + if (schemas.put(schema.id(), insideCroppedChunkRecord) == null) + statistics.incrementCount(Opcode.SCHEMA); + break; + } + case CHANNEL: + { + Channel channel = insideCroppedChunkRecord.body(); + if (channels.put(channel.topic(), insideCroppedChunkRecord) == null) + statistics.incrementCount(Opcode.CHANNEL); + break; + } + } + } + + List croppedMessageIndexRecords = croppedChunk.records().generateMessageIndexList().stream().map(MutableRecord::new).toList(); + chunkIndices.add(croppedChunkRecord.generateChunkIndexRecord(chunkOffset, croppedMessageIndexRecords)); + // Update statistics + statistics.incrementChunkCount(); + statistics.updateMessageTimes(croppedChunk.messageStartTime(), croppedChunk.messageEndTime()); + croppedChunkRecord.write(dataOutput, true); + croppedMessageIndexRecords.forEach(r -> r.write(dataOutput, true)); + break; + } + case ATTACHMENT: + { + Attachment attachment = record.body(); + if (attachment.logTime() >= startTimestamp && attachment.logTime() <= endTimestamp) + { + long attachmentOffset = dataOutput.position(); + record.write(dataOutput, true); + attachmentIndices.add(record.generateAttachmentIndexRecord(attachmentOffset)); + statistics.incrementCount(record.op()); + } + break; + } + case METADATA: + { + long metadataOffset = dataOutput.position(); + record.write(dataOutput, true); + metadataIndices.add(record.generateMetadataIndexRecord(metadataOffset)); + statistics.incrementCount(record.op()); + } + break; + } + } + dataOutput.putBytes(Magic.MAGIC_BYTES); // footer magic + dataOutput.close(); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogFileReader.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogFileReader.java index 2d637e85f..3d0dd5ef2 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogFileReader.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogFileReader.java @@ -7,7 +7,14 @@ import us.ihmc.log.LogTools; import us.ihmc.scs2.definition.yoGraphic.YoGraphicDefinition; import us.ihmc.scs2.session.SessionIOTools; -import us.ihmc.scs2.session.mcap.MCAP.Schema; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Channel; +import us.ihmc.scs2.session.mcap.specs.records.Chunk; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.Schema; import us.ihmc.scs2.sharedMemory.tools.SharedMemoryTools; import us.ihmc.scs2.simulation.robot.Robot; import us.ihmc.yoVariables.registry.YoNamespace; @@ -28,8 +35,8 @@ public class MCAPLogFileReader { - private static final Set SCHEMA_TO_IGNORE = Set.of("foxglove::Grid", "foxglove::SceneUpdate", "foxglove::FrameTransforms", "HandDeviceHealth"); - private static final Path SCS2_MCAP_DEBUG_HOME = SessionIOTools.SCS2_HOME.resolve("mcap-debug"); + public static final Set SCHEMA_TO_IGNORE = Set.of("foxglove::Grid", "foxglove::SceneUpdate", "foxglove::FrameTransforms", "HandDeviceHealth"); + public static final Path SCS2_MCAP_DEBUG_HOME = SessionIOTools.SCS2_HOME.resolve("mcap-debug"); static { @@ -51,7 +58,7 @@ public class MCAPLogFileReader private final MCAPMessageManager messageManager; private final MCAPConsoleLogManager consoleLogManager; private final TIntObjectHashMap schemas = new TIntObjectHashMap<>(); - private final TIntObjectHashMap rawSchemas = new TIntObjectHashMap<>(); + private final TIntObjectHashMap rawSchemas = new TIntObjectHashMap<>(); private final TIntObjectHashMap yoMessageMap = new TIntObjectHashMap<>(); private final MCAPFrameTransformManager frameTransformManager; private final YoLong currentChunkStartTimestamp = new YoLong("MCAPCurrentChunkStartTimestamp", propertiesRegistry); @@ -165,17 +172,17 @@ private void loadSchemas() throws IOException } catch (Exception e) { - MCAP.Schema schema = frameTransformManager.getMCAPSchema(); + Schema schema = frameTransformManager.getMCAPSchema(); File debugFile = exportSchemaToFile(SCS2_MCAP_DEBUG_HOME, schema, e); LogTools.error("Failed to load schema: " + schema.name() + ", saved to: " + debugFile.getAbsolutePath()); throw e; } - for (MCAP.Record record : mcap.records()) + for (Record record : mcap.records()) { - if (record.op() != MCAP.Opcode.SCHEMA) + if (record.op() != Opcode.SCHEMA) continue; - MCAP.Schema schema = (MCAP.Schema) record.body(); + Schema schema = (Schema) record.body(); rawSchemas.put(schema.id(), schema); @@ -205,11 +212,11 @@ else if (schema.encoding().equalsIgnoreCase("omgidl")) private void loadChannels() throws IOException { - for (MCAP.Record record : mcap.records()) + for (Record record : mcap.records()) { - if (record.op() != MCAP.Opcode.CHANNEL) + if (record.op() != Opcode.CHANNEL) continue; - MCAP.Channel channel = (MCAP.Channel) record.body(); + Channel channel = (Channel) record.body(); if (frameTransformManager.hasMCAPFrameTransforms() && channel.schemaId() == frameTransformManager.getFrameTransformSchema().getId()) continue; @@ -295,16 +302,16 @@ public boolean incrementTimestamp() public void readMessagesAtCurrentTimestamp() throws IOException { - List messages = messageManager.loadMessages(currentTimestamp.getValue()); + List messages = messageManager.loadMessages(currentTimestamp.getValue()); if (messages == null) { - LogTools.error("No messages at timestamp {}.", currentTimestamp.getValue()); + LogTools.warn("No messages at timestamp {}.", currentTimestamp.getValue()); return; } currentChunkStartTimestamp.set(messageManager.getActiveChunkStartTimestamp()); currentChunkEndTimestamp.set(messageManager.getActiveChunkEndTimestamp()); - for (MCAP.Message message : messages) + for (Message message : messages) { try { @@ -340,7 +347,7 @@ public void readMessagesAtCurrentTimestamp() throws IOException frameTransformManager.update(); } - public File exportSchemaToFile(Path path, MCAP.Schema schema, Exception e) throws IOException + public static File exportSchemaToFile(Path path, Schema schema, Exception e) throws IOException { String filename; if (e != null) @@ -357,7 +364,7 @@ public File exportSchemaToFile(Path path, MCAP.Schema schema, Exception e) throw return debugFile; } - private static void exportChannelToFile(Path path, MCAP.Channel channel, MCAPSchema schema, Exception e) throws IOException + public static void exportChannelToFile(Path path, Channel channel, MCAPSchema schema, Exception e) throws IOException { File debugFile; if (e != null) @@ -372,7 +379,7 @@ private static void exportChannelToFile(Path path, MCAP.Channel channel, MCAPSch pw.close(); } - private static void exportMessageDataToFile(Path path, MCAP.Message message, MCAPSchema schema, Exception e) throws IOException + public static void exportMessageDataToFile(Path path, Message message, MCAPSchema schema, Exception e) throws IOException { File debugFile; String prefix = "messageData-timestamp-%d-schema-%s"; @@ -389,6 +396,22 @@ private static void exportMessageDataToFile(Path path, MCAP.Message message, MCA os.close(); } + public static void exportChunkToFile(Path path, Chunk chunk, Exception e) throws IOException + { + File debugFile; + if (e != null) + debugFile = path.resolve("chunk-%d-%s.txt".formatted(chunk.messageStartTime(), e.getClass().getSimpleName())).toFile(); + else + debugFile = path.resolve("chunk-%d.txt".formatted(chunk.messageStartTime())).toFile(); + if (debugFile.exists()) + debugFile.delete(); + debugFile.createNewFile(); + FileOutputStream os = new FileOutputStream(debugFile); + MCAPDataOutput dataOutput = MCAPDataOutput.wrap(os.getChannel()); + chunk.write(dataOutput); + dataOutput.close(); + } + private static String cleanupName(String name) { return name.replace(':', '-'); @@ -404,7 +427,12 @@ public MCAPConsoleLogManager getConsoleLogManager() return consoleLogManager; } - public File getMcapFile() + public MCAP getMCAP() + { + return mcap; + } + + public File getMCAPFile() { return mcapFile; } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogSession.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogSession.java index b411ec3cd..7fc36fa6e 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogSession.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPLogSession.java @@ -415,6 +415,6 @@ public MCAPLogFileReader getMCAPLogFileReader() public File getMCAPFile() { - return mcapLogFileReader.getMcapFile(); + return mcapLogFileReader.getMCAPFile(); } } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPMessageManager.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPMessageManager.java index 74df50688..b2841b7a9 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPMessageManager.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPMessageManager.java @@ -2,6 +2,13 @@ import gnu.trove.list.array.TLongArrayList; import us.ihmc.scs2.session.mcap.MCAPBufferedChunk.ChunkBundle; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.ChunkIndex; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.MessageIndex; +import us.ihmc.scs2.session.mcap.specs.records.MessageIndexEntry; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; import java.io.IOException; import java.util.ArrayList; @@ -21,7 +28,7 @@ public class MCAPMessageManager /** * All the chunk indices of the MCAP file. */ - private final List mcapChunkIndices = new ArrayList<>(); + private final List mcapChunkIndices = new ArrayList<>(); private final MCAPBufferedChunk chunkBuffer; private ChunkBundle currentChunkBundle = null; private final long desiredLogDT; @@ -73,16 +80,16 @@ public int getNumberOfEntries() */ public void loadFromMCAP(MCAP mcap) { - for (MCAP.Record record : mcap.records()) + for (Record record : mcap.records()) { - if (record.op() == MCAP.Opcode.CHUNK_INDEX) + if (record.op() == Opcode.CHUNK_INDEX) { - mcapChunkIndices.add((MCAP.ChunkIndex) record.body()); + mcapChunkIndices.add((ChunkIndex) record.body()); } - else if (record.op() == MCAP.Opcode.MESSAGE_INDEX) + else if (record.op() == Opcode.MESSAGE_INDEX) { - MCAP.MessageIndex messageIndex = (MCAP.MessageIndex) record.body(); - for (MCAP.MessageIndex.MessageIndexEntry mcapEntry : messageIndex.messageIndexEntries()) + MessageIndex messageIndex = (MessageIndex) record.body(); + for (MessageIndexEntry mcapEntry : messageIndex.messageIndexEntries()) { long timestamp = round(mcapEntry.logTime(), desiredLogDT); if (allMessageTimestamps.isEmpty() || timestamp > allMessageTimestamps.get(allMessageTimestamps.size() - 1)) @@ -166,7 +173,7 @@ public long previousMessageTimestamp(long timestamp) /** * @return retrieves the messages at the given instant. */ - public List loadMessages(long timestamp) throws IOException + public List loadMessages(long timestamp) throws IOException { if (currentChunkBundle == null || timestamp < currentChunkBundle.startTime() || timestamp > currentChunkBundle.endTime()) { diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPSchema.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPSchema.java index 1fadf7a89..dcdc5ccf7 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPSchema.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/MCAPSchema.java @@ -1,7 +1,9 @@ package us.ihmc.scs2.session.mcap; import us.ihmc.euclid.tools.EuclidCoreIOTools; -import us.ihmc.scs2.session.mcap.MCAP.Schema; +import us.ihmc.scs2.session.mcap.encoding.CDRDeserializer; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.Schema; import java.util.ArrayList; import java.util.Arrays; @@ -227,7 +229,7 @@ public String toString(int indent) return indent(out, indent); } - public static String mcapMCAPMessageToString(MCAP.Message message, MCAPSchema schema) + public static String mcapMCAPMessageToString(Message message, MCAPSchema schema) { CDRDeserializer cdr = new CDRDeserializer(); cdr.initialize(message.messageBuffer(), 0, message.dataLength()); diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/OMGIDLSchemaParser.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/OMGIDLSchemaParser.java index 0017010a4..7b199a45b 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/OMGIDLSchemaParser.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/OMGIDLSchemaParser.java @@ -13,6 +13,7 @@ import us.ihmc.scs2.session.mcap.omgidl_parser.IDLListener; import us.ihmc.scs2.session.mcap.omgidl_parser.IDLParser; import us.ihmc.scs2.session.mcap.omgidl_parser.IDLParser.Enum_typeContext; +import us.ihmc.scs2.session.mcap.specs.records.Schema; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -24,7 +25,7 @@ public class OMGIDLSchemaParser { - public static MCAPSchema loadSchema(MCAP.Schema mcapSchema) throws IOException + public static MCAPSchema loadSchema(Schema mcapSchema) throws IOException { return loadSchema(mcapSchema.name(), mcapSchema.id(), new ByteBufferBackedInputStream(mcapSchema.data())); } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/ROS2SchemaParser.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/ROS2SchemaParser.java index 61da4fe5b..b3d6e7aab 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/ROS2SchemaParser.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/ROS2SchemaParser.java @@ -1,6 +1,7 @@ package us.ihmc.scs2.session.mcap; import us.ihmc.scs2.session.mcap.MCAPSchema.MCAPSchemaField; +import us.ihmc.scs2.session.mcap.specs.records.Schema; import java.util.LinkedHashMap; import java.util.List; @@ -16,12 +17,12 @@ public class ROS2SchemaParser public static final String SUB_SCHEMA_PREFIX = "MSG: fastdds/"; /** - * Loads a schema from the given {@link MCAP.Schema}. + * Loads a schema from the given {@link Schema}. * * @param mcapSchema the schema to load. * @return the loaded schema. */ - public static MCAPSchema loadSchema(MCAP.Schema mcapSchema) + public static MCAPSchema loadSchema(Schema mcapSchema) { return loadSchema(mcapSchema.name(), mcapSchema.id(), mcapSchema.data().array()); } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/YoMCAPMessage.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/YoMCAPMessage.java index 8f2b36840..4e1d07408 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/YoMCAPMessage.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/YoMCAPMessage.java @@ -2,6 +2,8 @@ import us.ihmc.log.LogTools; import us.ihmc.scs2.session.mcap.MCAPSchema.MCAPSchemaField; +import us.ihmc.scs2.session.mcap.encoding.CDRDeserializer; +import us.ihmc.scs2.session.mcap.specs.records.Message; import us.ihmc.yoVariables.registry.YoRegistry; import us.ihmc.yoVariables.variable.YoBoolean; import us.ihmc.yoVariables.variable.YoDouble; @@ -143,7 +145,7 @@ public int getChannelId() return channelId; } - public void readMessage(MCAP.Message message) + public void readMessage(Message message) { if (message.channelId() != channelId) throw new IllegalArgumentException("Expected channel ID: " + channelId + ", but received: " + message.channelId()); diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/CDRDeserializer.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/CDRDeserializer.java similarity index 99% rename from scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/CDRDeserializer.java rename to scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/CDRDeserializer.java index 741dadd50..94903bcd5 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/CDRDeserializer.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/CDRDeserializer.java @@ -1,4 +1,4 @@ -package us.ihmc.scs2.session.mcap; +package us.ihmc.scs2.session.mcap.encoding; import us.ihmc.idl.CDR; import us.ihmc.log.LogTools; diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoder.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoder.java similarity index 96% rename from scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoder.java rename to scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoder.java index acbb7f65b..b297fa642 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoder.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoder.java @@ -1,4 +1,4 @@ -package us.ihmc.scs2.session.mcap; +package us.ihmc.scs2.session.mcap.encoding; import net.jpountz.lz4.LZ4Exception; import net.jpountz.lz4.LZ4Factory; @@ -7,7 +7,6 @@ import net.jpountz.xxhash.XXHash32; import net.jpountz.xxhash.XXHashFactory; -import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -36,7 +35,6 @@ public class LZ4FrameDecoder 8 + // Content Size 1; // HC static final int INTEGER_BYTES = Integer.SIZE >>> 3; // or Integer.BYTES in Java 1.8 - static final int LONG_BYTES = Long.SIZE >>> 3; // or Long.BYTES in Java 1.8 static final int LZ4_FRAME_INCOMPRESSIBLE_MASK = 0x80000000; private final LZ4SafeDecompressor decompressor; @@ -53,11 +51,10 @@ public class LZ4FrameDecoder private FrameInfo frameInfo = null; /** - * Creates a new {@link InputStream} that will decompress data using fastest instances of + * Creates a new decoder that will decompress data using fastest instances of * {@link LZ4SafeDecompressor} and {@link XXHash32}. This instance will decompress all concatenated * frames in their sequential order. * - * @throws IOException if an I/O error occurs * @see LZ4Factory#fastestInstance() * @see XXHashFactory#fastestInstance() */ @@ -67,11 +64,10 @@ public LZ4FrameDecoder() } /** - * Creates a new {@link InputStream} that will decompress data using the LZ4 algorithm. + * Creates a new decoder that will decompress data using the LZ4 algorithm. * * @param decompressor the decompressor to use * @param checksum the hash function to use - * @throws IOException if an I/O error occurs */ public LZ4FrameDecoder(LZ4SafeDecompressor decompressor, XXHash32 checksum) { @@ -367,7 +363,7 @@ public boolean isFinished() public static class FLG { - private static final int DEFAULT_VERSION = 1; + public static final int DEFAULT_VERSION = 1; private final BitSet bitSet; private final int version; @@ -453,7 +449,7 @@ public static class BD private final BLOCKSIZE blockSizeValue; - private BD(BLOCKSIZE blockSizeValue) + public BD(BLOCKSIZE blockSizeValue) { this.blockSizeValue = blockSizeValue; } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncoder.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncoder.java new file mode 100644 index 000000000..89de698e4 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncoder.java @@ -0,0 +1,299 @@ +package us.ihmc.scs2.session.mcap.encoding; + +import net.jpountz.lz4.LZ4Compressor; +import net.jpountz.lz4.LZ4Factory; +import net.jpountz.xxhash.XXHash32; +import net.jpountz.xxhash.XXHashFactory; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder.BD; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder.BLOCKSIZE; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder.FLG; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder.FrameInfo; + +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +/** + * This class is a modified version of the original LZ4FrameOutputStream from the lz4-java project. + *

+ * This version allows to decode a byte array into another byte array, without using any {@link OutputStream}. + *

+ */ +public class LZ4FrameEncoder +{ + static final FLG.Bits[] DEFAULT_FEATURES = new FLG.Bits[] {FLG.Bits.BLOCK_INDEPENDENCE}; + + static final String CLOSED_STREAM = "The stream is already closed"; + + private final LZ4Compressor compressor; + private final XXHash32 checksum; + private final ByteBuffer blockBuffer; // Buffer for uncompressed input data + private final byte[] compressedBuffer; // Only allocated once so it can be reused + private final int maxBlockSize; + private final long knownSize; + private final ByteBuffer intLEBuffer = ByteBuffer.allocate(LZ4FrameDecoder.INTEGER_BYTES).order(ByteOrder.LITTLE_ENDIAN); + + private FrameInfo frameInfo = null; + + /** + * Creates a new encoder that will compress data of unknown size using the LZ4 algorithm. + * + * @param blockSize the BLOCKSIZE to use + * @param bits a set of features to use + * @see #LZ4FrameEncoder(BLOCKSIZE, long, FLG.Bits...) + */ + public LZ4FrameEncoder(BLOCKSIZE blockSize, FLG.Bits... bits) + { + this(blockSize, -1L, bits); + } + + /** + * Creates a new encoder that will compress data using using fastest instances of {@link LZ4Compressor} and {@link XXHash32}. + * + * @param blockSize the BLOCKSIZE to use + * @param knownSize the size of the uncompressed data. A value less than zero means unknown. + * @param bits a set of features to use + */ + public LZ4FrameEncoder(BLOCKSIZE blockSize, long knownSize, FLG.Bits... bits) + { + this(blockSize, knownSize, LZ4Factory.fastestInstance().fastCompressor(), XXHashFactory.fastestInstance().hash32(), bits); + } + + /** + * Creates a new encoder that will compress data using the specified instances of {@link LZ4Compressor} and {@link XXHash32}. + * + * @param blockSize the BLOCKSIZE to use + * @param knownSize the size of the uncompressed data. A value less than zero means unknown. + * @param compressor the {@link LZ4Compressor} instance to use to compress data + * @param checksum the {@link XXHash32} instance to use to check data for integrity + * @param bits a set of features to use + */ + public LZ4FrameEncoder(BLOCKSIZE blockSize, long knownSize, LZ4Compressor compressor, XXHash32 checksum, FLG.Bits... bits) + { + this.compressor = compressor; + this.checksum = checksum; + frameInfo = new FrameInfo(new FLG(FLG.DEFAULT_VERSION, bits), new BD(blockSize)); + maxBlockSize = frameInfo.getBD().getBlockMaximumSize(); + blockBuffer = ByteBuffer.allocate(maxBlockSize).order(ByteOrder.LITTLE_ENDIAN); + compressedBuffer = new byte[this.compressor.maxCompressedLength(maxBlockSize)]; + if (frameInfo.getFLG().isEnabled(FLG.Bits.CONTENT_SIZE) && knownSize < 0) + { + throw new IllegalArgumentException("Known size must be greater than zero in order to use the known size feature"); + } + this.knownSize = knownSize; + } + + /** + * Creates a new encoder that will compress data using the LZ4 algorithm. The block independence flag is set, and none of the other flags are + * set. + * + * @param blockSize the BLOCKSIZE to use + * @see #LZ4FrameEncoder(BLOCKSIZE, FLG.Bits...) + */ + public LZ4FrameEncoder(BLOCKSIZE blockSize) + { + this(blockSize, DEFAULT_FEATURES); + } + + /** + * Creates a new encoder that will compress data using the LZ4 algorithm with 4-MB blocks. + * + * @see #LZ4FrameEncoder(BLOCKSIZE) + */ + public LZ4FrameEncoder() + { + this(BLOCKSIZE.SIZE_4MB); + } + + public byte[] encode(byte[] in, byte[] out) + { + return encode(in, 0, in.length, out, 0); + } + + public byte[] encode(byte[] in, int inOffset, int inLength, byte[] out, int outOffset) + { + ByteBuffer resultBuffer = encode(ByteBuffer.wrap(in, inOffset, inLength), out == null ? null : ByteBuffer.wrap(out, outOffset, out.length - outOffset)); + if (resultBuffer == null) + return null; + byte[] result = new byte[resultBuffer.remaining()]; + resultBuffer.get(result); + return result; + } + + public ByteBuffer encode(ByteBuffer in, ByteBuffer out) + { + return encode(in, 0, in.remaining(), out, 0); + } + + public ByteBuffer encode(ByteBuffer in, int inOffset, int inLength, ByteBuffer out, int outOffset) + { + int limitPrev = in.limit(); + in.position(inOffset); + in.limit(inOffset + inLength); + if (out != null) + { + out.order(ByteOrder.LITTLE_ENDIAN); + out.position(outOffset); + } + + try + { + if (out != null) + { + writeHeader(out); + ensureNotFinished(); + + // while b will fill the buffer + while (in.remaining() > blockBuffer.remaining()) + { + int sizeWritten = blockBuffer.remaining(); + // fill remaining space in buffer + blockBuffer.put(in.slice(in.position(), sizeWritten)); + in.position(in.position() + sizeWritten); + writeBlock(out); + } + blockBuffer.put(in); + writeBlock(out); + writeEndMark(out); + out.flip(); + return out; + } + else + { + ByteBuffer whenOutIsNull = ByteBuffer.allocate(inLength + LZ4FrameDecoder.LZ4_MAX_HEADER_LENGTH).order(ByteOrder.LITTLE_ENDIAN); + writeHeader(whenOutIsNull); + ensureNotFinished(); + + while (in.remaining() > blockBuffer.remaining()) + { + int sizeWritten = blockBuffer.remaining(); + // fill remaining space in buffer + blockBuffer.put(in.slice(in.position(), sizeWritten)); + in.position(in.position() + sizeWritten); + + whenOutIsNull = ensureCapacity(whenOutIsNull, blockBuffer.limit()); + writeBlock(whenOutIsNull); + } + blockBuffer.put(in); + whenOutIsNull = ensureCapacity(whenOutIsNull, blockBuffer.limit()); + writeBlock(whenOutIsNull); + whenOutIsNull = ensureCapacity(whenOutIsNull, 2 * LZ4FrameDecoder.INTEGER_BYTES); + writeEndMark(whenOutIsNull); + + whenOutIsNull.flip(); + return whenOutIsNull; + } + } + finally + { + in.limit(limitPrev); + } + } + + private static ByteBuffer ensureCapacity(ByteBuffer buffer, int remainingNeeded) + { + if (buffer.remaining() >= remainingNeeded) + return buffer; + + ByteBuffer extended = ByteBuffer.allocate(buffer.capacity() + remainingNeeded); + extended.order(ByteOrder.LITTLE_ENDIAN); + buffer.flip(); + extended.put(buffer); + return extended; + } + + /** + * Writes the magic number and frame descriptor to the underlying {@link OutputStream}. + */ + private void writeHeader(ByteBuffer out) + { + if (out.remaining() < LZ4FrameDecoder.LZ4_MAX_HEADER_LENGTH) + { + throw new IllegalArgumentException("The provided buffer is too small to write the header"); + } + out.order(ByteOrder.LITTLE_ENDIAN); + out.putInt(LZ4FrameDecoder.MAGIC); + out.put(frameInfo.getFLG().toByte()); + out.put(frameInfo.getBD().toByte()); + if (frameInfo.isEnabled(FLG.Bits.CONTENT_SIZE)) + { + out.putLong(knownSize); + } + // compute checksum on all descriptor fields + final int hash = (checksum.hash(out.array(), LZ4FrameDecoder.INTEGER_BYTES, out.position() - LZ4FrameDecoder.INTEGER_BYTES, 0) >> 8) & 0xFF; + out.put((byte) hash); + } + + /** + * Compresses buffered data, optionally computes an XXHash32 checksum, and writes the result to the buffer. + */ + private void writeBlock(ByteBuffer out) + { + if (blockBuffer.position() == 0) + { + return; + } + // Make sure there's no stale data + Arrays.fill(compressedBuffer, (byte) 0); + + if (frameInfo.isEnabled(FLG.Bits.CONTENT_CHECKSUM)) + { + frameInfo.updateStreamHash(blockBuffer.array(), 0, blockBuffer.position()); + } + + int compressedLength = compressor.compress(blockBuffer.array(), 0, blockBuffer.position(), compressedBuffer, 0); + final byte[] bufferToWrite; + final int compressMethod; + + // Store block uncompressed if compressed length is greater (incompressible) + if (compressedLength >= blockBuffer.position()) + { + compressedLength = blockBuffer.position(); + bufferToWrite = Arrays.copyOf(blockBuffer.array(), compressedLength); + compressMethod = LZ4FrameDecoder.LZ4_FRAME_INCOMPRESSIBLE_MASK; + } + else + { + bufferToWrite = compressedBuffer; + compressMethod = 0; + } + + // Write content + out.putInt(compressedLength | compressMethod); + out.put(bufferToWrite, 0, compressedLength); // TODO bufferToWrite is a copy, we could avoid it + + // Calculate and write block checksum + if (frameInfo.isEnabled(FLG.Bits.BLOCK_CHECKSUM)) + { + out.putInt(checksum.hash(bufferToWrite, 0, compressedLength, 0)); + } + blockBuffer.rewind(); + } + + /** + * Similar to the {@link #writeBlock(ByteBuffer)} method. Writes a 0-length block (without block checksum) to signal the end + * of the block stream. + */ + private void writeEndMark(ByteBuffer out) + { + out.order(ByteOrder.LITTLE_ENDIAN); + out.putInt(0); + if (frameInfo.isEnabled(FLG.Bits.CONTENT_CHECKSUM)) + { + out.putInt(0, frameInfo.currentStreamHash()); + } + frameInfo.finish(); + } + + /** + * A simple state check to ensure the stream is still open. + */ + private void ensureNotFinished() + { + if (frameInfo.isFinished()) + { + throw new IllegalStateException(CLOSED_STREAM); + } + } +} \ No newline at end of file diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/MCAPCRC32Helper.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/MCAPCRC32Helper.java new file mode 100644 index 000000000..526a64db6 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/encoding/MCAPCRC32Helper.java @@ -0,0 +1,100 @@ +package us.ihmc.scs2.session.mcap.encoding; + +import us.ihmc.scs2.session.mcap.specs.records.MCAPElement; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Collection; +import java.util.zip.CRC32; + +public class MCAPCRC32Helper +{ + private final CRC32 crc32 = new CRC32(); + private final ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + + public MCAPCRC32Helper() + { + } + + public void reset() + { + crc32.reset(); + } + + public void addLong(long value) + { + buffer.clear(); + buffer.putLong(value); + buffer.flip(); + addByteBuffer(buffer); + } + + public void addInt(int value) + { + buffer.clear(); + buffer.putInt(value); + buffer.flip(); + addByteBuffer(buffer); + } + + public void addUnsignedInt(long value) + { + addInt((int) value); + } + + public void addShort(short value) + { + buffer.clear(); + buffer.putShort(value); + buffer.flip(); + addByteBuffer(buffer); + } + + public void addUnsignedShort(int value) + { + addShort((short) value); + } + + public void addByte(byte value) + { + crc32.update(value); + } + + public void addUnsignedByte(int value) + { + addByte((byte) value); + } + + public void addBytes(byte[] bytes) + { + crc32.update(bytes); + } + + public void addBytes(byte[] bytes, int offset, int length) + { + crc32.update(bytes, offset, length); + } + + public void addByteBuffer(ByteBuffer byteBuffer) + { + crc32.update(byteBuffer); + } + + public void addString(String value) + { + byte[] bytes = value.getBytes(); + addUnsignedInt(bytes.length); + addBytes(bytes); + } + + public void addCollection(Collection collection) + { + addUnsignedInt(collection.stream().mapToLong(MCAPElement::getElementLength).sum()); + collection.forEach(element -> element.updateCRC(this)); + } + + public long getValue() + { + return crc32.getValue(); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInput.java index 596f1ad6d..8187c5a9f 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInput.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInput.java @@ -1,7 +1,8 @@ package us.ihmc.scs2.session.mcap.input; import com.github.luben.zstd.ZstdDecompressCtx; -import us.ihmc.scs2.session.mcap.LZ4FrameDecoder; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder; +import us.ihmc.scs2.session.mcap.specs.records.Compression; import java.io.IOException; import java.nio.ByteBuffer; diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPByteBufferDataInput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPByteBufferDataInput.java index fa648631d..9a254d104 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPByteBufferDataInput.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPByteBufferDataInput.java @@ -1,7 +1,8 @@ package us.ihmc.scs2.session.mcap.input; import com.github.luben.zstd.ZstdDecompressCtx; -import us.ihmc.scs2.session.mcap.LZ4FrameDecoder; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameDecoder; +import us.ihmc.scs2.session.mcap.specs.records.Compression; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -77,6 +78,8 @@ public ByteBuffer getByteBuffer(long offset, int length, boolean direct) { ByteBuffer out = direct ? ByteBuffer.allocateDirect(length) : ByteBuffer.allocate(length); out.put(0, buffer, (int) offset, length); + out.position(0); + out.limit(length); out.order(ByteOrder.LITTLE_ENDIAN); return out; } @@ -84,7 +87,6 @@ public ByteBuffer getByteBuffer(long offset, int length, boolean direct) @Override public ByteBuffer getDecompressedByteBuffer(long offset, int compressedLength, int uncompressedLength, Compression compression, boolean direct) { - ByteBuffer compressedBuffer = getByteBuffer(offset, compressedLength, false); ByteBuffer decompressedBuffer; if (compression == Compression.LZ4) @@ -101,7 +103,7 @@ else if (compression == Compression.ZSTD) int previousLimit = buffer.limit(); buffer.limit((int) (offset + compressedLength)); buffer.position((int) offset); - decompressedBuffer = zstdDecompressCtx.decompress(compressedBuffer, uncompressedLength); + decompressedBuffer = zstdDecompressCtx.decompress(buffer, uncompressedLength); buffer.position(previousPosition); buffer.limit(previousLimit); } diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPDataInput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPDataInput.java index 7e9b8a627..389e839f2 100644 --- a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPDataInput.java +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/input/MCAPDataInput.java @@ -1,25 +1,12 @@ package us.ihmc.scs2.session.mcap.input; +import us.ihmc.scs2.session.mcap.specs.records.Compression; + import java.nio.ByteBuffer; import java.nio.channels.FileChannel; public interface MCAPDataInput { - enum Compression - { - NONE, LZ4, ZSTD; - - public static Compression fromString(String name) - { - return switch (name.trim().toLowerCase()) - { - case "none", "" -> NONE; - case "lz4" -> LZ4; - case "zstd" -> ZSTD; - default -> throw new IllegalArgumentException("Unsupported compression: " + name); - }; - } - } void position(long position); diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPBufferedFileChannelOutput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPBufferedFileChannelOutput.java new file mode 100644 index 000000000..ef365ea51 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPBufferedFileChannelOutput.java @@ -0,0 +1,146 @@ +package us.ihmc.scs2.session.mcap.output; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; + +public class MCAPBufferedFileChannelOutput implements MCAPDataOutput +{ + static final int DEFAULT_BUFFER_SIZE = 8192; + public static final boolean DEFAULT_USE_DIRECT_BUFFER = false; + + private final ByteBuffer writingBuffer; + private final FileChannel fileChannel; + + public MCAPBufferedFileChannelOutput(FileChannel fileChannel) + { + this(fileChannel, DEFAULT_BUFFER_SIZE, DEFAULT_USE_DIRECT_BUFFER); + } + + public MCAPBufferedFileChannelOutput(FileChannel fileChannel, int writingBufferSize, boolean useDirectBuffer) + { + this.fileChannel = fileChannel; + writingBuffer = useDirectBuffer ? ByteBuffer.allocateDirect(writingBufferSize) : ByteBuffer.allocate(writingBufferSize); + writingBuffer.order(ByteOrder.LITTLE_ENDIAN); + } + + @Override + public long position() + { + try + { + return fileChannel.position() + writingBuffer.position(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + @Override + public void putLong(long value) + { + if (writingBuffer.remaining() < Long.BYTES) + flush(); + writingBuffer.putLong(value); + } + + @Override + public void putInt(int value) + { + if (writingBuffer.remaining() < Integer.BYTES) + flush(); + writingBuffer.putInt(value); + } + + @Override + public void putShort(short value) + { + if (writingBuffer.remaining() < Short.BYTES) + flush(); + writingBuffer.putShort(value); + } + + @Override + public void putByte(byte value) + { + if (writingBuffer.remaining() < Byte.BYTES) + flush(); + writingBuffer.put(value); + } + + @Override + public void putBytes(byte[] bytes, int offset, int length) + { + if (writingBuffer.remaining() >= length) + { + writingBuffer.put(bytes, offset, length); + return; + } + + try + { + flush(); + fileChannel.write(ByteBuffer.wrap(bytes, offset, length)); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + @Override + public void putByteBuffer(ByteBuffer byteBuffer) + { + if (writingBuffer.remaining() >= byteBuffer.remaining()) + { + writingBuffer.put(byteBuffer); + return; + } + + try + { + flush(); + fileChannel.write(byteBuffer); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + public void flush() + { + writingBuffer.flip(); + try + { + fileChannel.write(writingBuffer); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + writingBuffer.clear(); + } + + @Override + public void close() + { + flush(); + try + { + fileChannel.close(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + @Override + public String toString() + { + return "MCAPBufferedFileChannelOutput [fileChannel=" + fileChannel + "]"; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPByteBufferDataOutput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPByteBufferDataOutput.java new file mode 100644 index 000000000..300f4e099 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPByteBufferDataOutput.java @@ -0,0 +1,105 @@ +package us.ihmc.scs2.session.mcap.output; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class MCAPByteBufferDataOutput implements MCAPDataOutput +{ + private static final int DEFAULT_INITIAL_CAPACITY = 8192; + private static final int DEFAULT_GROWTH_FACTOR = 2; + private static final boolean DEFAULT_DIRECT_BUFFER = false; + + private ByteBuffer buffer; + private final int growthFactor; + private final boolean directBuffer; + + public MCAPByteBufferDataOutput() + { + this(DEFAULT_INITIAL_CAPACITY, DEFAULT_GROWTH_FACTOR, DEFAULT_DIRECT_BUFFER); + } + + public MCAPByteBufferDataOutput(int initialCapacity, int growthFactor, boolean directBuffer) + { + this.growthFactor = growthFactor; + this.directBuffer = directBuffer; + buffer = newBuffer(initialCapacity); + } + + private ByteBuffer newBuffer(int capacity) + { + ByteBuffer newBuffer = directBuffer ? ByteBuffer.allocateDirect(capacity) : ByteBuffer.allocate(capacity); + newBuffer.order(ByteOrder.LITTLE_ENDIAN); + return newBuffer; + } + + @Override + public long position() + { + return buffer.position(); + } + + @Override + public void putLong(long value) + { + ensureCapacity(Long.BYTES); + buffer.putLong(value); + } + + @Override + public void putInt(int value) + { + ensureCapacity(Integer.BYTES); + buffer.putInt(value); + } + + @Override + public void putShort(short value) + { + ensureCapacity(Short.BYTES); + buffer.putShort(value); + } + + @Override + public void putByte(byte value) + { + ensureCapacity(Byte.BYTES); + buffer.put(value); + } + + @Override + public void putBytes(byte[] bytes, int offset, int length) + { + ensureCapacity(length); + buffer.put(bytes, offset, length); + } + + @Override + public void putByteBuffer(ByteBuffer byteBuffer) + { + ensureCapacity(byteBuffer.remaining()); + buffer.put(byteBuffer); + } + + @Override + public void close() + { + buffer.flip(); + } + + private void ensureCapacity(int bytesToWrite) + { + if (buffer.remaining() < bytesToWrite) + { + int newCapacity = Math.max(buffer.capacity() * growthFactor, buffer.position() + bytesToWrite); + ByteBuffer newBuffer = newBuffer(newCapacity); + buffer.flip(); + newBuffer.put(buffer); + buffer = newBuffer; + } + } + + public ByteBuffer getBuffer() + { + return buffer; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPDataOutput.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPDataOutput.java new file mode 100644 index 000000000..66b9abab9 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/output/MCAPDataOutput.java @@ -0,0 +1,64 @@ +package us.ihmc.scs2.session.mcap.output; + +import us.ihmc.scs2.session.mcap.specs.records.MCAPElement; + +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.Collection; + +public interface MCAPDataOutput +{ + long position(); + + void putLong(long value); + + void putInt(int value); + + default void putUnsignedInt(long value) + { + putInt((int) value); + } + + void putShort(short value); + + default void putUnsignedShort(int value) + { + putShort((short) value); + } + + void putByte(byte value); + + default void putUnsignedByte(int value) + { + putByte((byte) value); + } + + default void putBytes(byte[] bytes) + { + putBytes(bytes, 0, bytes.length); + } + + void putBytes(byte[] bytes, int offset, int length); + + default void putString(String string) + { + byte[] bytes = string.getBytes(); + putUnsignedInt(bytes.length); + putBytes(bytes); + } + + void putByteBuffer(ByteBuffer byteBuffer); + + default void putCollection(Collection collection) + { + putUnsignedInt(collection.stream().mapToLong(MCAPElement::getElementLength).sum()); + collection.forEach(element -> element.write(this)); + } + + void close(); + + static MCAPDataOutput wrap(FileChannel fileChannel) + { + return new MCAPBufferedFileChannelOutput(fileChannel); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/MCAP.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/MCAP.java new file mode 100644 index 000000000..f15fd2ec1 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/MCAP.java @@ -0,0 +1,151 @@ +package us.ihmc.scs2.session.mcap.specs; + +import us.ihmc.log.LogTools; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.records.Footer; +import us.ihmc.scs2.session.mcap.specs.records.MCAPElement; +import us.ihmc.scs2.session.mcap.specs.records.Magic; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.RecordDataInputBacked; + +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; + +/** + * MCAP is a modular container format and logging library for pub/sub messages with arbitrary + * message serialization. It is primarily intended for use in robotics applications, and works well + * under various workloads, resource constraints, and durability requirements. Time values + * (`log_time`, `publish_time`, `create_time`) are represented in nanoseconds since a + * user-understood epoch (i.e. Unix epoch, robot boot time, etc.) + * + * @see Source + */ +public class MCAP +{ + /** + * Stream object that this MCAP was parsed from. + */ + protected MCAPDataInput dataInput; + + private final List records; + + private Record footer; + + public MCAP(FileChannel fileChannel) + { + dataInput = MCAPDataInput.wrap(fileChannel); + + long currentPos = 0; + Magic.readMagic(dataInput, currentPos); + currentPos += Magic.getElementLength(); + records = new ArrayList<>(); + Record lastRecord; + + try + { + do + { + lastRecord = new RecordDataInputBacked(dataInput, currentPos); + if (lastRecord.getElementLength() < 0) + throw new IllegalArgumentException("Invalid record length: " + lastRecord.getElementLength()); + currentPos += lastRecord.getElementLength(); + records.add(lastRecord); + } + while (!(lastRecord.op() == Opcode.FOOTER)); + } + catch (IllegalArgumentException e) + { + try + { + + LogTools.info("Loaded records:\n"); + for (Record record : records) + { + System.out.println(record); + } + } + catch (Exception e2) + { + throw e; + } + throw e; + } + + Magic.readMagic(dataInput, currentPos); + } + + public MCAPDataInput getDataInput() + { + return dataInput; + } + + public List records() + { + return records; + } + + public Record footer() + { + if (footer == null) + { + footer = new RecordDataInputBacked(dataInput, Footer.computeOffsetFooter(dataInput)); + } + return footer; + } + + public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser) + { + return parseList(dataInput, elementParser, dataInput.getUnsignedInt()); + } + + public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser, long length) + { + return parseList(dataInput, elementParser, dataInput.position(), length); + } + + public static List parseList(MCAPDataInput dataInput, MCAPDataReader elementParser, long offset, long length) + { + return parseList(dataInput, elementParser, offset, length, null); + } + + public static List parseList(MCAPDataInput dataInput, + MCAPDataReader elementParser, + long offset, + long length, + List listToPack) + { + long position = offset; + long limit = position + length; + if (listToPack == null) + listToPack = new ArrayList<>(); + + while (position < limit) + { + T parsed = elementParser.parse(dataInput, position); + listToPack.add(parsed); + position += parsed.getElementLength(); + } + + return listToPack; + } + + public interface MCAPDataReader + { + T parse(MCAPDataInput dataInput, long position); + } + + public static long checkPositiveLong(long value, String name) + { + if (value < 0) + throw new IllegalArgumentException(name + " must be positive. Value: " + value); + return value; + } + + public static void checkLength(long expectedLength, long actualLength) + { + if (actualLength != expectedLength) + throw new IllegalArgumentException("Unexpected length: expected= " + expectedLength + ", actual= " + actualLength); + } +} \ No newline at end of file diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Attachment.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Attachment.java new file mode 100644 index 000000000..f7dc3142a --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Attachment.java @@ -0,0 +1,115 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Attachment records contain auxiliary artifacts such as text, core dumps, calibration data, or other arbitrary data. + * Attachment records must not appear within a chunk. + * + * @see MCAP Attachment + */ +public interface Attachment extends MCAPElement +{ + static Attachment load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new AttachmentDataInputBacked(dataInput, elementPosition, elementLength); + } + + ByteBuffer crc32Input(); + + /** Time at which the attachment was recorded. */ + long logTime(); + + /** Time at which the attachment was created. If not available, must be set to zero. */ + long createTime(); + + /** Name of the attachment, e.g "scene1.jpg". */ + String name(); + + /** Media type of the attachment (e.g "text/plain"). */ + String mediaType(); + + /** Size in bytes of the attachment data. */ + long dataLength(); + + /** Attachment data. */ + ByteBuffer data(); + + /** + * CRC-32 checksum of the preceding fields in the record. + * A value of zero indicates that CRC validation should not be performed. + */ + long crc32(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(logTime()); + dataOutput.putLong(createTime()); + dataOutput.putString(name()); + dataOutput.putString(mediaType()); + dataOutput.putLong(dataLength()); + dataOutput.putByteBuffer(data()); + dataOutput.putUnsignedInt(crc32()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(logTime()); + crc32.addLong(createTime()); + crc32.addString(name()); + crc32.addString(mediaType()); + crc32.addLong(dataLength()); + crc32.addByteBuffer(data()); + crc32.addUnsignedInt(crc32()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-logTime = " + logTime(); + out += "\n\t-createTime = " + createTime(); + out += "\n\t-name = " + name(); + out += "\n\t-mediaType = " + mediaType(); + out += "\n\t-dataLength = " + dataLength(); + out += "\n\t-data = " + data(); + out += "\n\t-crc32 = " + crc32(); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Attachment other) + { + if (logTime() != other.logTime()) + return false; + if (createTime() != other.createTime()) + return false; + if (!Objects.equals(name(), other.name())) + return false; + if (!Objects.equals(mediaType(), other.mediaType())) + return false; + if (dataLength() != other.dataLength()) + return false; + if (!data().equals(other.data())) + return false; + return crc32() == other.crc32(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentDataInputBacked.java new file mode 100644 index 000000000..a9328c5c3 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentDataInputBacked.java @@ -0,0 +1,127 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.nio.ByteBuffer; + +class AttachmentDataInputBacked implements Attachment +{ + private final MCAPDataInput dataInput; + private final long logTime; + private final long createTime; + private final String name; + private final String mediaType; + private final long dataLength; + private final long dataOffset; + private WeakReference dataRef; + private final long crc32; + private final long crc32InputStart; + private final int crc32InputLength; + private WeakReference crc32InputRef; + + AttachmentDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + crc32InputStart = elementPosition; + logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime"); + createTime = MCAP.checkPositiveLong(dataInput.getLong(), "createTime"); + name = dataInput.getString(); + mediaType = dataInput.getString(); + dataLength = MCAP.checkPositiveLong(dataInput.getLong(), "lengthData"); + dataOffset = dataInput.position(); + dataInput.skip(dataLength); + crc32InputLength = (int) (dataInput.position() - elementPosition); + crc32 = dataInput.getUnsignedInt(); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public long getElementLength() + { + return 3 * Long.BYTES + 3 * Integer.BYTES + name.length() + mediaType.length() + (int) dataLength; + } + + @Override + public ByteBuffer crc32Input() + { + ByteBuffer crc32Input = this.crc32InputRef == null ? null : this.crc32InputRef.get(); + + if (crc32Input == null) + { + crc32Input = dataInput.getByteBuffer(crc32InputStart, crc32InputLength, false); + crc32InputRef = new WeakReference<>(crc32Input); + } + + return crc32Input; + } + + @Override + public long logTime() + { + return logTime; + } + + @Override + public long createTime() + { + return createTime; + } + + @Override + public String name() + { + return name; + } + + @Override + public String mediaType() + { + return mediaType; + } + + public long dataOffset() + { + return dataOffset; + } + + @Override + public long dataLength() + { + return dataLength; + } + + @Override + public ByteBuffer data() + { + ByteBuffer data = this.dataRef == null ? null : this.dataRef.get(); + + if (data == null) + { + data = dataInput.getByteBuffer(dataOffset, (int) dataLength, false); + dataRef = new WeakReference<>(data); + } + return data; + } + + @Override + public long crc32() + { + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Attachment attachment && Attachment.super.equals(attachment); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndex.java new file mode 100644 index 000000000..2ffab336f --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndex.java @@ -0,0 +1,117 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.Objects; + +/** + * An Attachment Index record contains the location of an attachment in the file. + * An Attachment Index record exists for every Attachment record in the file. + * + * @see MCAP Attachment Index + */ +public interface AttachmentIndex extends MCAPElement +{ + static AttachmentIndex load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new AttachmentIndexDataInputBacked(dataInput, elementPosition, elementLength); + } + + @Override + default long getElementLength() + { + return 5 * Long.BYTES + 2 * Integer.BYTES + name().length() + mediaType().length(); + } + + Record attachment(); + + /** Byte offset from the start of the file to the attachment record. */ + long attachmentOffset(); + + /** Byte length of the attachment record, including opcode and length prefix. */ + long attachmentLength(); + + /** Time at which the attachment was recorded. */ + long logTime(); + + /** Time at which the attachment was created. If not available, must be set to zero. */ + long createTime(); + + /** Size of the attachment data. */ + long dataLength(); + + /** Name of the attachment. */ + String name(); + + /** Media type of the attachment (e.g "text/plain"). */ + String mediaType(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(attachmentOffset()); + dataOutput.putLong(attachmentLength()); + dataOutput.putLong(logTime()); + dataOutput.putLong(createTime()); + dataOutput.putLong(dataLength()); + dataOutput.putString(name()); + dataOutput.putString(mediaType()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(attachmentOffset()); + crc32.addLong(attachmentLength()); + crc32.addLong(logTime()); + crc32.addLong(createTime()); + crc32.addLong(dataLength()); + crc32.addString(name()); + crc32.addString(mediaType()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-attachmentOffset = " + attachmentOffset(); + out += "\n\t-attachmentLength = " + attachmentLength(); + out += "\n\t-logTime = " + logTime(); + out += "\n\t-createTime = " + createTime(); + out += "\n\t-dataLength = " + dataLength(); + out += "\n\t-name = " + name(); + out += "\n\t-mediaType = " + mediaType(); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof AttachmentIndex other) + { + if (attachmentOffset() != other.attachmentOffset()) + return false; + if (attachmentLength() != other.attachmentLength()) + return false; + if (logTime() != other.logTime()) + return false; + if (createTime() != other.createTime()) + return false; + if (dataLength() != other.dataLength()) + return false; + if (!Objects.equals(name(), other.name())) + return false; + return Objects.equals(mediaType(), other.mediaType()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndexDataInputBacked.java new file mode 100644 index 000000000..1e9c44826 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/AttachmentIndexDataInputBacked.java @@ -0,0 +1,103 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; + +class AttachmentIndexDataInputBacked implements AttachmentIndex +{ + private final MCAPDataInput dataInput; + private final long attachmentOffset; + private final long attachmentLength; + private final long logTime; + private final long createTime; + private final long dataLength; + private final String name; + private final String mediaType; + + private WeakReference attachmentRef; + + AttachmentIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + attachmentOffset = MCAP.checkPositiveLong(dataInput.getLong(), "attachmentOffset"); + attachmentLength = MCAP.checkPositiveLong(dataInput.getLong(), "attachmentLength"); + logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime"); + createTime = MCAP.checkPositiveLong(dataInput.getLong(), "createTime"); + dataLength = MCAP.checkPositiveLong(dataInput.getLong(), "dataSize"); + name = dataInput.getString(); + mediaType = dataInput.getString(); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public Record attachment() + { + Record attachment = attachmentRef == null ? null : attachmentRef.get(); + + if (attachment == null) + { + attachment = new RecordDataInputBacked(dataInput, attachmentOffset); + attachmentRef = new WeakReference<>(attachment); + } + + return attachment; + } + + @Override + public long attachmentOffset() + { + return attachmentOffset; + } + + @Override + public long attachmentLength() + { + return attachmentLength; + } + + @Override + public long logTime() + { + return logTime; + } + + @Override + public long createTime() + { + return createTime; + } + + @Override + public long dataLength() + { + return dataLength; + } + + @Override + public String name() + { + return name; + } + + @Override + public String mediaType() + { + return mediaType; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof AttachmentIndex other && AttachmentIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Channel.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Channel.java new file mode 100644 index 000000000..a25b05c9e --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Channel.java @@ -0,0 +1,91 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.List; +import java.util.Objects; + +/** + * Channel records define encoded streams of messages on topics. + * Channel records are uniquely identified within a file by their channel ID. + * A Channel record must occur at least once in the file prior to any message referring to its channel ID. + * Any two channel records sharing a common ID must be identical. + * + * @see MCAP Channel + */ +public interface Channel extends MCAPElement +{ + static Channel load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new ChannelDataInputBacked(dataInput, elementPosition, elementLength); + } + + int id(); + + int schemaId(); + + String topic(); + + String messageEncoding(); + + List metadata(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putInt(id()); + dataOutput.putInt(schemaId()); + dataOutput.putString(topic()); + dataOutput.putString(messageEncoding()); + dataOutput.putCollection(metadata()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addInt(id()); + crc32.addInt(schemaId()); + crc32.addString(topic()); + crc32.addString(messageEncoding()); + crc32.addCollection(metadata()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-id = " + id(); + out += "\n\t-schemaId = " + schemaId(); + out += "\n\t-topic = " + topic(); + out += "\n\t-messageEncoding = " + messageEncoding(); + out += "\n\t-metadata = [%s]".formatted(metadata().toString()); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Channel other) + { + if (id() != other.id()) + return false; + if (schemaId() != other.schemaId()) + return false; + if (!Objects.equals(topic(), other.topic())) + return false; + if (!Objects.equals(messageEncoding(), other.messageEncoding())) + return false; + return Objects.equals(metadata(), other.metadata()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelDataInputBacked.java new file mode 100644 index 000000000..b3d833958 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelDataInputBacked.java @@ -0,0 +1,90 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.util.List; + +class ChannelDataInputBacked implements Channel +{ + private final MCAPDataInput dataInput; + private final long elementLength; + private final int id; + private final int schemaId; + private final String topic; + private final String messageEncoding; + private WeakReference> metadataRef; + private final long metadataOffset; + private final long metadataLength; + + public ChannelDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + this.elementLength = elementLength; + + dataInput.position(elementPosition); + id = dataInput.getUnsignedShort(); + schemaId = dataInput.getUnsignedShort(); + topic = dataInput.getString(); + messageEncoding = dataInput.getString(); + metadataLength = dataInput.getUnsignedInt(); + metadataOffset = dataInput.position(); + } + + @Override + public long getElementLength() + { + return elementLength; + } + + @Override + public int id() + { + return id; + } + + @Override + public int schemaId() + { + return schemaId; + } + + @Override + public String topic() + { + return topic; + } + + @Override + public String messageEncoding() + { + return messageEncoding; + } + + @Override + public List metadata() + { + List metadata = metadataRef == null ? null : metadataRef.get(); + + if (metadata == null) + { + metadata = MCAP.parseList(dataInput, StringPair::new, metadataOffset, metadataLength); + metadataRef = new WeakReference<>(metadata); + } + + return metadata; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Channel other && Channel.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelMessageCount.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelMessageCount.java new file mode 100644 index 000000000..93b819c04 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChannelMessageCount.java @@ -0,0 +1,110 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +public class ChannelMessageCount implements MCAPElement +{ + public static final long ELEMENT_LENGTH = Short.BYTES + Long.BYTES; + private final int channelId; + private long messageCount; + + public ChannelMessageCount(MCAPDataInput dataInput, long elementPosition) + { + dataInput.position(elementPosition); + channelId = dataInput.getUnsignedShort(); + messageCount = dataInput.getLong(); + } + + public ChannelMessageCount(int channelId) + { + this(channelId, 0); + } + + public ChannelMessageCount(int channelId, long messageCount) + { + this.channelId = channelId; + this.messageCount = messageCount; + } + + @Override + public long getElementLength() + { + return ELEMENT_LENGTH; + } + + public int channelId() + { + return channelId; + } + + public void incrementMessageCount() + { + messageCount++; + } + + public void setMessageCount(long messageCount) + { + this.messageCount = messageCount; + } + + public long messageCount() + { + return messageCount; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedShort(channelId); + dataOutput.putLong(messageCount); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedShort(channelId); + crc32.addLong(messageCount); + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-channelId = " + channelId; + out += "\n\t-messageCount = " + messageCount; + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof ChannelMessageCount other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof ChannelMessageCount other) + { + if (channelId() != other.channelId()) + return false; + return messageCount() == other.messageCount(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Chunk.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Chunk.java new file mode 100644 index 000000000..26b086f57 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Chunk.java @@ -0,0 +1,142 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.commons.MathTools; +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Chunk records each contain a batch of Schema, Channel, and Message records. + * The batch of records contained in a chunk may be compressed or uncompressed. + * All messages in the chunk must reference channels recorded earlier in the file (in a previous chunk or earlier in the current chunk). + * + *

+ * MCAP files can have Schema, Channel, and Message records written directly to the data section, or they can be written into Chunk records to facilitate + * indexing and compression. + * For MCAPs that include Chunk Index records in the summary section, all Message records should be written into Chunk records. + * Why? + * The presence of Chunk Index records in the summary section indicates to readers that the MCAP is indexed, and they can use those records to look up messages + * by log time or topic. + * However, Message records outside of chunks cannot be indexed, and may not be found by readers using the index. + *

+ * + * @see MCAP Chunk + */ +public interface Chunk extends MCAPElement +{ + static Chunk load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new ChunkDataInputBacked(dataInput, elementPosition, elementLength); + } + + /** + * Earliest message log_time in the chunk. Zero if the chunk has no messages. + */ + long messageStartTime(); + + /** + * Latest message log_time in the chunk. Zero if the chunk has no messages. + */ + long messageEndTime(); + + /** + * Uncompressed size of the records field. + */ + long recordsUncompressedLength(); + + /** + * CRC-32 checksum of uncompressed `records` field. A value of zero indicates that CRC validation + * should not be performed. + */ + long uncompressedCRC32(); + + /** + * compression algorithm. i.e. zstd, lz4, "". An empty string indicates no compression. Refer to + * well-known compression formats. + */ + Compression compression(); + + /** + * Length of the records in bytes. + */ + long recordsCompressedLength(); + + default ByteBuffer getRecordsCompressedBuffer() + { + return getRecordsCompressedBuffer(false); + } + + ByteBuffer getRecordsCompressedBuffer(boolean directBuffer); + + default ByteBuffer getRecordsUncompressedBuffer() + { + return getRecordsUncompressedBuffer(false); + } + + ByteBuffer getRecordsUncompressedBuffer(boolean directBuffer); + + /** + * The decompressed records. + */ + Records records(); + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-messageStartTime = " + messageStartTime(); + out += "\n\t-messageEndTime = " + messageEndTime(); + out += "\n\t-compression = " + compression(); + out += "\n\t-recordsUncompressedLength = " + recordsUncompressedLength(); + out += "\n\t-uncompressedCrc32 = " + uncompressedCRC32(); + out += "\n\t-records = " + (records() == null ? "null" : "\n" + EuclidCoreIOTools.getCollectionString("\n", records(), e -> e.toString(indent + 1))); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Chunk other) + { + if (messageStartTime() != other.messageStartTime()) + return false; + if (messageEndTime() != other.messageEndTime()) + return false; + if (recordsUncompressedLength() != other.recordsUncompressedLength()) + return false; + if (uncompressedCRC32() != other.uncompressedCRC32()) + return false; + if (compression() != other.compression()) + return false; + if (recordsCompressedLength() != other.recordsCompressedLength()) + return false; + return Objects.equals(records(), other.records()); + } + + return false; + } + + default Chunk crop(long startTimestamp, long endTimestamp) + { + long croppedStartTime = MathTools.clamp(messageStartTime(), startTimestamp, endTimestamp); + long croppedEndTime = MathTools.clamp(messageEndTime(), startTimestamp, endTimestamp); + Records croppedRecords = records().crop(croppedStartTime, croppedEndTime); + // There may be no records when testing a chunk that is before the start timestamp. + // We still want to test it in case there stuff like schemas, channels, and other time-insensitive data. + if (croppedRecords.isEmpty()) + return null; + + MutableChunk croppedChunk = new MutableChunk(); + croppedChunk.setMessageStartTime(croppedStartTime); + croppedChunk.setMessageEndTime(croppedEndTime); + croppedChunk.setRecords(croppedRecords); + croppedChunk.setCompression(compression()); + + return croppedChunk; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkDataInputBacked.java new file mode 100644 index 000000000..4000f660f --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkDataInputBacked.java @@ -0,0 +1,203 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class ChunkDataInputBacked implements Chunk +{ + private final MCAPDataInput dataInput; + private final long elementLength; + /** + * Earliest message log_time in the chunk. Zero if the chunk has no messages. + */ + private final long messageStartTime; + /** + * Latest message log_time in the chunk. Zero if the chunk has no messages. + */ + private final long messageEndTime; + /** + * Uncompressed size of the records field. + */ + private final long recordsUncompressedLength; + /** + * CRC32 checksum of uncompressed records field. A value of zero indicates that CRC validation + * should not be performed. + */ + private final long uncompressedCRC32; + /** + * compression algorithm. i.e. zstd, lz4, "". An empty string indicates no compression. Refer to + * well-known compression formats. + */ + private final Compression compression; + /** + * Offset position of the records in either in the {@code ByteBuffer} or {@code FileChannel}, + * depending on how this chunk was created. + */ + private final long recordsOffset; + /** + * Length of the records in bytes. + */ + private final long recordsCompressedLength; + /** + * The decompressed records. + */ + private WeakReference recordsRef; + private WeakReference recordsUncompressedBufferRef; + + ChunkDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + this.elementLength = elementLength; + + dataInput.position(elementPosition); + messageStartTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageStartTime"); + messageEndTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageEndTime"); + recordsUncompressedLength = MCAP.checkPositiveLong(dataInput.getLong(), "recordsUncompressedLength"); + uncompressedCRC32 = dataInput.getUnsignedInt(); + compression = Compression.fromString(dataInput.getString()); + recordsCompressedLength = MCAP.checkPositiveLong(dataInput.getLong(), "recordsCompressedLength"); + recordsOffset = dataInput.position(); + MCAP.checkLength(getElementLength(), 4 * Long.BYTES + Integer.BYTES + compression.getLength() + recordsCompressedLength); + } + + @Override + public long getElementLength() + { + return elementLength; + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + @Override + public long recordsUncompressedLength() + { + return recordsUncompressedLength; + } + + /** + * CRC-32 checksum of uncompressed `records` field. A value of zero indicates that CRC validation + * should not be performed. + */ + @Override + public long uncompressedCRC32() + { + return uncompressedCRC32; + } + + @Override + public Compression compression() + { + return compression; + } + + public long recordsOffset() + { + return recordsOffset; + } + + @Override + public long recordsCompressedLength() + { + return recordsCompressedLength; + } + + @Override + public ByteBuffer getRecordsCompressedBuffer(boolean directBuffer) + { + return dataInput.getByteBuffer(recordsOffset, (int) recordsCompressedLength, directBuffer); + } + + @Override + public ByteBuffer getRecordsUncompressedBuffer(boolean directBuffer) + { + ByteBuffer recordsUncompressedBuffer = recordsUncompressedBufferRef == null ? null : recordsUncompressedBufferRef.get(); + + if (recordsUncompressedBuffer == null) + { + recordsUncompressedBuffer = dataInput.getDecompressedByteBuffer(recordsOffset, + (int) recordsCompressedLength, + (int) recordsUncompressedLength, + compression, + directBuffer); + recordsUncompressedBufferRef = new WeakReference<>(recordsUncompressedBuffer); + } + + return recordsUncompressedBuffer.duplicate().order(ByteOrder.LITTLE_ENDIAN); + } + + @Override + public Records records() + { + Records records = recordsRef == null ? null : recordsRef.get(); + + if (records != null) + return records; + + if (compression == Compression.NONE) + { + records = Records.load(dataInput, recordsOffset, (int) recordsCompressedLength); + } + else + { + records = Records.load(MCAPDataInput.wrap(getRecordsUncompressedBuffer()), 0, (int) recordsUncompressedLength); + } + + recordsRef = new WeakReference<>(records); + return records; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(messageStartTime); + dataOutput.putLong(messageEndTime); + dataOutput.putLong(recordsUncompressedLength); + dataOutput.putUnsignedInt(uncompressedCRC32); + dataOutput.putString(compression.getName()); + dataOutput.putLong(recordsCompressedLength); + dataOutput.putByteBuffer(dataInput.getByteBuffer(recordsOffset, (int) recordsCompressedLength, false)); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(messageStartTime); + crc32.addLong(messageEndTime); + crc32.addLong(recordsUncompressedLength); + crc32.addUnsignedInt(uncompressedCRC32); + crc32.addString(compression.getName()); + crc32.addLong(recordsCompressedLength); + crc32.addByteBuffer(dataInput.getByteBuffer(recordsOffset, (int) recordsCompressedLength, false)); + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Chunk other && Chunk.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndex.java new file mode 100644 index 000000000..cd830103c --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndex.java @@ -0,0 +1,164 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.List; +import java.util.Objects; + +import static us.ihmc.scs2.session.mcap.specs.records.MCAPElement.indent; + +/** + * ChunkIndex records contain the location of a Chunk record and its associated MessageIndex records. + * A ChunkIndex record exists for every Chunk in the file. + * + * @see MCAP Chunk Index + */ +public interface ChunkIndex extends MCAPElement +{ + static ChunkIndex load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new ChunkIndexDataInputBacked(dataInput, elementPosition, elementLength); + } + + Record chunk(); + + /** + * Earliest message log_time in the chunk. Zero if the chunk has no messages. + */ + long messageStartTime(); + + /** + * Latest message log_time in the chunk. Zero if the chunk has no messages. + */ + long messageEndTime(); + + /** + * Offset to the chunk record from the start of the file. + */ + long chunkOffset(); + + /** + * Byte length of the chunk record, including opcode and length prefix. + */ + long chunkLength(); + + /** + * Total length in bytes of the message index records after the chunk. + */ + long messageIndexOffsetsLength(); + + /** + * Mapping from channel ID to the offset of the message index record for that channel after the + * chunk, from the start of the file. An empty map indicates no message indexing is available. + */ + List messageIndexOffsets(); + + /** + * Total length in bytes of the message index records after the chunk. + */ + long messageIndexLength(); + + /** + * The compression used within the chunk. + * Refer to well-known compression formats. + * This field should match the value in the corresponding Chunk record. + */ + Compression compression(); + + /** + * The size of the chunk records field. + */ + long recordsCompressedLength(); + + /** + * The uncompressed size of the chunk records field. This field should match the value in the + * corresponding Chunk record. + */ + long recordsUncompressedLength(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(messageStartTime()); + dataOutput.putLong(messageEndTime()); + dataOutput.putLong(chunkOffset()); + dataOutput.putLong(chunkLength()); + dataOutput.putCollection(messageIndexOffsets()); + dataOutput.putLong(messageIndexLength()); + dataOutput.putString(compression().getName()); + dataOutput.putLong(recordsCompressedLength()); + dataOutput.putLong(recordsUncompressedLength()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(messageStartTime()); + crc32.addLong(messageEndTime()); + crc32.addLong(chunkOffset()); + crc32.addLong(chunkLength()); + crc32.addCollection(messageIndexOffsets()); + crc32.addLong(messageIndexLength()); + crc32.addString(compression().getName()); + crc32.addLong(recordsCompressedLength()); + crc32.addLong(recordsUncompressedLength()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-messageStartTime = " + messageStartTime(); + out += "\n\t-messageEndTime = " + messageEndTime(); + out += "\n\t-chunkOffset = " + chunkOffset(); + out += "\n\t-chunkLength = " + chunkLength(); + out += "\n\t-messageIndexOffsetsLength = " + messageIndexOffsetsLength(); + List messageIndexOffsets = messageIndexOffsets(); + out += "\n\t-messageIndexOffsets = " + (messageIndexOffsets == null ? + "null" : + "\n" + EuclidCoreIOTools.getCollectionString("\n", messageIndexOffsets, e -> e.toString(indent + 1))); + out += "\n\t-messageIndexLength = " + messageIndexLength(); + out += "\n\t-compression = " + compression(); + out += "\n\t-compressedSize = " + recordsCompressedLength(); + out += "\n\t-uncompressedSize = " + recordsUncompressedLength(); + return indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof ChunkIndex other) + { + if (messageStartTime() != other.messageStartTime()) + return false; + if (messageEndTime() != other.messageEndTime()) + return false; + if (chunkOffset() != other.chunkOffset()) + return false; + if (chunkLength() != other.chunkLength()) + return false; + if (messageIndexOffsetsLength() != other.messageIndexOffsetsLength()) + return false; + if (!Objects.equals(messageIndexOffsets(), other.messageIndexOffsets())) + return false; + if (messageIndexLength() != other.messageIndexLength()) + return false; + if (compression() != other.compression()) + return false; + if (recordsCompressedLength() != other.recordsCompressedLength()) + return false; + return recordsUncompressedLength() == other.recordsUncompressedLength(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndexDataInputBacked.java new file mode 100644 index 000000000..24bdf90e9 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/ChunkIndexDataInputBacked.java @@ -0,0 +1,144 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.util.List; + +public class ChunkIndexDataInputBacked implements ChunkIndex +{ + private final MCAPDataInput dataInput; + private final long elementLength; + private final long messageStartTime; + private final long messageEndTime; + private final long chunkOffset; + private final long chunkLength; + private final long messageIndexOffsetsOffset; + private final long messageIndexOffsetsLength; + private WeakReference> messageIndexOffsetsRef; + private final long messageIndexLength; + private final Compression compression; + private final long recordsCompressedLength; + private final long recordsUncompressedLength; + + ChunkIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + this.elementLength = elementLength; + + dataInput.position(elementPosition); + messageStartTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageStartTime"); + messageEndTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageEndTime"); + chunkOffset = MCAP.checkPositiveLong(dataInput.getLong(), "chunkOffset"); + chunkLength = MCAP.checkPositiveLong(dataInput.getLong(), "chunkLength"); + messageIndexOffsetsLength = dataInput.getUnsignedInt(); + messageIndexOffsetsOffset = dataInput.position(); + dataInput.skip(messageIndexOffsetsLength); + messageIndexLength = MCAP.checkPositiveLong(dataInput.getLong(), "messageIndexLength"); + compression = Compression.fromString(dataInput.getString()); + recordsCompressedLength = MCAP.checkPositiveLong(dataInput.getLong(), "compressedSize"); + recordsUncompressedLength = MCAP.checkPositiveLong(dataInput.getLong(), "uncompressedSize"); + } + + @Override + public long getElementLength() + { + return elementLength; + } + + private WeakReference chunkRef; + + @Override + public Record chunk() + { + Record chunk = chunkRef == null ? null : chunkRef.get(); + + if (chunk == null) + { + chunk = new RecordDataInputBacked(dataInput, chunkOffset); + chunkRef = new WeakReference<>(chunk); + } + return chunkRef.get(); + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + @Override + public long chunkOffset() + { + return chunkOffset; + } + + @Override + public long chunkLength() + { + return chunkLength; + } + + @Override + public long messageIndexOffsetsLength() + { + return messageIndexOffsetsLength; + } + + @Override + public List messageIndexOffsets() + { + List messageIndexOffsets = messageIndexOffsetsRef == null ? null : messageIndexOffsetsRef.get(); + + if (messageIndexOffsets == null) + { + messageIndexOffsets = MCAP.parseList(dataInput, MessageIndexOffset::new, messageIndexOffsetsOffset, messageIndexOffsetsLength); + messageIndexOffsetsRef = new WeakReference<>(messageIndexOffsets); + } + + return messageIndexOffsets; + } + + @Override + public long messageIndexLength() + { + return messageIndexLength; + } + + @Override + public Compression compression() + { + return compression; + } + + @Override + public long recordsCompressedLength() + { + return recordsCompressedLength; + } + + @Override + public long recordsUncompressedLength() + { + return recordsUncompressedLength; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof ChunkIndex other && ChunkIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java new file mode 100644 index 000000000..0aedba235 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java @@ -0,0 +1,36 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +public enum Compression +{ + NONE(""), LZ4("lz4"), ZSTD("zstd"); + + private final String name; + private final int length; + + Compression(String name) + { + this.name = name; + length = name.getBytes().length + Integer.BYTES; + } + + public int getLength() + { + return length; + } + + public String getName() + { + return name; + } + + public static Compression fromString(String name) + { + return switch (name.trim().toLowerCase()) + { + case "" -> NONE; + case "lz4" -> LZ4; + case "zstd" -> ZSTD; + default -> throw new IllegalArgumentException("Unsupported compression: " + name); + }; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java new file mode 100644 index 000000000..f526ec16d --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java @@ -0,0 +1,81 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +/** + * A Data End record indicates the end of the data section. + * + * @see MCAP Data End + */ +public class DataEnd implements MCAPElement +{ + private final long dataSectionCRC32; + + public DataEnd(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + dataInput.position(elementPosition); + dataSectionCRC32 = dataInput.getUnsignedInt(); + MCAP.checkLength(elementLength, getElementLength()); + } + + public DataEnd(long dataSectionCrc32) + { + this.dataSectionCRC32 = dataSectionCrc32; + } + + @Override + public long getElementLength() + { + return Integer.BYTES; + } + + /** + * CRC-32 of all bytes in the data section. A value of 0 indicates the CRC-32 is not available. + */ + public long dataSectionCRC32() + { + return dataSectionCRC32; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedInt(dataSectionCRC32); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedInt(dataSectionCRC32); + return crc32; + } + + @Override + public String toString() + { + return getClass().getSimpleName() + ":\n\t-dataSectionCrc32 = " + dataSectionCRC32; + } + + @Override + public boolean equals(Object object) + { + return object instanceof DataEnd other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof DataEnd other) + return dataSectionCRC32() == other.dataSectionCRC32(); + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java new file mode 100644 index 000000000..80d03ad91 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java @@ -0,0 +1,209 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.util.Collection; + +/** + * Footer records contain end-of-file information. MCAP files must end with a Footer record. + * + * @see MCAP Footer + */ +public class Footer implements MCAPElement +{ + public static final int ELEMENT_LENGTH = 2 * Long.BYTES + Integer.BYTES; + private final MCAPDataInput dataInput; + /** + * Position in the file of the first record of the summary section. + *

+ * The summary section contains schema, channel, chunk index, attachment index, metadata index, and statistics records. + * It is not delimited by an encapsulating record as the rest, instead, the summary section simply starts right after the {@link DataEnd} record. + * Note that the records in the summary section must be grouped by {@link Opcode}. + *

+ */ + private final long summarySectionOffset; + /** + * The summary section is followed directly by summary offset records. + *

+ * There is one summary offset record per {@link Opcode} in the summary section. + * The summary offset records are used to quickly locate the start of each group. + */ + private final long summaryOffsetSectionOffset; + private final long summaryCRC32; + private Records summarySection; + private Records summaryOffsetSection; + + public Footer(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + summarySectionOffset = MCAP.checkPositiveLong(dataInput.getLong(), "summarySectionOffset"); + summaryOffsetSectionOffset = MCAP.checkPositiveLong(dataInput.getLong(), "summaryOffsetSectionOffset"); + summaryCRC32 = dataInput.getUnsignedInt(); + MCAP.checkLength(elementLength, getElementLength()); + } + + public Footer(long summarySectionOffset, Collection summaryRecords, Collection summaryOffsetRecords) + { + this.summarySection = new Records(summaryRecords); + this.summaryOffsetSection = new Records(summaryOffsetRecords); + + this.summarySectionOffset = summarySectionOffset; + this.summaryOffsetSectionOffset = summarySectionOffset + summarySection.getElementLength(); + + MCAPCRC32Helper crc32 = new MCAPCRC32Helper(); + summarySection.forEach(record -> record.updateCRC(crc32)); + summaryOffsetSection.forEach(record -> record.updateCRC(crc32)); + crc32.addUnsignedByte(Opcode.FOOTER.id()); + crc32.addLong(ELEMENT_LENGTH); + crc32.addLong(summarySectionOffset); + crc32.addLong(summaryOffsetSectionOffset); + this.summaryCRC32 = crc32.getValue(); + + this.dataInput = null; + } + + public static long computeOffsetFooter(MCAPDataInput dataInput) + { + // Offset to the beginning of the footer. + return dataInput.size() - Record.RECORD_HEADER_LENGTH - ELEMENT_LENGTH - Magic.MAGIC_SIZE; + } + + @Override + public long getElementLength() + { + return ELEMENT_LENGTH; + } + + public Records summarySection() + { + if (summarySection == null && summarySectionOffset != 0) + summarySection = Records.load(dataInput, summarySectionOffset, summarySectionLength()); + return summarySection; + } + + public long summarySectionLength() + { + long summarySectionEnd = summaryOffsetSectionOffset != 0 ? summaryOffsetSectionOffset : computeOffsetFooter(dataInput); + return summarySectionEnd - summarySectionOffset; + } + + public Records summaryOffsetSection() + { + if (summaryOffsetSection == null && summaryOffsetSectionOffset != 0) + summaryOffsetSection = Records.load(dataInput, summaryOffsetSectionOffset, summaryOffsetSectionLength()); + return summaryOffsetSection; + } + + public long summaryOffsetSectionLength() + { + return computeOffsetFooter(dataInput) - summaryOffsetSectionOffset; + } + + private byte[] summaryCRC32Input; + + public byte[] summaryCRC32Input() + { + if (summaryCRC32Input == null) + { + long offset = summaryCRC32StartOffset(); + long length = dataInput.size() - offset - Magic.MAGIC_SIZE - Integer.BYTES; + summaryCRC32Input = dataInput.getBytes(offset, (int) length); + } + return summaryCRC32Input; + } + + /** + * Offset to the first record of the summary section or to the footer if there is no summary section. + *

+ * It is used to compute the CRC. + *

+ */ + public long summaryCRC32StartOffset() + { + return summarySectionOffset != 0 ? summarySectionOffset : computeOffsetFooter(dataInput); + } + + public long summarySectionOffset() + { + return summarySectionOffset; + } + + public long summaryOffsetSectionOffset() + { + return summaryOffsetSectionOffset; + } + + /** + * A CRC-32 of all bytes from the start of the Summary section up through and including the end of + * the previous field (summary_offset_start) in the footer record. A value of 0 indicates the CRC-32 + * is not available. + */ + public long summaryCRC32() + { + return summaryCRC32; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(summarySectionOffset()); + dataOutput.putLong(summaryOffsetSectionOffset()); + dataOutput.putUnsignedInt(summaryCRC32()); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(summarySectionOffset()); + crc32.addLong(summaryOffsetSectionOffset()); + crc32.addUnsignedInt(summaryCRC32()); + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-ofsSummarySection = " + summarySectionOffset(); + out += "\n\t-ofsSummaryOffsetSection = " + summaryOffsetSectionOffset(); + out += "\n\t-summaryCrc32 = " + summaryCRC32(); + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Footer other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Footer other) + { + if (summarySectionOffset() != other.summarySectionOffset()) + return false; + if (summaryOffsetSectionOffset() != other.summaryOffsetSectionOffset()) + return false; + return summaryCRC32() == other.summaryCRC32(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java new file mode 100644 index 000000000..c16ffad9d --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java @@ -0,0 +1,97 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.util.Objects; + +/** + * Header is the first record in an MCAP file. + * + * @see MCAP Header + */ +public class Header implements MCAPElement +{ + private final String profile; + private final String library; + + public Header(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + dataInput.position(elementPosition); + profile = dataInput.getString(); + library = dataInput.getString(); + MCAP.checkLength(elementLength, getElementLength()); + } + + public Header(String profile, String library) + { + this.profile = profile; + this.library = library; + } + + @Override + public long getElementLength() + { + return 2 * Integer.BYTES + profile.length() + library.length(); + } + + public String profile() + { + return profile; + } + + public String library() + { + return library; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putString(profile); + dataOutput.putString(library); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addString(profile); + crc32.addString(library); + return crc32; + } + + @Override + public String toString() + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-profile = " + profile; + out += "\n\t-library = " + library; + return out; + } + + @Override + public boolean equals(Object object) + { + return object instanceof Header other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Header other) + { + if (!Objects.equals(profile(), other.profile())) + return false; + return Objects.equals(library(), other.library()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java new file mode 100644 index 000000000..d7944d089 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java @@ -0,0 +1,28 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +public interface MCAPElement +{ + static String indent(String stringToIndent, int indent) + { + if (indent <= 0) + return stringToIndent; + String indentStr = "\t".repeat(indent); + return indentStr + stringToIndent.replace("\n", "\n" + indentStr); + } + + void write(MCAPDataOutput dataOutput); + + MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32); + + long getElementLength(); + + default String toString(int indent) + { + return indent(toString(), indent); + } + + boolean equals(MCAPElement mcapElement); +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java new file mode 100644 index 000000000..c9c755a1b --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java @@ -0,0 +1,38 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.Arrays; + +/** + * @see MCAP Magic + */ +public class Magic +{ + public static final int MAGIC_SIZE = 8; + public static final byte[] MAGIC_BYTES = {-119, 77, 67, 65, 80, 48, 13, 10}; + + private Magic() + { + + } + + public static void readMagic(MCAPDataInput dataInput, long elementPosition) + { + dataInput.position(elementPosition); + byte[] magic = dataInput.getBytes(MAGIC_SIZE); + if (!(Arrays.equals(magic, MAGIC_BYTES))) + throw new IllegalArgumentException("Invalid magic bytes: " + Arrays.toString(magic) + ". Expected: " + Arrays.toString(MAGIC_BYTES)); + } + + public static void writeMagic(MCAPDataOutput dataOutput) + { + dataOutput.putBytes(MAGIC_BYTES); + } + + public static long getElementLength() + { + return MAGIC_SIZE; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java new file mode 100644 index 000000000..393f46e90 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java @@ -0,0 +1,95 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Message records encode a single timestamped message on a channel. + * The message encoding and schema must match that of the Channel record corresponding to the message's channel ID. + * + * @see MCAP Message + */ +public interface Message extends MCAPElement +{ + static Message load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new MessageDataInputBacked(dataInput, elementPosition, elementLength); + } + + int channelId(); + + long sequence(); + + long logTime(); + + long publishTime(); + + long dataOffset(); + + int dataLength(); + + ByteBuffer messageBuffer(); + + byte[] messageData(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedShort(channelId()); + dataOutput.putUnsignedInt(sequence()); + dataOutput.putLong(logTime()); + dataOutput.putLong(publishTime()); + dataOutput.putUnsignedInt(dataLength()); + dataOutput.putByteBuffer(messageBuffer()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedShort(channelId()); + crc32.addUnsignedInt(sequence()); + crc32.addLong(logTime()); + crc32.addLong(publishTime()); + crc32.addUnsignedInt(dataLength()); + crc32.addByteBuffer(messageBuffer()); + return crc32; + } + + @Override + default long getElementLength() + { + return dataLength() + Short.BYTES + Integer.BYTES + 2 * Long.BYTES; + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Message other) + { + if (channelId() != other.channelId()) + return false; + if (sequence() != other.sequence()) + return false; + if (logTime() != other.logTime()) + return false; + if (publishTime() != other.publishTime()) + return false; + if (dataOffset() != other.dataOffset()) + return false; + if (dataLength() != other.dataLength()) + return false; + return Objects.equals(messageBuffer(), other.messageBuffer()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java new file mode 100644 index 000000000..2278359a7 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java @@ -0,0 +1,130 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.nio.ByteBuffer; + +class MessageDataInputBacked implements Message +{ + private final MCAPDataInput dataInput; + private final int channelId; + private final long sequence; + private final long logTime; + private final long publishTime; + private final long dataOffset; + private final int dataLength; + private WeakReference messageBufferRef; + private WeakReference messageDataRef; + + MessageDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + channelId = dataInput.getUnsignedShort(); + sequence = dataInput.getUnsignedInt(); + logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime"); + publishTime = MCAP.checkPositiveLong(dataInput.getLong(), "publishTime"); + dataOffset = dataInput.position(); + dataLength = (int) (elementLength - (Short.BYTES + Integer.BYTES + 2 * Long.BYTES)); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public int channelId() + { + return channelId; + } + + @Override + public long sequence() + { + return sequence; + } + + @Override + public long logTime() + { + return logTime; + } + + @Override + public long publishTime() + { + return publishTime; + } + + /** + * Returns the offset of the data portion of this message in the buffer returned by + * {@link #messageBuffer()}. + * + * @return the offset of the data portion of this message. + */ + @Override + public long dataOffset() + { + return dataOffset; + } + + /** + * Returns the length of the data portion of this message. + * + * @return the length of the data portion of this message. + */ + @Override + public int dataLength() + { + return dataLength; + } + + /** + * Returns the buffer containing this message, the data AND the header. Use {@link #dataOffset()} + * and {@link #dataLength()} to get the data portion. + * + * @return the buffer containing this message. + */ + @Override + public ByteBuffer messageBuffer() + { + ByteBuffer messageBuffer = messageBufferRef == null ? null : messageBufferRef.get(); + if (messageBuffer == null) + { + messageBuffer = dataInput.getByteBuffer(dataOffset, dataLength, false); + messageBufferRef = new WeakReference<>(messageBuffer); + } + return messageBuffer; + } + + @Override + public byte[] messageData() + { + byte[] messageData = messageDataRef == null ? null : messageDataRef.get(); + + if (messageData == null) + { + messageData = dataInput.getBytes(dataOffset, dataLength); + messageDataRef = new WeakReference<>(messageData); + } + return messageData; + } + + @Override + public String toString() + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-channelId = " + channelId; + out += "\n\t-sequence = " + sequence; + out += "\n\t-logTime = " + logTime; + out += "\n\t-publishTime = " + publishTime; + // out += "\n\t-data = " + data; + return out; + } + + @Override + public boolean equals(Object object) + { + return object instanceof Message other && Message.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java new file mode 100644 index 000000000..1f4112bac --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java @@ -0,0 +1,67 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.List; +import java.util.Objects; + +/** + * MessageIndex records allow readers to locate individual records within a chunk by timestamp. + * A sequence of Message Index records occurs immediately after each chunk. + * Exactly one Message Index record must exist in the sequence for every channel on which a message occurs inside the chunk. + * + * @see MCAP Message Index + */ +public interface MessageIndex extends MCAPElement +{ + int channelId(); + + List messageIndexEntries(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedShort(channelId()); + dataOutput.putCollection(messageIndexEntries()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedShort(channelId()); + crc32.addCollection(messageIndexEntries()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-channelId = " + channelId(); + List messageIndexEntries = messageIndexEntries(); + out += "\n\t-messageIndexEntries = " + (messageIndexEntries == null ? + "null" : + "\n" + EuclidCoreIOTools.getCollectionString("\n", messageIndexEntries, e -> e.toString(indent + 1))); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof MessageIndex other) + { + if (channelId() != other.channelId()) + return false; + return Objects.equals(messageIndexEntries(), other.messageIndexEntries()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java new file mode 100644 index 000000000..5a0808c24 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java @@ -0,0 +1,66 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.util.List; + +public class MessageIndexDataInputBacked implements MessageIndex +{ + private final MCAPDataInput dataInput; + private final long elementLength; + private final int channelId; + private WeakReference> messageIndexEntriesRef; + private final long messageIndexEntriesOffset; + private final long messageIndexEntriesLength; + + public MessageIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + this.elementLength = elementLength; + + dataInput.position(elementPosition); + channelId = dataInput.getUnsignedShort(); + messageIndexEntriesLength = dataInput.getUnsignedInt(); + messageIndexEntriesOffset = dataInput.position(); + } + + @Override + public long getElementLength() + { + return elementLength; + } + + @Override + public int channelId() + { + return channelId; + } + + @Override + public List messageIndexEntries() + { + List messageIndexEntries = messageIndexEntriesRef == null ? null : messageIndexEntriesRef.get(); + + if (messageIndexEntries == null) + { + messageIndexEntries = MCAP.parseList(dataInput, MessageIndexEntry::new, messageIndexEntriesOffset, messageIndexEntriesLength); + messageIndexEntriesRef = new WeakReference<>(messageIndexEntries); + } + + return messageIndexEntries; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MessageIndex other && MessageIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java new file mode 100644 index 000000000..27b3d2c1a --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java @@ -0,0 +1,106 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +public class MessageIndexEntry implements MCAPElement +{ + /** + * Time at which the message was recorded. + */ + private final long logTime; + + /** + * Offset is relative to the start of the uncompressed chunk data. + */ + private final long offset; + + /** + * @param logTime time at which the message was recorded. + * @param offset offset is relative to the start of the uncompressed chunk data. + */ + public MessageIndexEntry(long logTime, long offset) + { + this.logTime = MCAP.checkPositiveLong(logTime, "logTime"); + this.offset = MCAP.checkPositiveLong(offset, "offset"); + } + + public MessageIndexEntry(MCAPDataInput dataInput, long elementPosition) + { + dataInput.position(elementPosition); + logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime"); + offset = MCAP.checkPositiveLong(dataInput.getLong(), "offset"); + } + + @Override + public long getElementLength() + { + return 2 * Long.BYTES; + } + + public long logTime() + { + return logTime; + } + + public long offset() + { + return offset; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(logTime()); + dataOutput.putLong(offset()); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(logTime()); + crc32.addLong(offset()); + return crc32; + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-logTime = " + logTime(); + out += "\n\t-offset = " + offset(); + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MessageIndexEntry other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof MessageIndexEntry other) + { + if (logTime() != other.logTime()) + return false; + return offset() == other.offset(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java new file mode 100644 index 000000000..65649d104 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java @@ -0,0 +1,100 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +public class MessageIndexOffset implements MCAPElement +{ + /** + * Channel ID. + */ + private final int channelId; + /** + * Offset of the message index record for that channel after the chunk, from the start of the file. + */ + private final long offset; + + public MessageIndexOffset(MCAPDataInput dataInput, long elementPosition) + { + dataInput.position(elementPosition); + channelId = dataInput.getUnsignedShort(); + offset = MCAP.checkPositiveLong(dataInput.getLong(), "offset"); + } + + public MessageIndexOffset(int channelId, long offset) + { + this.channelId = channelId; + this.offset = offset; + } + + @Override + public long getElementLength() + { + return Short.BYTES + Long.BYTES; + } + + public int channelId() + { + return channelId; + } + + public long offset() + { + return offset; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedShort(channelId); + dataOutput.putLong(offset); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedShort(channelId); + crc32.addLong(offset); + return crc32; + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-channelId = " + channelId(); + out += "\n\t-offset = " + offset(); + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MessageIndexOffset other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof MessageIndexOffset other) + { + if (channelId() != other.channelId()) + return false; + return offset() == other.offset(); + } + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java new file mode 100644 index 000000000..4a494564b --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java @@ -0,0 +1,96 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.util.List; +import java.util.Objects; + +/** + * A metadata record contains arbitrary user data in key-value pairs. + * + * @see MCAP Metadata + */ +public class Metadata implements MCAPElement +{ + private final String name; + private final List metadata; + private final int metadataLength; + + Metadata(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + dataInput.position(elementPosition); + name = dataInput.getString(); + long start = dataInput.position(); + metadata = MCAP.parseList(dataInput, StringPair::new); // TODO Looks into postponing the loading of the metadata. + metadataLength = (int) (dataInput.position() - start); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public long getElementLength() + { + return Integer.BYTES + name.length() + metadataLength; + } + + public String name() + { + return name; + } + + public List metadata() + { + return metadata; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putString(name); + dataOutput.putCollection(metadata); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addString(name); + crc32.addCollection(metadata); + return crc32; + } + + @Override + public String toString() + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-name = " + name(); + out += "\n\t-metadata = " + EuclidCoreIOTools.getCollectionString(", ", metadata(), e -> e.key()); + return out; + } + + @Override + public boolean equals(Object object) + { + return object instanceof Metadata other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Metadata other) + { + if (!name().equals(other.name())) + return false; + return Objects.equals(metadata(), other.metadata()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java new file mode 100644 index 000000000..c91bad990 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java @@ -0,0 +1,77 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.Objects; + +/** + * A metadata index record contains the location of a metadata record within the file. + * + * @see MCAP Metadata Index + */ +public interface MetadataIndex extends MCAPElement +{ + @Override + default long getElementLength() + { + return 2 * Long.BYTES + Integer.BYTES + name().length(); + } + + Record metadata(); + + long metadataOffset(); + + long metadataLength(); + + String name(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(metadataOffset()); + dataOutput.putLong(metadataLength()); + dataOutput.putString(name()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(metadataOffset()); + crc32.addLong(metadataLength()); + crc32.addString(name()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-metadataOffset = " + metadataOffset(); + out += "\n\t-metadataLength = " + metadataLength(); + out += "\n\t-name = " + name(); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof MetadataIndex other) + { + if (metadataOffset() != other.metadataOffset()) + return false; + if (metadataLength() != other.metadataLength()) + return false; + if (!Objects.equals(name(), other.name())) + return false; + return Objects.equals(metadata(), other.metadata()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java new file mode 100644 index 000000000..d7bb80631 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java @@ -0,0 +1,69 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; + +public class MetadataIndexDataInputBacked implements MetadataIndex +{ + private final MCAPDataInput dataInput; + private final long metadataOffset; + private final long metadataLength; + private final String name; + private WeakReference metadataRef; + + MetadataIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + metadataOffset = MCAP.checkPositiveLong(dataInput.getLong(), "metadataOffset"); + metadataLength = MCAP.checkPositiveLong(dataInput.getLong(), "metadataLength"); + name = dataInput.getString(); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public Record metadata() + { + Record metadata = metadataRef == null ? null : metadataRef.get(); + + if (metadata == null) + { + metadata = new RecordDataInputBacked(dataInput, metadataOffset); + metadataRef = new WeakReference<>(metadata); + } + return metadata; + } + + @Override + public long metadataOffset() + { + return metadataOffset; + } + + @Override + public long metadataLength() + { + return metadataLength; + } + + @Override + public String name() + { + return name; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MetadataIndex other && MetadataIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java new file mode 100644 index 000000000..db7804b31 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java @@ -0,0 +1,120 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +public class MutableAttachmentIndex implements AttachmentIndex +{ + private long attachmentOffset; + private long attachmentLength; + private long logTime; + private long createTime; + private long dataLength; + private String name; + private String mediaType; + private Record attachment; + + @Override + public Record attachment() + { + return attachment; + } + + public void setAttachment(Record attachment) + { + this.attachment = attachment; + Attachment attachmentBody = attachment.body(); + attachmentLength = attachment.getElementLength(); + logTime = attachmentBody.logTime(); + createTime = attachmentBody.createTime(); + dataLength = attachmentBody.dataLength(); + name = attachmentBody.name(); + mediaType = attachmentBody.mediaType(); + } + + @Override + public long attachmentOffset() + { + return attachmentOffset; + } + + public void setAttachmentOffset(long attachmentOffset) + { + this.attachmentOffset = attachmentOffset; + } + + @Override + public long attachmentLength() + { + return attachmentLength; + } + + public void setAttachmentLength(long attachmentLength) + { + this.attachmentLength = attachmentLength; + } + + @Override + public long logTime() + { + return logTime; + } + + public void setLogTime(long logTime) + { + this.logTime = logTime; + } + + @Override + public long createTime() + { + return createTime; + } + + public void setCreateTime(long createTime) + { + this.createTime = createTime; + } + + @Override + public long dataLength() + { + return dataLength; + } + + public void setDataLength(long dataLength) + { + this.dataLength = dataLength; + } + + @Override + public String name() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + @Override + public String mediaType() + { + return mediaType; + } + + public void setMediaType(String mediaType) + { + this.mediaType = mediaType; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof AttachmentIndex other && AttachmentIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java new file mode 100644 index 000000000..bf94c2140 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java @@ -0,0 +1,179 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import com.github.luben.zstd.ZstdCompressCtx; +import us.ihmc.scs2.session.mcap.encoding.LZ4FrameEncoder; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.output.MCAPByteBufferDataOutput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; + +public class MutableChunk implements Chunk +{ + private long messageStartTime; + private long messageEndTime; + private Compression compression = Compression.LZ4; + + private Records records; + private long recordsCRC32 = -1L; + private ByteBuffer recordsCompressedData; + + public void setMessageStartTime(long messageStartTime) + { + this.messageStartTime = messageStartTime; + } + + public void setMessageEndTime(long messageEndTime) + { + this.messageEndTime = messageEndTime; + } + + public void setCompression(Compression compression) + { + this.compression = compression; + } + + public void setRecords(Records records) + { + this.records = records; + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + @Override + public long recordsUncompressedLength() + { + return records.getElementLength(); + } + + @Override + public long uncompressedCRC32() + { + return records == null ? 0 : records.updateCRC(null).getValue(); + } + + @Override + public Compression compression() + { + return compression; + } + + @Override + public long recordsCompressedLength() + { + return getRecordsCompressedBuffer().remaining(); + } + + @Override + public Records records() + { + return records; + } + + @Override + public long getElementLength() + { + getRecordsCompressedBuffer(); // Make sure the compressed data is available. + return 4 * Long.BYTES + Integer.BYTES + compression.getLength() + recordsCompressedLength(); + } + + @Override + public ByteBuffer getRecordsCompressedBuffer(boolean directBuffer) + { + long newRecordsCRC32 = uncompressedCRC32(); + + if (recordsCompressedData == null || recordsCRC32 != newRecordsCRC32) + { + recordsCRC32 = newRecordsCRC32; + Objects.requireNonNull(compression, "The compression has not been set yet."); + Objects.requireNonNull(records, "The records have not been set yet."); + + ByteBuffer uncompressedBuffer = getRecordsUncompressedBuffer(compression == Compression.ZSTD); + + recordsCompressedData = switch (compression) + { + case NONE: + { + yield uncompressedBuffer; + } + case LZ4: + { + LZ4FrameEncoder lz4FrameEncoder = new LZ4FrameEncoder(); + yield lz4FrameEncoder.encode(uncompressedBuffer, null); + } + case ZSTD: + { + try (ZstdCompressCtx zstdCompressCtx = new ZstdCompressCtx()) + { + yield zstdCompressCtx.compress(uncompressedBuffer); + } + } + }; + recordsCompressedData.order(ByteOrder.LITTLE_ENDIAN); + } + + return recordsCompressedData.duplicate(); + } + + @Override + public ByteBuffer getRecordsUncompressedBuffer(boolean directBuffer) + { + MCAPByteBufferDataOutput recordsOutput = new MCAPByteBufferDataOutput((int) records.getElementLength(), 2, directBuffer); + records.forEach(element -> element.write(recordsOutput)); + recordsOutput.close(); + return recordsOutput.getBuffer(); + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(messageStartTime); + dataOutput.putLong(messageEndTime); + dataOutput.putLong(records.getElementLength()); + dataOutput.putUnsignedInt(records.updateCRC(null).getValue()); + dataOutput.putString(compression.getName()); + ByteBuffer recordsCompressedBuffer = getRecordsCompressedBuffer(); + dataOutput.putLong(recordsCompressedBuffer.remaining()); + dataOutput.putByteBuffer(recordsCompressedBuffer); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(messageStartTime); + crc32.addLong(messageEndTime); + crc32.addLong(records.getElementLength()); + crc32.addUnsignedInt(records.updateCRC(null).getValue()); + crc32.addString(compression.getName()); + ByteBuffer recordsCompressedBuffer = getRecordsCompressedBuffer(); + crc32.addLong(recordsCompressedBuffer.remaining()); + crc32.addByteBuffer(recordsCompressedBuffer); + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Chunk other && Chunk.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java new file mode 100644 index 000000000..01f1fe433 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java @@ -0,0 +1,172 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class MutableChunkIndex implements ChunkIndex +{ + private Record chunk; + private long messageStartTime; + private long messageEndTime; + private long chunkOffset; + private long chunkLength; + private long messageIndexOffsetsLength; + private List messageIndexOffsets; + private long messageIndexLength; + private Compression compression; + private long recordsCompressedLength; + private long recordsUncompressedLength; + + @Override + public Record chunk() + { + return chunk; + } + + public void setChunk(Record chunk) + { + this.chunk = chunk; + Chunk body = chunk.body(); + messageStartTime = body.messageStartTime(); + messageEndTime = body.messageEndTime(); + chunkLength = chunk.getElementLength(); + // Resets the message index offsets, they have to be set manually. + messageIndexOffsets = new ArrayList<>(); + messageIndexOffsetsLength = 0L; + compression = body.compression(); + recordsCompressedLength = body.recordsCompressedLength(); + recordsUncompressedLength = body.recordsUncompressedLength(); + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + public void setMessageStartTime(long messageStartTime) + { + this.messageStartTime = messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + public void setMessageEndTime(long messageEndTime) + { + this.messageEndTime = messageEndTime; + } + + @Override + public long chunkOffset() + { + return chunkOffset; + } + + public void setChunkOffset(long chunkOffset) + { + this.chunkOffset = chunkOffset; + } + + @Override + public long chunkLength() + { + return chunkLength; + } + + public void setChunkLength(long chunkLength) + { + this.chunkLength = chunkLength; + } + + @Override + public long messageIndexOffsetsLength() + { + return messageIndexOffsetsLength; + } + + public void setMessageIndexOffsetsLength(long messageIndexOffsetsLength) + { + this.messageIndexOffsetsLength = messageIndexOffsetsLength; + } + + @Override + public List messageIndexOffsets() + { + return messageIndexOffsets; + } + + public void setMessageIndexOffsets(List messageIndexOffsets) + { + this.messageIndexOffsets = messageIndexOffsets; + messageIndexOffsetsLength = messageIndexOffsets.stream().mapToLong(MessageIndexOffset::getElementLength).sum(); + } + + @Override + public long messageIndexLength() + { + return messageIndexLength; + } + + public void setMessageIndexLength(long messageIndexLength) + { + this.messageIndexLength = messageIndexLength; + } + + @Override + public Compression compression() + { + return compression; + } + + public void setCompression(Compression compression) + { + this.compression = compression; + } + + @Override + public long recordsCompressedLength() + { + return recordsCompressedLength; + } + + public void setRecordsCompressedLength(long recordsCompressedLength) + { + this.recordsCompressedLength = recordsCompressedLength; + } + + @Override + public long recordsUncompressedLength() + { + return recordsUncompressedLength; + } + + public void setRecordsUncompressedLength(long recordsUncompressedLength) + { + this.recordsUncompressedLength = recordsUncompressedLength; + } + + @Override + public long getElementLength() + { + Objects.requireNonNull(messageIndexOffsets, "The message index offsets must be set before calling this method."); + Objects.requireNonNull(compression, "The compression must be set before calling this method."); + return 7 * Long.BYTES + Integer.BYTES + messageIndexOffsetsLength + compression.getLength(); + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof ChunkIndex other && ChunkIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java new file mode 100644 index 000000000..0439ab866 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java @@ -0,0 +1,116 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import java.nio.ByteBuffer; + +public class MutableMessage implements Message +{ + private int channelId; + private long sequence; + private long logTime; + private long publishTime; + private long dataOffset; + private int dataLength; + private byte[] messageData; + + public MutableMessage(int channelId, byte[] data) + { + this.channelId = channelId; + this.messageData = data; + this.dataLength = data.length; + } + + @Override + public int channelId() + { + return channelId; + } + + public void setChannelId(int channelId) + { + this.channelId = channelId; + } + + @Override + public long sequence() + { + return sequence; + } + + public void setSequence(long sequence) + { + this.sequence = sequence; + } + + @Override + public long logTime() + { + return logTime; + } + + public void setLogTime(long logTime) + { + this.logTime = logTime; + } + + @Override + public long publishTime() + { + return publishTime; + } + + public void setPublishTime(long publishTime) + { + this.publishTime = publishTime; + } + + @Override + public long dataOffset() + { + return dataOffset; + } + + public void setDataOffset(long dataOffset) + { + this.dataOffset = dataOffset; + } + + @Override + public int dataLength() + { + return dataLength; + } + + public void setDataLength(int dataLength) + { + this.dataLength = dataLength; + } + + @Override + public byte[] messageData() + { + return messageData; + } + + @Override + public ByteBuffer messageBuffer() + { + return ByteBuffer.wrap(messageData); + } + + public void setMessageData(byte[] messageData) + { + this.messageData = messageData; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Message other && Message.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java new file mode 100644 index 000000000..454a603a4 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java @@ -0,0 +1,65 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import java.util.ArrayList; +import java.util.List; + +public class MutableMessageIndex implements MessageIndex +{ + private int channelId; + private List messageIndexEntries; + private long messageIndexEntriesLength = 0; + + public void setChannelId(int channelId) + { + this.channelId = channelId; + } + + public void setMessageIndexEntries(List messageIndexEntries) + { + this.messageIndexEntries = messageIndexEntries; + messageIndexEntriesLength = messageIndexEntries.stream().mapToLong(MessageIndexEntry::getElementLength).sum(); + } + + public void addMessageIndexEntry(MessageIndexEntry messageIndexEntry) + { + if (messageIndexEntries == null) + messageIndexEntries = new ArrayList<>(); + messageIndexEntries.add(messageIndexEntry); + messageIndexEntriesLength += messageIndexEntry.getElementLength(); + } + + public long messageIndexEntriesLength() + { + return messageIndexEntriesLength; + } + + @Override + public long getElementLength() + { + return Short.BYTES + Integer.BYTES + messageIndexEntriesLength; + } + + @Override + public int channelId() + { + return channelId; + } + + @Override + public List messageIndexEntries() + { + return messageIndexEntries; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MessageIndex other && MessageIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java new file mode 100644 index 000000000..ee3385c05 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java @@ -0,0 +1,68 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +public class MutableMetadataIndex implements MetadataIndex +{ + private long metadataOffset; + private long metadataLength; + private String name; + private Record metadata; + + @Override + public Record metadata() + { + return metadata; + } + + public void setMetadata(Record metadata) + { + this.metadata = metadata; + Metadata metadataBody = metadata.body(); + metadataLength = metadata.getElementLength(); + name = metadataBody.name(); + } + + @Override + public long metadataOffset() + { + return metadataOffset; + } + + public void setMetadataOffset(long metadataOffset) + { + this.metadataOffset = metadataOffset; + } + + @Override + public long metadataLength() + { + return metadataLength; + } + + public void setMetadataLength(long metadataLength) + { + this.metadataLength = metadataLength; + } + + @Override + public String name() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof MetadataIndex other && MetadataIndex.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java new file mode 100644 index 000000000..3902935c0 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java @@ -0,0 +1,110 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.Objects; + +public class MutableRecord implements Record +{ + private Opcode op; + private Object body; + + public MutableRecord() + { + } + + public MutableRecord(Object body) + { + this.op = body == null ? null : Opcode.byBodyType(body.getClass()); + this.body = body; + } + + public void setOp(Opcode op) + { + this.op = op; + } + + public void setBody(Object body) + { + this.body = body; + } + + @Override + public Opcode op() + { + return op; + } + + @Override + public T body() + { + return (T) body; + } + + @Override + public void write(MCAPDataOutput dataOutput, boolean writeBody) + { + dataOutput.putUnsignedByte(op == null ? 0 : op.id()); + dataOutput.putLong(bodyLength()); + + if (writeBody) + { + if (body instanceof MCAPElement) + ((MCAPElement) body).write(dataOutput); + else if (body instanceof byte[]) + dataOutput.putBytes((byte[]) body); + else + throw new UnsupportedOperationException("Unsupported body type: " + body.getClass()); + } + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedByte(op == null ? 0 : op.id()); + crc32.addLong(bodyLength()); + + if (body instanceof MCAPElement) + ((MCAPElement) body).updateCRC(crc32); + else if (body instanceof byte[]) + crc32.addBytes((byte[]) body); + else + throw new UnsupportedOperationException("Unsupported body type: " + body.getClass()); + return crc32; + } + + @Override + public long getElementLength() + { + return Record.RECORD_HEADER_LENGTH + bodyLength(); + } + + @Override + public long bodyLength() + { + Objects.requireNonNull(body); + long bodyLength; + if (body instanceof MCAPElement) + bodyLength = ((MCAPElement) body).getElementLength(); + else if (body instanceof byte[]) + bodyLength = ((byte[]) body).length; + else + throw new UnsupportedOperationException("Unsupported body type: " + body.getClass()); + return bodyLength; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Record other && Record.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java new file mode 100644 index 000000000..7c9168b82 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java @@ -0,0 +1,233 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class MutableStatistics implements Statistics +{ + private long messageCount; + private int schemaCount; + private long channelCount; + private long attachmentCount; + private long metadataCount; + private long chunkCount; + private long messageStartTime; + private long messageEndTime; + private List channelMessageCounts; + private long channelMessageCountsLength; + + @Override + public long getElementLength() + { + return 3 * Long.BYTES + 5 * Integer.BYTES + Short.BYTES + channelMessageCountsLength; + } + + public void incrementCount(Opcode op) + { + switch (op) + { + case MESSAGE: + incrementMessageCount(); + break; + case SCHEMA: + incrementSchemaCount(); + break; + case CHANNEL: + incrementChannelCount(); + break; + case ATTACHMENT: + incrementAttachmentCount(); + break; + case METADATA: + incrementMetadataCount(); + break; + case CHUNK: + incrementChunkCount(); + break; + default: + // Do nothing + } + } + + public void incrementMessageCount() + { + messageCount++; + } + + public void setMessageCount(long messageCount) + { + this.messageCount = messageCount; + } + + public void incrementSchemaCount() + { + schemaCount++; + } + + public void setSchemaCount(int schemaCount) + { + this.schemaCount = schemaCount; + } + + public void incrementChannelCount() + { + channelCount++; + } + + public void setChannelCount(long channelCount) + { + this.channelCount = channelCount; + } + + public void incrementAttachmentCount() + { + attachmentCount++; + } + + public void setAttachmentCount(long attachmentCount) + { + this.attachmentCount = attachmentCount; + } + + public void incrementMetadataCount() + { + metadataCount++; + } + + public void setMetadataCount(long metadataCount) + { + this.metadataCount = metadataCount; + } + + public void incrementChunkCount() + { + chunkCount++; + } + + public void setChunkCount(long chunkCount) + { + this.chunkCount = chunkCount; + } + + public void updateMessageTimes(long messageStartTime, long messageEndTime) + { + updateMessageStartTime(messageStartTime); + updateMessageEndTime(messageEndTime); + } + + public void updateMessageStartTime(long messageStartTime) + { + if (this.messageStartTime == 0) + this.messageStartTime = messageStartTime; + else + this.messageStartTime = Math.min(this.messageStartTime, messageStartTime); + } + + public void setMessageStartTime(long messageStartTime) + { + this.messageStartTime = messageStartTime; + } + + public void updateMessageEndTime(long messageEndTime) + { + if (this.messageEndTime == 0) + this.messageEndTime = messageEndTime; + else + this.messageEndTime = Math.max(this.messageEndTime, messageEndTime); + } + + public void setMessageEndTime(long messageEndTime) + { + this.messageEndTime = messageEndTime; + } + + public void incrementChannelMessageCount(int channelIndex) + { + if (channelMessageCounts == null) + channelMessageCounts = new ArrayList<>(); + Optional countOptional = channelMessageCounts.stream().filter(count -> count.channelId() == channelIndex).findFirst(); + + if (countOptional.isPresent()) + { + countOptional.get().incrementMessageCount(); + } + else + { + channelMessageCounts.add(new ChannelMessageCount(channelIndex, 1)); + channelMessageCountsLength += ChannelMessageCount.ELEMENT_LENGTH; + } + } + + public void setChannelMessageCounts(List channelMessageCounts) + { + this.channelMessageCounts = channelMessageCounts; + channelMessageCountsLength = channelMessageCounts.size() * ChannelMessageCount.ELEMENT_LENGTH; + } + + @Override + public long messageCount() + { + return messageCount; + } + + @Override + public int schemaCount() + { + return schemaCount; + } + + @Override + public long channelCount() + { + return channelCount; + } + + @Override + public long attachmentCount() + { + return attachmentCount; + } + + @Override + public long metadataCount() + { + return metadataCount; + } + + @Override + public long chunkCount() + { + return chunkCount; + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + @Override + public List channelMessageCounts() + { + return channelMessageCounts; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Statistics other && Statistics.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java new file mode 100644 index 000000000..7d5a9a052 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java @@ -0,0 +1,176 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import gnu.trove.map.hash.TIntObjectHashMap; + +public enum Opcode +{ + /** + * Header is the first record in an MCAP file. + * + * @see Header + */ + HEADER(1), + /** + * Footer records contain end-of-file information. MCAP files must end with a Footer record. + * + * @see Footer + */ + FOOTER(2), + + /** + * A Schema record defines an individual schema. + * Schema records are uniquely identified within a file by their schema ID. + * A Schema record must occur at least once in the file prior to any Channel referring to its ID. + * Any two schema records sharing a common ID must be identical. + * + * @see Schema + */ + SCHEMA(3), + /** + * Channel records define encoded streams of messages on topics. + * Channel records are uniquely identified within a file by their channel ID. + * A Channel record must occur at least once in the file prior to any message referring to its channel ID. + * Any two channel records sharing a common ID must be identical. + * + * @see Channel + */ + CHANNEL(4), + /** + * Message records encode a single timestamped message on a channel. + * The message encoding and schema must match that of the Channel record corresponding to the message's channel ID. + * + * @see Message + */ + MESSAGE(5), + /** + * Chunk records each contain a batch of Schema, Channel, and Message records. + * The batch of records contained in a chunk may be compressed or uncompressed. + * All messages in the chunk must reference channels recorded earlier in the file (in a previous chunk or earlier in the current chunk). + * + * @see Chunk + */ + CHUNK(6), + /** + * MessageIndex records allow readers to locate individual records within a chunk by timestamp. + * A sequence of Message Index records occurs immediately after each chunk. + * Exactly one Message Index record must exist in the sequence for every channel on which a message occurs inside the chunk. + * + * @see MessageIndex + */ + MESSAGE_INDEX(7), + /** + * ChunkIndex records contain the location of a Chunk record and its associated MessageIndex records. + * A ChunkIndex record exists for every Chunk in the file. + * + * @see ChunkIndex + */ + CHUNK_INDEX(8), + /** + * Attachment records contain auxiliary artifacts such as text, core dumps, calibration data, or other arbitrary data. + * Attachment records must not appear within a chunk. + * + * @see Attachment + */ + ATTACHMENT(9), + /** + * An Attachment Index record contains the location of an attachment in the file. + * An Attachment Index record exists for every Attachment record in the file. + * + * @see AttachmentIndex + */ + ATTACHMENT_INDEX(10), + /** + * A Statistics record contains summary information about the recorded data. + * The statistics record is optional, but the file should contain at most one. + * + * @see Statistics + */ + STATISTICS(11), + /** + * A metadata record contains arbitrary user data in key-value pairs. + * + * @see Metadata + */ + METADATA(12), + /** + * A metadata index record contains the location of a metadata record within the file. + * + * @see MetadataIndex + */ + METADATA_INDEX(13), + /** + * A Summary Offset record contains the location of records within the summary section. + * Each Summary Offset record corresponds to a group of summary records with the same opcode. + * + * @see SummaryOffset + */ + SUMMARY_OFFSET(14), + /** + * A Data End record indicates the end of the data section. + * + * @see DataEnd + */ + DATA_END(15); + + private final int id; + + Opcode(int id) + { + this.id = id; + } + + public int id() + { + return id; + } + + private static final TIntObjectHashMap byId = new TIntObjectHashMap<>(15); + + static + { + for (Opcode e : Opcode.values()) + byId.put(e.id(), e); + } + + public static Opcode byId(int id) + { + return byId.get(id); + } + + public static Opcode byBodyType(Class bodyType) + { + if (bodyType == null) + return null; + if (Header.class.isAssignableFrom(bodyType)) + return HEADER; + if (Footer.class.isAssignableFrom(bodyType)) + return FOOTER; + if (Schema.class.isAssignableFrom(bodyType)) + return SCHEMA; + if (Channel.class.isAssignableFrom(bodyType)) + return CHANNEL; + if (Message.class.isAssignableFrom(bodyType)) + return MESSAGE; + if (Chunk.class.isAssignableFrom(bodyType)) + return CHUNK; + if (MessageIndex.class.isAssignableFrom(bodyType)) + return MESSAGE_INDEX; + if (ChunkIndex.class.isAssignableFrom(bodyType)) + return CHUNK_INDEX; + if (Attachment.class.isAssignableFrom(bodyType)) + return ATTACHMENT; + if (AttachmentIndex.class.isAssignableFrom(bodyType)) + return ATTACHMENT_INDEX; + if (Statistics.class.isAssignableFrom(bodyType)) + return STATISTICS; + if (Metadata.class.isAssignableFrom(bodyType)) + return METADATA; + if (MetadataIndex.class.isAssignableFrom(bodyType)) + return METADATA_INDEX; + if (SummaryOffset.class.isAssignableFrom(bodyType)) + return SUMMARY_OFFSET; + if (DataEnd.class.isAssignableFrom(bodyType)) + return DATA_END; + throw new IllegalArgumentException("Unsupported body type: " + bodyType); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java new file mode 100644 index 000000000..9eacb4ede --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java @@ -0,0 +1,106 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.List; +import java.util.Objects; + +/** + * MCAP files may contain a variety of records. + * Records are identified by a single-byte opcode. + * Record opcodes in the range 0x01-0x7F are reserved for future MCAP format usage. + * 0x80-0xFF are reserved for application extensions and user proposals. + * 0x00 is not a valid opcode. + * + * @see MCAP Records + */ +public interface Record extends MCAPElement +{ + int RECORD_HEADER_LENGTH = 9; + + static Record load(MCAPDataInput dataInput) + { + return load(dataInput, dataInput.position()); + } + + static Record load(MCAPDataInput dataInput, long elementPosition) + { + return new RecordDataInputBacked(dataInput, elementPosition); + } + + Opcode op(); + + long bodyLength(); + + T body(); + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-op = " + op(); + Object body = body(); + out += "\n\t-body = " + (body == null ? "null" : "\n" + ((MCAPElement) body).toString(indent + 2)); + return MCAPElement.indent(out, indent); + } + + @Override + default void write(MCAPDataOutput dataOutput) + { + write(dataOutput, true); + } + + void write(MCAPDataOutput dataOutput, boolean writeBody); + + default Record generateMetadataIndexRecord(long metadataOffset) + { + if (op() != Opcode.METADATA) + throw new UnsupportedOperationException("Cannot generate a metadata index record from a non-metadata record"); + + MutableMetadataIndex metadataIndex = new MutableMetadataIndex(); + metadataIndex.setMetadataOffset(metadataOffset); + metadataIndex.setMetadata(this); + return new MutableRecord(metadataIndex); + } + + default Record generateAttachmentIndexRecord(long attachmentOffset) + { + if (op() != Opcode.ATTACHMENT) + throw new UnsupportedOperationException("Cannot generate an attachment index record from a non-attachment record"); + + MutableAttachmentIndex attachmentIndex = new MutableAttachmentIndex(); + attachmentIndex.setAttachmentOffset(attachmentOffset); + attachmentIndex.setAttachment(this); + return new MutableRecord(attachmentIndex); + } + + default Record generateChunkIndexRecord(long chunkOffset, List messageIndexRecordList) + { + if (op() != Opcode.CHUNK) + throw new UnsupportedOperationException("Cannot generate a chunk index record from a non-chunk record"); + + MutableChunkIndex chunkIndex = new MutableChunkIndex(); + chunkIndex.setChunkOffset(chunkOffset); + chunkIndex.setChunk(this); + chunkIndex.setMessageIndexOffsets(Records.generateMessageIndexOffsets(chunkOffset + getElementLength(), messageIndexRecordList)); + chunkIndex.setMessageIndexLength(messageIndexRecordList.stream().mapToLong(Record::getElementLength).sum()); + return new MutableRecord(chunkIndex); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Record other) + { + if (op() != other.op()) + return false; + return Objects.equals(body(), other.body()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java new file mode 100644 index 000000000..3b2ca020f --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java @@ -0,0 +1,131 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; + +public class RecordDataInputBacked implements Record +{ + + private final MCAPDataInput dataInput; + + private final Opcode op; + private final long bodyLength; + private final long bodyOffset; + private WeakReference bodyRef; + + public RecordDataInputBacked(MCAPDataInput dataInput, long elementPosition) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + op = Opcode.byId(dataInput.getUnsignedByte()); + bodyLength = MCAP.checkPositiveLong(dataInput.getLong(), "bodyLength"); + bodyOffset = dataInput.position(); + MCAP.checkLength(getElementLength(), (int) (bodyLength + RECORD_HEADER_LENGTH)); + } + + @Override + public void write(MCAPDataOutput dataOutput, boolean writeBody) + { + dataOutput.putUnsignedByte(op.id()); + dataOutput.putLong(bodyLength); + if (writeBody) + dataOutput.putBytes(dataInput.getBytes(bodyOffset, (int) bodyLength)); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedByte(op.id()); + crc32.addLong(bodyLength); + crc32.addBytes(dataInput.getBytes(bodyOffset, (int) bodyLength)); + return crc32; + } + + @Override + public Opcode op() + { + return op; + } + + @Override + public long bodyLength() + { + return bodyLength; + } + + @Override + @SuppressWarnings("unchecked") + public T body() + { + Object body = bodyRef == null ? null : bodyRef.get(); + + if (body == null) + { + if (op == null) + { + body = dataInput.getBytes(bodyOffset, (int) bodyLength); + } + else + { + body = switch (op) + { + case MESSAGE -> Message.load(dataInput, bodyOffset, bodyLength); + case METADATA_INDEX -> new MetadataIndexDataInputBacked(dataInput, bodyOffset, bodyLength); + case CHUNK -> Chunk.load(dataInput, bodyOffset, bodyLength); + case SCHEMA -> Schema.load(dataInput, bodyOffset, bodyLength); + case CHUNK_INDEX -> ChunkIndex.load(dataInput, bodyOffset, bodyLength); + case DATA_END -> new DataEnd(dataInput, bodyOffset, bodyLength); + case ATTACHMENT_INDEX -> AttachmentIndex.load(dataInput, bodyOffset, bodyLength); + case STATISTICS -> Statistics.load(dataInput, bodyOffset, bodyLength); + case MESSAGE_INDEX -> new MessageIndexDataInputBacked(dataInput, bodyOffset, bodyLength); + case CHANNEL -> Channel.load(dataInput, bodyOffset, bodyLength); + case METADATA -> new Metadata(dataInput, bodyOffset, bodyLength); + case ATTACHMENT -> Attachment.load(dataInput, bodyOffset, bodyLength); + case HEADER -> new Header(dataInput, bodyOffset, bodyLength); + case FOOTER -> (Footer) new Footer(dataInput, bodyOffset, bodyLength); + case SUMMARY_OFFSET -> (SummaryOffset) new SummaryOffset(dataInput, bodyOffset, bodyLength); + }; + } + + bodyRef = new WeakReference<>(body); + } + return (T) body; + } + + @Override + public long getElementLength() + { + return RECORD_HEADER_LENGTH + bodyLength; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-op = " + op(); + out += "\n\t-bodyLength = " + bodyLength; + out += "\n\t-bodyOffset = " + bodyOffset; + Object body = body(); + out += "\n\t-body = " + (body == null ? "null" : "\n" + ((MCAPElement) body).toString(indent + 2)); + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Record other && Record.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Records.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Records.java new file mode 100644 index 000000000..3121933f1 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Records.java @@ -0,0 +1,182 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import gnu.trove.map.hash.TIntObjectHashMap; +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import static us.ihmc.scs2.session.mcap.specs.records.MCAPElement.indent; + +/** + * @see MCAP Records + */ +public class Records extends ArrayList +{ + public Records() + { + } + + public Records(Collection collection) + { + super(collection); + } + + public static Records load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + Records records = new Records(); + MCAP.parseList(dataInput, RecordDataInputBacked::new, elementPosition, elementLength, records); + return records; + } + + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + + for (Record record : this) + record.updateCRC(crc32); + + return crc32; + } + + public Records crop(long startTimestamp, long endTimestamp) + { + Records croppedRecords = new Records(); + + for (Record record : this) + { + switch (record.op()) + { + case HEADER: + case FOOTER: + case SCHEMA: + case CHANNEL: + case METADATA: + case METADATA_INDEX: + { + croppedRecords.add(record); + break; + } + case MESSAGE: + { + Message message = record.body(); + if (message.logTime() >= startTimestamp && message.logTime() <= endTimestamp) + croppedRecords.add(record); + break; + } + case ATTACHMENT: + { + Attachment attachment = record.body(); + if (attachment.logTime() >= startTimestamp && attachment.logTime() <= endTimestamp) + croppedRecords.add(record); + break; + } + default: + throw new IllegalArgumentException("Unexpected value: " + record.op()); + } + } + return croppedRecords; + } + + public long getElementLength() + { + // TODO Improve this by keeping track of modifications to the records. + return stream().mapToLong(Record::getElementLength).sum(); + } + + @Override + public String toString() + { + return toString(0); + } + + public String toString(int indent) + { + if (isEmpty()) + return indent(getClass().getSimpleName() + ": []", indent); + + String out = getClass().getSimpleName() + "[\n"; + out += EuclidCoreIOTools.getCollectionString("\n", this, r -> r.toString(indent + 1)); + return indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + if (object == this) + return true; + + if (object instanceof Records other) + { + if (size() != other.size()) + return false; + for (int i = 0; i < size(); i++) + { + Record record = get(i); + Record otherRecord = other.get(i); + if (!record.equals(otherRecord)) + return false; + } + return true; + } + + return false; + } + + public List generateMessageIndexList() + { + TIntObjectHashMap messageIndexMap = new TIntObjectHashMap<>(); + long messageIndexOffset = 0; + + for (Record record : this) + { + if (record.op() == Opcode.MESSAGE) + { + Message message = record.body(); + int channelId = message.channelId(); + + MutableMessageIndex messageIndex = messageIndexMap.get(channelId); + + if (messageIndex == null) + { + messageIndex = new MutableMessageIndex(); + messageIndex.setChannelId(channelId); + messageIndexMap.put(channelId, messageIndex); + } + + MessageIndexEntry messageIndexEntry = new MessageIndexEntry(message.logTime(), messageIndexOffset); + messageIndex.addMessageIndexEntry(messageIndexEntry); + } + messageIndexOffset += record.getElementLength(); + } + + List messageIndices = Arrays.asList(messageIndexMap.values(new MutableMessageIndex[messageIndexMap.size()])); + messageIndices.sort(Comparator.comparingInt(MessageIndex::channelId)); + return messageIndices; + } + + public static List generateMessageIndexOffsets(long offset, List messageIndexRecordList) + { + List messageIndexOffsets = new ArrayList<>(); + + long messageIndexOffset = offset; + + for (Record messageIndexRecord : messageIndexRecordList) + { + if (messageIndexRecord.op() != Opcode.MESSAGE_INDEX) + throw new IllegalArgumentException("Expected a message index record, but got: " + messageIndexRecord.op()); + MessageIndex messageIndex = messageIndexRecord.body(); + messageIndexOffsets.add(new MessageIndexOffset(messageIndex.channelId(), messageIndexOffset)); + messageIndexOffset += messageIndexRecord.getElementLength(); + } + + return messageIndexOffsets; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Schema.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Schema.java new file mode 100644 index 000000000..451c80ae6 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Schema.java @@ -0,0 +1,98 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; + +/** + * A Schema record defines an individual schema. + * Schema records are uniquely identified within a file by their schema ID. + * A Schema record must occur at least once in the file prior to any Channel referring to its ID. + * Any two schema records sharing a common ID must be identical. + * + * @see MCAP Schema + */ +public interface Schema extends MCAPElement +{ + static Schema load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new SchemaDataInputBacked(dataInput, elementPosition, elementLength); + } + + @Override + default long getElementLength() + { + return Short.BYTES + 3 * Integer.BYTES + name().length() + encoding().length() + (int) dataLength(); + } + + int id(); + + String name(); + + String encoding(); + + long dataLength(); + + ByteBuffer data(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedShort(id()); + dataOutput.putString(name()); + dataOutput.putString(encoding()); + dataOutput.putUnsignedInt(dataLength()); + dataOutput.putByteBuffer(data()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedShort(id()); + crc32.addString(name()); + crc32.addString(encoding()); + crc32.addUnsignedInt(dataLength()); + crc32.addByteBuffer(data()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ":"; + out += "\n\t-id = " + id(); + out += "\n\t-name = " + name(); + out += "\n\t-encoding = " + encoding(); + out += "\n\t-dataLength = " + dataLength(); + out += "\n\t-data = " + Arrays.toString(data().array()); + return MCAPElement.indent(out, indent); + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Schema other) + { + if (id() != other.id()) + return false; + if (!Objects.equals(name(), other.name())) + return false; + if (!Objects.equals(encoding(), other.encoding())) + return false; + if (dataLength() != other.dataLength()) + return false; + return Arrays.equals(data().array(), other.data().array()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SchemaDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SchemaDataInputBacked.java new file mode 100644 index 000000000..56b412331 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SchemaDataInputBacked.java @@ -0,0 +1,80 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.nio.ByteBuffer; + +public class SchemaDataInputBacked implements Schema +{ + private final MCAPDataInput dataInput; + private final int id; + private final String name; + private final String encoding; + private final long dataLength; + private final long dataOffset; + private WeakReference dataRef; + + public SchemaDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + id = dataInput.getUnsignedShort(); + name = dataInput.getString(); + encoding = dataInput.getString(); + dataLength = dataInput.getUnsignedInt(); + dataOffset = dataInput.position(); + MCAP.checkLength(elementLength, getElementLength()); + } + + @Override + public int id() + { + return id; + } + + @Override + public String name() + { + return name; + } + + @Override + public String encoding() + { + return encoding; + } + + @Override + public long dataLength() + { + return dataLength; + } + + @Override + public ByteBuffer data() + { + ByteBuffer data = this.dataRef == null ? null : this.dataRef.get(); + + if (data == null) + { + data = dataInput.getByteBuffer(dataOffset, (int) dataLength, false); + dataRef = new WeakReference<>(data); + } + return data; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Schema schema && Schema.super.equals(schema); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Statistics.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Statistics.java new file mode 100644 index 000000000..c20581ee6 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Statistics.java @@ -0,0 +1,118 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.euclid.tools.EuclidCoreIOTools; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.List; +import java.util.Objects; + +/** + * A Statistics record contains summary information about the recorded data. + * The statistics record is optional, but the file should contain at most one. + * + * @see MCAP Statistics + */ +public interface Statistics extends MCAPElement +{ + static Statistics load(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + return new StatisticsDataInputBacked(dataInput, elementPosition, elementLength); + } + + long messageCount(); + + int schemaCount(); + + long channelCount(); + + long attachmentCount(); + + long metadataCount(); + + long chunkCount(); + + long messageStartTime(); + + long messageEndTime(); + + List channelMessageCounts(); + + @Override + default void write(MCAPDataOutput dataOutput) + { + dataOutput.putLong(messageCount()); + dataOutput.putUnsignedShort(schemaCount()); + dataOutput.putUnsignedInt(channelCount()); + dataOutput.putUnsignedInt(attachmentCount()); + dataOutput.putUnsignedInt(metadataCount()); + dataOutput.putUnsignedInt(chunkCount()); + dataOutput.putLong(messageStartTime()); + dataOutput.putLong(messageEndTime()); + dataOutput.putCollection(channelMessageCounts()); + } + + @Override + default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addLong(messageCount()); + crc32.addUnsignedShort(schemaCount()); + crc32.addUnsignedInt(channelCount()); + crc32.addUnsignedInt(attachmentCount()); + crc32.addUnsignedInt(metadataCount()); + crc32.addUnsignedInt(chunkCount()); + crc32.addLong(messageStartTime()); + crc32.addLong(messageEndTime()); + crc32.addCollection(channelMessageCounts()); + return crc32; + } + + @Override + default String toString(int indent) + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-messageCount = " + messageCount(); + out += "\n\t-schemaCount = " + schemaCount(); + out += "\n\t-channelCount = " + channelCount(); + out += "\n\t-attachmentCount = " + attachmentCount(); + out += "\n\t-metadataCount = " + metadataCount(); + out += "\n\t-chunkCount = " + chunkCount(); + out += "\n\t-messageStartTime = " + messageStartTime(); + out += "\n\t-messageEndTime = " + messageEndTime(); + out += "\n\t-channelMessageCounts = \n" + EuclidCoreIOTools.getCollectionString("\n", channelMessageCounts(), e -> e.toString(1)); + return out; + } + + @Override + default boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof Statistics other) + { + if (messageCount() != other.messageCount()) + return false; + if (schemaCount() != other.schemaCount()) + return false; + if (channelCount() != other.channelCount()) + return false; + if (attachmentCount() != other.attachmentCount()) + return false; + if (metadataCount() != other.metadataCount()) + return false; + if (chunkCount() != other.chunkCount()) + return false; + if (messageStartTime() != other.messageStartTime()) + return false; + if (messageEndTime() != other.messageEndTime()) + return false; + return Objects.equals(channelMessageCounts(), other.channelMessageCounts()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StatisticsDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StatisticsDataInputBacked.java new file mode 100644 index 000000000..75e06d1ba --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StatisticsDataInputBacked.java @@ -0,0 +1,122 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.WeakReference; +import java.util.List; + +public class StatisticsDataInputBacked implements Statistics +{ + private final MCAPDataInput dataInput; + private final long elementLength; + private final long messageCount; + private final int schemaCount; + private final long channelCount; + private final long attachmentCount; + private final long metadataCount; + private final long chunkCount; + private final long messageStartTime; + private final long messageEndTime; + private WeakReference> channelMessageCountsRef; + private final long channelMessageCountsOffset; + private final long channelMessageCountsLength; + + public StatisticsDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + this.elementLength = elementLength; + + dataInput.position(elementPosition); + messageCount = MCAP.checkPositiveLong(dataInput.getLong(), "messageCount"); + schemaCount = dataInput.getUnsignedShort(); + channelCount = dataInput.getUnsignedInt(); + attachmentCount = dataInput.getUnsignedInt(); + metadataCount = dataInput.getUnsignedInt(); + chunkCount = dataInput.getUnsignedInt(); + messageStartTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageStartTime"); + messageEndTime = MCAP.checkPositiveLong(dataInput.getLong(), "messageEndTime"); + channelMessageCountsLength = dataInput.getUnsignedInt(); + channelMessageCountsOffset = dataInput.position(); + } + + @Override + public long getElementLength() + { + return elementLength; + } + + @Override + public long messageCount() + { + return messageCount; + } + + @Override + public int schemaCount() + { + return schemaCount; + } + + @Override + public long channelCount() + { + return channelCount; + } + + @Override + public long attachmentCount() + { + return attachmentCount; + } + + @Override + public long metadataCount() + { + return metadataCount; + } + + @Override + public long chunkCount() + { + return chunkCount; + } + + @Override + public long messageStartTime() + { + return messageStartTime; + } + + @Override + public long messageEndTime() + { + return messageEndTime; + } + + @Override + public List channelMessageCounts() + { + List channelMessageCounts = channelMessageCountsRef == null ? null : channelMessageCountsRef.get(); + + if (channelMessageCounts == null) + { + channelMessageCounts = MCAP.parseList(dataInput, ChannelMessageCount::new, channelMessageCountsOffset, channelMessageCountsLength); + channelMessageCountsRef = new WeakReference<>(channelMessageCounts); + } + + return channelMessageCounts; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public boolean equals(Object object) + { + return object instanceof Statistics other && Statistics.super.equals(other); + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StringPair.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StringPair.java new file mode 100644 index 000000000..525b0dc1a --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/StringPair.java @@ -0,0 +1,88 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; + +import java.util.Objects; + +public class StringPair implements MCAPElement +{ + private final String key; + private final String value; + + public StringPair(MCAPDataInput dataInput, long elementPosition) + { + dataInput.position(elementPosition); + key = dataInput.getString(); + value = dataInput.getString(); + } + + public StringPair(String key, String value) + { + this.key = key; + this.value = value; + } + + @Override + public long getElementLength() + { + return key.length() + value.length() + 2 * Integer.BYTES; + } + + public String key() + { + return key; + } + + public String value() + { + return value; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putString(key); + dataOutput.putString(value); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addString(key); + crc32.addString(value); + return crc32; + } + + @Override + public String toString() + { + return (key + ": " + value).replace("\n", ""); + } + + @Override + public boolean equals(Object object) + { + return object instanceof StringPair other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof StringPair other) + { + if (!Objects.equals(key(), other.key())) + return false; + + return Objects.equals(value(), other.value()); + } + + return false; + } +} diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SummaryOffset.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SummaryOffset.java new file mode 100644 index 000000000..f10068b86 --- /dev/null +++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/SummaryOffset.java @@ -0,0 +1,160 @@ +package us.ihmc.scs2.session.mcap.specs.records; + +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; + +import java.lang.ref.Reference; +import java.lang.ref.SoftReference; +import java.lang.ref.WeakReference; +import java.util.List; + +/** + * A Summary Offset record contains the location of records within the summary section. + * Each Summary Offset record corresponds to a group of summary records with the same opcode. + * + * @see MCAP Summary Offset + */ +public class SummaryOffset implements MCAPElement +{ + int ELEMENT_LENGTH = Byte.BYTES + 2 * Long.BYTES; + + private final MCAPDataInput dataInput; + private final Opcode groupOpcode; + private final long groupOffset; + private final long groupLength; + + private Reference groupRef; + + public SummaryOffset(MCAPDataInput dataInput, long elementPosition, long elementLength) + { + this.dataInput = dataInput; + + dataInput.position(elementPosition); + groupOpcode = Opcode.byId(dataInput.getUnsignedByte()); + groupOffset = MCAP.checkPositiveLong(dataInput.getLong(), "offsetGroup"); + groupLength = MCAP.checkPositiveLong(dataInput.getLong(), "lengthGroup"); + MCAP.checkLength(elementLength, getElementLength()); + } + + public SummaryOffset(Opcode groupOpcode, long groupOffset, long groupLength) + { + this.dataInput = null; + this.groupOpcode = groupOpcode; + this.groupOffset = groupOffset; + this.groupLength = groupLength; + } + + public SummaryOffset(long groupOffset, List recordGroup) + { + if (recordGroup.isEmpty()) + throw new IllegalArgumentException("The record group cannot be empty"); + + groupOpcode = recordGroup.get(0).op(); + + if (recordGroup.stream().anyMatch(record -> record.op() != groupOpcode)) + throw new IllegalArgumentException("All records in the group must have the same opcode"); + + this.groupOffset = groupOffset; + groupLength = recordGroup.stream().mapToLong(Record::getElementLength).sum(); + + groupRef = new SoftReference<>(new Records(recordGroup)); + dataInput = null; + } + + @Override + public long getElementLength() + { + return ELEMENT_LENGTH; + } + + public Records group() + { + Records group = groupRef == null ? null : groupRef.get(); + + if (group == null) + { + if (dataInput == null) + throw new IllegalStateException("This record is not backed by a data input."); + + group = Records.load(dataInput, groupOffset, (int) groupLength); + groupRef = new WeakReference<>(group); + } + return group; + } + + public Opcode groupOpcode() + { + return groupOpcode; + } + + public long groupOffset() + { + return groupOffset; + } + + public long groupLength() + { + return groupLength; + } + + @Override + public void write(MCAPDataOutput dataOutput) + { + dataOutput.putUnsignedByte(groupOpcode().id()); + dataOutput.putLong(groupOffset()); + dataOutput.putLong(groupLength()); + } + + @Override + public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32) + { + if (crc32 == null) + crc32 = new MCAPCRC32Helper(); + crc32.addUnsignedByte(groupOpcode().id()); + crc32.addLong(groupOffset()); + crc32.addLong(groupLength()); + return crc32; + } + + @Override + public String toString() + { + return toString(0); + } + + @Override + public String toString(int indent) + { + String out = getClass().getSimpleName() + ": "; + out += "\n\t-groupOpcode = " + groupOpcode(); + out += "\n\t-groupOffset = " + groupOffset(); + out += "\n\t-groupLength = " + groupLength(); + return MCAPElement.indent(out, indent); + } + + @Override + public boolean equals(Object object) + { + return object instanceof SummaryOffset other && equals(other); + } + + @Override + public boolean equals(MCAPElement mcapElement) + { + if (mcapElement == this) + return true; + + if (mcapElement instanceof SummaryOffset other) + { + if (groupOpcode() != other.groupOpcode()) + return false; + if (groupOffset() != other.groupOffset()) + return false; + return groupLength() == other.groupLength(); + } + + return false; + } +} diff --git a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/MCAPLogCropperTest.java b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/MCAPLogCropperTest.java new file mode 100644 index 000000000..dfa5786b7 --- /dev/null +++ b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/MCAPLogCropperTest.java @@ -0,0 +1,406 @@ +package us.ihmc.scs2.session.mcap; + +import org.apache.commons.io.FilenameUtils; +import org.junit.jupiter.api.Test; +import us.ihmc.log.LogTools; +import us.ihmc.scs2.session.mcap.MCAPLogCropper.OutputFormat; +import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper; +import us.ihmc.scs2.session.mcap.input.MCAPDataInput; +import us.ihmc.scs2.session.mcap.output.MCAPByteBufferDataOutput; +import us.ihmc.scs2.session.mcap.output.MCAPDataOutput; +import us.ihmc.scs2.session.mcap.specs.MCAP; +import us.ihmc.scs2.session.mcap.specs.records.Chunk; +import us.ihmc.scs2.session.mcap.specs.records.ChunkIndex; +import us.ihmc.scs2.session.mcap.specs.records.Footer; +import us.ihmc.scs2.session.mcap.specs.records.Magic; +import us.ihmc.scs2.session.mcap.specs.records.Message; +import us.ihmc.scs2.session.mcap.specs.records.MessageIndex; +import us.ihmc.scs2.session.mcap.specs.records.MessageIndexEntry; +import us.ihmc.scs2.session.mcap.specs.records.MessageIndexOffset; +import us.ihmc.scs2.session.mcap.specs.records.Opcode; +import us.ihmc.scs2.session.mcap.specs.records.Record; +import us.ihmc.scs2.session.mcap.specs.records.Records; +import us.ihmc.scs2.session.mcap.specs.records.Schema; +import us.ihmc.scs2.session.mcap.specs.records.SummaryOffset; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class MCAPLogCropperTest +{ + @Test + public void testSimpleCloningMCAP() throws IOException + { + File demoMCAPFile = getDemoMCAPFile(); + MCAP originalMCAP = new MCAP(new FileInputStream(demoMCAPFile).getChannel()); + File clonedDemoMCAPFile = createTempMCAPFile("clonedDemo"); + MCAPDataOutput dataOutput = MCAPDataOutput.wrap(new FileOutputStream(clonedDemoMCAPFile).getChannel()); + dataOutput.putBytes(Magic.MAGIC_BYTES); // header magic + originalMCAP.records().forEach(record -> record.write(dataOutput)); + dataOutput.putBytes(Magic.MAGIC_BYTES); // footer magic + dataOutput.close(); + + // Let's compare the original and the cloned files by loading them into memory and comparing their content + MCAP clonedMCAP = new MCAP(new FileInputStream(clonedDemoMCAPFile).getChannel()); + + if (originalMCAP.records().size() != clonedMCAP.records().size()) + { + fail("Original and cloned MCAPs have different number of records"); + } + + for (int i = 0; i < originalMCAP.records().size(); i++) + { + assertEquals(originalMCAP.records().get(i), clonedMCAP.records().get(i), "Record " + i + " is different"); + } + + assertFileEquals(demoMCAPFile, clonedDemoMCAPFile); + } + + /** + * Compares the content of two files. This is a simple byte-to-byte comparison. + * + * @param expected The expected file. + * @param actual The actual file. + */ + private static void assertFileEquals(File expected, File actual) throws IOException + { + try (FileInputStream expectedFileInputStream = new FileInputStream(expected); FileInputStream actualFileInputStream = new FileInputStream(actual)) + { + byte[] expectedBuffer = new byte[1024]; + byte[] actualBuffer = new byte[1024]; + + int expectedRead = 0; + int actualRead = 0; + + while ((expectedRead = expectedFileInputStream.read(expectedBuffer)) != -1) + { + actualRead = actualFileInputStream.read(actualBuffer); + if (actualRead == -1) + { + fail("Actual file is shorter than the expected file"); + } + + if (expectedRead != actualRead) + { + fail("Files have different lengths"); + } + + for (int i = 0; i < expectedRead; i++) + { + if (expectedBuffer[i] != actualBuffer[i]) + { + fail("Files are different"); + } + } + } + } + } + + @Test + public void testNotActuallyCroppingMCAPDemoFile() throws IOException + { + File demoMCAPFile = getDemoMCAPFile(); + + MCAP originalMCAP = new MCAP(new FileInputStream(demoMCAPFile).getChannel()); + MCAPLogFileReader.exportChunkToFile(MCAPLogFileReader.SCS2_MCAP_DEBUG_HOME, ((Chunk) originalMCAP.records().get(1).body()), null); + MCAPLogCropper mcapLogCropper = new MCAPLogCropper(originalMCAP); + mcapLogCropper.setStartTimestamp(0); + mcapLogCropper.setEndTimestamp(Long.MAX_VALUE); + mcapLogCropper.setOutputFormat(OutputFormat.MCAP); + File croppedDemoMCAPFile = createTempMCAPFile("croppedDemo"); + mcapLogCropper.crop(new FileOutputStream(croppedDemoMCAPFile)); + + // Let's compare the original and the cropped files by loading them into memory and comparing their content + MCAP croppedMCAP = new MCAP(new FileInputStream(croppedDemoMCAPFile).getChannel()); + + // The cropped MCAP is slightly different: + // - The ZSTD compression gives slightly different sizes, which in turn offsets the records + // - In the demo.mcap file, there were some empty message index records, the cropping removes them. + assertChunksEqual(originalMCAP.records(), croppedMCAP.records()); + assertSchemasEqual(originalMCAP.records(), croppedMCAP.records()); + assertChannelsEqual(originalMCAP.records(), croppedMCAP.records()); + assertAttachmentsEqual(originalMCAP.records(), croppedMCAP.records()); + assertMetadatasEqual(originalMCAP.records(), croppedMCAP.records()); + + validateChunkIndices(originalMCAP); + validateMessageIndices(originalMCAP.records()); + validateFooter(originalMCAP); + + validateChunkIndices(croppedMCAP); + validateMessageIndices(croppedMCAP.records()); + validateFooter(croppedMCAP); + } + + private void assertChunksEqual(List expectedRecords, List actualRecords) + { + List expectedChunks = expectedRecords.stream().filter(r -> r.op() == Opcode.CHUNK).map(r -> (Chunk) r.body()).toList(); + List actualChunks = actualRecords.stream().filter(r -> r.op() == Opcode.CHUNK).map(r -> (Chunk) r.body()).toList(); + + if (expectedChunks.size() != actualChunks.size()) + { + fail("Expected " + expectedChunks.size() + " chunks, but found " + actualChunks.size()); + } + + for (int i = 0; i < expectedChunks.size(); i++) + { + Chunk expectedChunk = expectedChunks.get(i); + Chunk actualChunk = actualChunks.stream().filter(c -> c.messageStartTime() == expectedChunk.messageStartTime()).findFirst().orElse(null); + assertNotNull(actualChunk, "Could not find a chunk with message start time " + expectedChunk.messageStartTime()); + + assertEquals(expectedChunk.messageStartTime(), actualChunk.messageStartTime(), "Chunk " + i + " has different start time"); + assertEquals(expectedChunk.messageEndTime(), actualChunk.messageEndTime(), "Chunk " + i + " has different end time"); + assertEquals(expectedChunk.recordsUncompressedLength(), actualChunk.recordsUncompressedLength(), "Chunk " + i + " has different uncompressed length"); + assertEquals(expectedChunk.uncompressedCRC32(), actualChunk.uncompressedCRC32(), "Chunk " + i + " has different uncompressed CRC32"); + assertEquals(expectedChunk.compression(), actualChunk.compression(), "Chunk " + i + " has different compression"); + assertEquals(expectedChunk.records(), actualChunk.records(), "Chunk " + i + " has different records"); + } + } + + private void assertSchemasEqual(List expectedRecords, List actualRecords) + { + List expectedSchemas = expectedRecords.stream().filter(r -> r.op() == Opcode.SCHEMA).map(r -> (Schema) r.body()).toList(); + List actualSchemas = actualRecords.stream().filter(r -> r.op() == Opcode.SCHEMA).map(r -> (Schema) r.body()).toList(); + + if (expectedSchemas.size() != actualSchemas.size()) + { + fail("Expected " + expectedSchemas.size() + " schemas, but found " + actualSchemas.size()); + } + + for (int i = 0; i < expectedSchemas.size(); i++) + { + Schema expectedSchema = expectedSchemas.get(i); + Schema actualSchema = actualSchemas.stream().filter(s -> s.id() == expectedSchema.id()).findFirst().orElse(null); + assertNotNull(actualSchema, "Could not find a schema with ID " + expectedSchema.id()); + + assertEquals(expectedSchema, actualSchema, "Schema " + i + " is different"); + } + } + + private void assertChannelsEqual(List expectedRecords, List actualRecords) + { + List expectedChannels = expectedRecords.stream().filter(r -> r.op() == Opcode.CHANNEL).toList(); + List actualChannels = actualRecords.stream().filter(r -> r.op() == Opcode.CHANNEL).toList(); + + if (expectedChannels.size() != actualChannels.size()) + { + fail("Expected " + expectedChannels.size() + " channels, but found " + actualChannels.size()); + } + + for (int i = 0; i < expectedChannels.size(); i++) + { + assertEquals(expectedChannels.get(i), actualChannels.get(i), "Channel " + i + " is different"); + } + } + + private void assertAttachmentsEqual(List expectedRecords, List actualRecords) + { + List expectedAttachments = expectedRecords.stream().filter(r -> r.op() == Opcode.ATTACHMENT).toList(); + List actualAttachments = actualRecords.stream().filter(r -> r.op() == Opcode.ATTACHMENT).toList(); + + if (expectedAttachments.size() != actualAttachments.size()) + { + fail("Expected " + expectedAttachments.size() + " attachments, but found " + actualAttachments.size()); + } + + for (int i = 0; i < expectedAttachments.size(); i++) + { + assertEquals(expectedAttachments.get(i), actualAttachments.get(i), "Attachment " + i + " is different"); + } + } + + private void assertMetadatasEqual(List expectedRecords, List actualRecords) + { + List expectedMetadatas = expectedRecords.stream().filter(r -> r.op() == Opcode.METADATA).toList(); + List actualMetadatas = actualRecords.stream().filter(r -> r.op() == Opcode.METADATA).toList(); + + if (expectedMetadatas.size() != actualMetadatas.size()) + { + fail("Expected " + expectedMetadatas.size() + " metadatas, but found " + actualMetadatas.size()); + } + + for (int i = 0; i < expectedMetadatas.size(); i++) + { + assertEquals(expectedMetadatas.get(i), actualMetadatas.get(i), "Metadata " + i + " is different"); + } + } + + private void validateChunkIndices(MCAP mcap) + { + List chunkIndices = mcap.records().stream().filter(r -> r.op() == Opcode.CHUNK_INDEX).toList(); + if (chunkIndices.isEmpty()) + { + fail("No chunk index found"); + } + + for (int i = 0; i < chunkIndices.size(); i++) + { + ChunkIndex chunkIndex = chunkIndices.get(i).body(); + Record chunkRecord = chunkIndex.chunk(); + + if (chunkRecord == null) + fail("Chunk index " + i + " has no chunk"); + if (chunkRecord.op() != Opcode.CHUNK) + fail("Chunk index " + i + " has a record that is not a chunk"); + + Chunk chunk = chunkRecord.body(); + + assertEquals(chunkIndex.messageStartTime(), chunk.messageStartTime(), "Chunk index " + i + " has different start time"); + assertEquals(chunkIndex.messageEndTime(), chunk.messageEndTime(), "Chunk index " + i + " has different end time"); + assertEquals(chunkIndex.recordsUncompressedLength(), chunk.recordsUncompressedLength(), "Chunk index " + i + " has different uncompressed length"); + assertEquals(chunkIndex.compression(), chunk.compression(), "Chunk index " + i + " has different compression"); + assertEquals(chunkIndex.recordsCompressedLength(), chunk.recordsCompressedLength(), "Chunk index " + i + " has different compressed length"); + + for (MessageIndexOffset messageIndexOffset : chunkIndex.messageIndexOffsets()) + { + assertTrue(messageIndexOffset.offset() >= 0, "Chunk index " + i + " has a message index offset that is negative"); + + int channelId = messageIndexOffset.channelId(); + long offset = messageIndexOffset.offset(); + + Record messageIndexRecord = Record.load(mcap.getDataInput(), offset); + assertEquals(Opcode.MESSAGE_INDEX, messageIndexRecord.op(), "Chunk index " + i + " has a message index record that is not a message index"); + + MessageIndex messageIndex = messageIndexRecord.body(); + assertEquals(channelId, messageIndex.channelId(), "Chunk index " + i + " has a message index record with different channel ID"); + } + } + } + + private void validateMessageIndices(List records) + { + List messageIndices = records.stream().filter(r -> r.op() == Opcode.MESSAGE_INDEX).map(r -> (MessageIndex) r.body()).toList(); + if (messageIndices.isEmpty()) + { + fail("No message index found"); + } + List chunkIndices = records.stream().filter(r -> r.op() == Opcode.CHUNK_INDEX).map(r -> (ChunkIndex) r.body()).toList(); + + for (int i = 0; i < messageIndices.size(); i++) + { + MessageIndex messageIndex = messageIndices.get(i); + // Find the chunk containing the message from the timestamp: + + for (MessageIndexEntry messageIndexEntry : messageIndex.messageIndexEntries()) + { + long logTime = messageIndexEntry.logTime(); + ChunkIndex chunkIndex = chunkIndices.stream() + .filter(c -> c.messageStartTime() <= logTime && c.messageEndTime() >= logTime) + .findFirst() + .orElse(null); + assertNotNull(chunkIndex, "Could not find a chunk index for message index entry " + messageIndexEntry); + + Chunk chunk = chunkIndex.chunk().body(); + ByteBuffer recordsUncompressedBuffer = chunk.getRecordsUncompressedBuffer(); + MCAPDataInput dataInput = MCAPDataInput.wrap(recordsUncompressedBuffer); + Record messageRecord = Record.load(dataInput, messageIndexEntry.offset()); + assertEquals(Opcode.MESSAGE, messageRecord.op(), "Message index entry " + messageIndexEntry + " does not point to a message record"); + Message message = messageRecord.body(); + assertEquals(logTime, message.logTime(), "Message index entry " + messageIndexEntry + " points to a message with different log time"); + } + } + } + + private void validateFooter(MCAP mcap) + { + List footerRecords = mcap.records().stream().filter(r -> r.op() == Opcode.FOOTER).toList(); + assertEquals(1, footerRecords.size(), "Expected one footer, but found " + footerRecords.size()); + + Record footerRecord = footerRecords.get(0); + Footer footer = footerRecord.body(); + Records summarySection = footer.summarySection(); + Records summaryOffsetSection = footer.summaryOffsetSection(); + + // Computing the CRC in different ways to validate the footer + MCAPCRC32Helper crc32 = new MCAPCRC32Helper(); + summarySection.forEach(record -> record.updateCRC(crc32)); + summaryOffsetSection.forEach(record -> record.updateCRC(crc32)); + crc32.addUnsignedByte(footerRecord.op().id()); + crc32.addLong(footerRecord.bodyLength()); + crc32.addLong(footer.summarySectionOffset()); + crc32.addLong(footer.summaryOffsetSectionOffset()); + assertEquals(crc32.getValue(), footer.summaryCRC32(), "Footer has different summary CRC32"); + + MCAPByteBufferDataOutput dataOutput = new MCAPByteBufferDataOutput(); + summarySection.forEach(record -> record.write(dataOutput)); + summaryOffsetSection.forEach(record -> record.write(dataOutput)); + dataOutput.putUnsignedByte(footerRecord.op().id()); + dataOutput.putLong(footerRecord.bodyLength()); + dataOutput.putLong(footer.summarySectionOffset()); + dataOutput.putLong(footer.summaryOffsetSectionOffset()); + dataOutput.close(); + byte[] expectedSummaryCRC32Input = new byte[dataOutput.getBuffer().remaining()]; + dataOutput.getBuffer().get(expectedSummaryCRC32Input); + + assertArrayEquals(expectedSummaryCRC32Input, footer.summaryCRC32Input(), "Footer has different summary CRC32 input"); + assertEquals(footer.summarySectionLength() + footer.summaryOffsetSectionLength() + Record.RECORD_HEADER_LENGTH + 2 * Long.BYTES, + footer.summaryCRC32Input().length, + "Footer has different summary CRC32 input length"); + crc32.reset(); + crc32.addBytes(footer.summaryCRC32Input()); + assertEquals(crc32.getValue(), footer.summaryCRC32(), "Footer has different summary CRC32"); + + assertTrue(summarySection.stream() + .allMatch(r -> r.op() == Opcode.SCHEMA || r.op() == Opcode.CHANNEL || r.op() == Opcode.CHUNK_INDEX + || r.op() == Opcode.ATTACHMENT_INDEX || r.op() == Opcode.METADATA_INDEX || r.op() == Opcode.STATISTICS), + "Summary section contains a record that is not a schema, channel, chunk index, attachment index, metadata index, or statistics"); + + assertTrue(summaryOffsetSection.stream().allMatch(r -> r.op() == Opcode.SUMMARY_OFFSET), + "Summary offset section contains a record that is not a summary offset"); + + for (Record summaryOffsetRecord : summaryOffsetSection) + { + SummaryOffset summaryOffset = summaryOffsetRecord.body(); + Records group = summaryOffset.group(); + assertTrue(group.stream().allMatch(r -> r.op() == summaryOffset.groupOpcode()), "Group contains a record that is not of the expected type"); + } + } + + private static File getDemoMCAPFile() throws IOException + { + File demoMCAPFile; + // Check if the demo file is already downloaded, allowing for faster local testing + Path localFileVersion = Paths.get(System.getProperty("user.home"), "Downloads", "demo.mcap"); + if (Files.exists(localFileVersion)) + { + demoMCAPFile = localFileVersion.toFile(); + } + else + { + URL demoMCAPURL = new URL("https://github.com/foxglove/mcap/raw/main/testdata/mcap/demo.mcap"); + demoMCAPFile = downloadFile(demoMCAPURL); + } + return demoMCAPFile; + } + + private static File downloadFile(URL url) throws IOException + { + File file = createTempMCAPFile(FilenameUtils.getBaseName(url.getFile())); + LogTools.info("Downloading file from " + url); + try (InputStream in = url.openStream()) + { + Files.copy(in, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + LogTools.info("Downloaded file to " + file.getAbsolutePath()); + return file; + } + + public static File createTempMCAPFile(String name) throws IOException + { + File file = File.createTempFile(name, ".mcap"); + LogTools.info("Created temporary file: " + file.getAbsolutePath()); + file.deleteOnExit(); + return file; + } +} diff --git a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoderTest.java b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoderTest.java similarity index 88% rename from scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoderTest.java rename to scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoderTest.java index 4672df6b4..3485f1bf8 100644 --- a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/LZ4FrameDecoderTest.java +++ b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoderTest.java @@ -1,12 +1,9 @@ -package us.ihmc.scs2.session.mcap; - -import java.nio.ByteBuffer; -import java.util.List; -import java.util.Scanner; +package us.ihmc.scs2.session.mcap.encoding; +import gnu.trove.list.array.TByteArrayList; import org.junit.jupiter.api.Test; -import gnu.trove.list.array.TByteArrayList; +import java.util.Scanner; public class LZ4FrameDecoderTest { diff --git a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncodingTest.java b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncodingTest.java new file mode 100644 index 000000000..729909471 --- /dev/null +++ b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/encoding/LZ4FrameEncodingTest.java @@ -0,0 +1,35 @@ +package us.ihmc.scs2.session.mcap.encoding; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +public class LZ4FrameEncodingTest +{ + @Test + public void testEncodeDecode() + { + Random random = new Random(23423L); + + for (int i = 0; i < 100; i++) + { + byte[] originalData = new byte[random.nextInt(1000) + 10]; + random.nextBytes(originalData); + + // Gonna have to use a ByteArrayOutputStream to comply with the API + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + LZ4FrameEncoder lz4Compressor = new LZ4FrameEncoder(); + + byte[] compressedData = lz4Compressor.encode(originalData, null); + + LZ4FrameDecoder lz4Decoder = new LZ4FrameDecoder(); + byte[] decompressedData = lz4Decoder.decode(compressedData, null); + + assertArrayEquals(originalData, decompressedData); + } + } +} diff --git a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInputTest.java b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInputTest.java index 1fee2e3b7..ab5399f4f 100644 --- a/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInputTest.java +++ b/scs2-session-logger/src/test/java/us/ihmc/scs2/session/mcap/input/MCAPBufferedFileChannelInputTest.java @@ -2,7 +2,7 @@ import com.github.luben.zstd.ZstdCompressCtx; import org.junit.jupiter.api.Test; -import us.ihmc.scs2.session.mcap.input.MCAPDataInput.Compression; +import us.ihmc.scs2.session.mcap.specs.records.Compression; import java.io.IOException; import java.nio.ByteBuffer; diff --git a/scs2-session-logger/src/test/resources/us/ihmc/scs2/session/mcap/LZ4FrameDecoderCompressedData.txt b/scs2-session-logger/src/test/resources/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoderCompressedData.txt similarity index 100% rename from scs2-session-logger/src/test/resources/us/ihmc/scs2/session/mcap/LZ4FrameDecoderCompressedData.txt rename to scs2-session-logger/src/test/resources/us/ihmc/scs2/session/mcap/encoding/LZ4FrameDecoderCompressedData.txt diff --git a/scs2-session-visualizer-jfx/build.gradle.kts b/scs2-session-visualizer-jfx/build.gradle.kts index 9f13132da..ed8eac0ec 100644 --- a/scs2-session-visualizer-jfx/build.gradle.kts +++ b/scs2-session-visualizer-jfx/build.gradle.kts @@ -85,9 +85,27 @@ ihmc.jarWithLibFolder() tasks.getByPath("installDist").dependsOn("compositeJar") app.entrypoint(sessionVisualizerExecutableName, "us.ihmc.scs2.sessionVisualizer.jfx.SessionVisualizer", listOf("-Djdk.gtk.version=2", "-Dprism.vsync=false")) -tasks.create("buildDebianPackage") { +/** + * This task is used to compile the project and filter out any dependency not required for Linux. + */ +tasks.create("installDistLinux") { dependsOn("installDist") + doLast() { + fileTree("${project.projectDir}/build/install/scs2-session-visualizer-jfx/lib").matching { + include("*-win.jar") + include("*-android-*") + include("*-windows-*") + include("*-ios-*") + include("*-macosx-*") + include("*-osx-*") + }.forEach(File::delete) + } +} + +tasks.create("buildDebianPackage") { + dependsOn("installDistLinux") + doLast { val deploymentFolder = "${project.projectDir}/deployment" diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/controllers/editor/searchTextField/ReferenceFrameSearchField.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/controllers/editor/searchTextField/ReferenceFrameSearchField.java index 870b5e2cb..76ae91b7a 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/controllers/editor/searchTextField/ReferenceFrameSearchField.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/controllers/editor/searchTextField/ReferenceFrameSearchField.java @@ -94,6 +94,9 @@ protected String simplifyText(String text) ReferenceFrameWrapper frame = findReferenceFrame(text); + if (frame == null) + return null; + String uniqueName = frame.getUniqueShortName(); if (uniqueName != null && uniqueName.equals(text)) return null; diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/log/LogSessionManagerController.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/log/LogSessionManagerController.java index b537e968e..5d9394a5a 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/log/LogSessionManagerController.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/log/LogSessionManagerController.java @@ -81,7 +81,7 @@ public class LogSessionManagerController implements SessionControlsController @FXML private ToggleButton enableVariableFilterToggleButton; @FXML - private ComboBox outputFormatComboxBox; + private ComboBox outputFormatComboBox; @FXML private JFXTrimSlider logPositionSlider; @FXML @@ -222,7 +222,7 @@ public void initialize(SessionVisualizerToolkit toolkit) startTrimToCurrentButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); endTrimToCurrentButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); resetTrimsButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); - outputFormatComboxBox.disableProperty().bind(showTrimsButton.selectedProperty().not()); + outputFormatComboBox.disableProperty().bind(showTrimsButton.selectedProperty().not()); cropAndExportButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); cropProgressMonitorPane.getChildren().addListener((ListChangeListener) c -> { @@ -241,26 +241,26 @@ public void initialize(SessionVisualizerToolkit toolkit) activeSessionProperty.set(null); }); - outputFormatComboxBox.setItems(FXCollections.observableArrayList(OutputFormat.values())); - outputFormatComboxBox.getSelectionModel().select(OutputFormat.Default); + outputFormatComboBox.setItems(FXCollections.observableArrayList(OutputFormat.values())); + outputFormatComboBox.getSelectionModel().select(OutputFormat.Default); enableVariableFilterToggleButton.setDisable(true); // Only available if export format is MATLAB/CSV for now. - outputFormatComboxBox.getSelectionModel().selectedItemProperty().addListener((o, oldValue, newValue) -> - { - if (newValue == OutputFormat.Default || !showTrimsButton.isSelected()) - { - enableVariableFilterToggleButton.setSelected(false); - enableVariableFilterToggleButton.setDisable(true); - } - else - { - enableVariableFilterToggleButton.setDisable(false); - } - }); + outputFormatComboBox.getSelectionModel().selectedItemProperty().addListener((o, oldValue, newValue) -> + { + if (newValue == OutputFormat.Default || !showTrimsButton.isSelected()) + { + enableVariableFilterToggleButton.setSelected(false); + enableVariableFilterToggleButton.setDisable(true); + } + else + { + enableVariableFilterToggleButton.setDisable(false); + } + }); showTrimsButton.selectedProperty().addListener((o, oldValue, newValue) -> { - if (newValue && outputFormatComboxBox.getSelectionModel().getSelectedItem() != OutputFormat.Default) + if (newValue && outputFormatComboBox.getSelectionModel().getSelectedItem() != OutputFormat.Default) { enableVariableFilterToggleButton.setDisable(false); } @@ -438,7 +438,7 @@ public void cropAndExport() throws IOException File destination; - OutputFormat outputFormat = outputFormatComboxBox.getSelectionModel().getSelectedItem(); + OutputFormat outputFormat = outputFormatComboBox.getSelectionModel().getSelectedItem(); switch (outputFormat) { case MATLAB: diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGMultiVideoDataReader.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGMultiVideoDataReader.java index f8743826d..1cd052144 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGMultiVideoDataReader.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGMultiVideoDataReader.java @@ -52,7 +52,6 @@ public void readVideoFrameNow(long timestamp) readers.forEach(reader -> { reader.readFrameAtTimestamp(timestamp); - // LogTools.info("Reading frame at {} out of {}\n", timestamp / 1000000000.0, reader.getVideoLengthInSeconds()); }); } diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoDataReader.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoDataReader.java index 2cdca707c..2efc7d091 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoDataReader.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoDataReader.java @@ -12,9 +12,18 @@ public class FFMPEGVideoDataReader private final File videoFile; private final FFmpegFrameGrabber frameGrabber; private Frame currentFrame = null; + /** + * Stores the max video duration in nanoseconds + */ private final long maxVideoTimestamp; - - private final AtomicLong currentTimestamp = new AtomicLong(-1); + /** + * Stores the current nanosecond playback offset from start of video + */ + private final AtomicLong playbackOffset = new AtomicLong(0); + /** + * Stores the current nanosecond timestamp for video playback. Note that this stores the timestamp independent of the playback offset. + */ + private final AtomicLong currentTimestamp = new AtomicLong(0); public FFMPEGVideoDataReader(File file) { @@ -23,13 +32,14 @@ public FFMPEGVideoDataReader(File file) try { frameGrabber.start(); + currentFrame = frameGrabber.grabFrame(); } - catch (FFmpegFrameGrabber.Exception e) + catch (FrameGrabber.Exception e) { throw new RuntimeException(e); } - maxVideoTimestamp = frameGrabber.getLengthInTime(); + maxVideoTimestamp = frameGrabber.getLengthInTime() * 1000; } public Frame getCurrentFrame() @@ -37,17 +47,28 @@ public Frame getCurrentFrame() return currentFrame; } - public long readFrameAtTimestamp(long timestamp) + /** + * reads the frame in the video at timestamp + playbackOffset + * + * @param timestamp nanosecond timestamp to specify time from start of video + */ + public void readFrameAtTimestamp(long timestamp) { + if (currentTimestamp.get() != timestamp) + { + // clamp video timestamp to be between 0 and maxVideoTimestamp + currentTimestamp.set(Math.min(maxVideoTimestamp, Math.max(0, (timestamp)))); + } + + // clamp video timestamp + offset to be within bounds + long clampedTime = Math.min(maxVideoTimestamp, Math.max(0, currentTimestamp.get() + playbackOffset.get())); + // NOTE: timestamp passed in is in nanoseconds - if (timestamp != currentTimestamp.get()) + if (clampedTime != frameGrabber.getTimestamp()) { - // timestamp / 1000L converts a nanosecond timestamp to the video timestamp in time_base units - long clampedTime = Math.min(maxVideoTimestamp, Math.max(0, timestamp / 1000L)); - currentTimestamp.set(clampedTime); try { - frameGrabber.setVideoTimestamp(clampedTime); + frameGrabber.setVideoTimestamp(convertNanosecondToVideoTimestamp(clampedTime)); currentFrame = frameGrabber.grabFrame(); } catch (FrameGrabber.Exception e) @@ -55,12 +76,36 @@ public long readFrameAtTimestamp(long timestamp) throw new RuntimeException(e); } } - return currentTimestamp.get(); + } + + public void readCurrentFrame() + { + readFrameAtTimestamp(this.currentTimestamp.get()); } public long getVideoLengthInSeconds() { - return maxVideoTimestamp / 1000000; + return maxVideoTimestamp / 1000000000L; + } + + /** + * Sets the video playback offset referenced from start of the video. + * + * @param offset Nanosecond offset for starting video playback + */ + public void setPlaybackOffset(long offset) + { + this.playbackOffset.set(offset); + } + + public long getPlaybackOffset() + { + return this.playbackOffset.get(); + } + + public long getCurrentTimestamp() + { + return currentTimestamp.get(); } public void shutdown() @@ -75,4 +120,9 @@ public void shutdown() throw new RuntimeException(e); } } + + private long convertNanosecondToVideoTimestamp(long nanosecondTimestamp) + { + return nanosecondTimestamp / 1000L; + } } diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoViewer.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoViewer.java index c177465ec..e9a855fe9 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoViewer.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/FFMPEGVideoViewer.java @@ -9,17 +9,22 @@ import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.geometry.Rectangle2D; import javafx.scene.Node; import javafx.scene.Scene; +import javafx.scene.control.Spinner; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; -import javafx.scene.layout.*; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; import javafx.stage.Screen; import javafx.stage.Stage; import javafx.stage.Window; import javafx.util.Duration; +import javafx.util.StringConverter; import org.bytedeco.javacv.Frame; import org.bytedeco.javacv.JavaFXFrameConverter; import us.ihmc.log.LogTools; @@ -32,7 +37,8 @@ public class FFMPEGVideoViewer private static final double THUMBNAIL_HIGHLIGHT_SCALE = 1.05; private final ImageView thumbnail = new ImageView(); - private final StackPane thumbnailContainer = new StackPane(thumbnail); + private final Spinner offsetSpinner = new Spinner<>(-1.0e7, 1.0e7, 0.0, 0.001); + private final StackPane thumbnailContainer = new StackPane(thumbnail, offsetSpinner); private final ImageView videoView = new ImageView(); private final BooleanProperty updateVideoView = new SimpleBooleanProperty(this, "updateVideoView", false); @@ -47,23 +53,44 @@ public FFMPEGVideoViewer(Window owner, FFMPEGVideoDataReader reader, double defa { this.reader = reader; this.defaultThumbnailSize = defaultThumbnailSize; + + StackPane.setAlignment(offsetSpinner, Pos.BOTTOM_CENTER); + StackPane.setMargin(offsetSpinner, new Insets(0.0, 0.0, 5.0, 0.0)); + offsetSpinner.setEditable(true); + offsetSpinner.getValueFactory().setConverter(new StringConverter() + { + @Override + public String toString(Double object) + { + return object.toString() + "s"; + } + + @Override + public Double fromString(String string) + { + return Double.valueOf(string.replaceAll("s", "").trim()); + } + }); + thumbnail.setPreserveRatio(true); videoView.setPreserveRatio(true); thumbnail.setFitWidth(defaultThumbnailSize); thumbnail.setOnMouseEntered(e -> - { - Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(0.1), - new KeyValue(thumbnail.fitWidthProperty(), - THUMBNAIL_HIGHLIGHT_SCALE * defaultThumbnailSize, - Interpolator.EASE_BOTH))); - timeline.playFromStart(); - }); + { + Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(0.1), + new KeyValue(thumbnail.fitWidthProperty(), + THUMBNAIL_HIGHLIGHT_SCALE * defaultThumbnailSize, + Interpolator.EASE_BOTH))); + timeline.playFromStart(); + }); thumbnail.setOnMouseExited(e -> - { - Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(0.1), - new KeyValue(thumbnail.fitWidthProperty(), defaultThumbnailSize, Interpolator.EASE_BOTH))); - timeline.playFromStart(); - }); + { + Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(0.1), + new KeyValue(thumbnail.fitWidthProperty(), + defaultThumbnailSize, + Interpolator.EASE_BOTH))); + timeline.playFromStart(); + }); thumbnail.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { @@ -89,7 +116,7 @@ public FFMPEGVideoViewer(Window owner, FFMPEGVideoDataReader reader, double defa videoWindowProperty.set(stage); stage.getIcons().add(SessionVisualizerIOTools.LOG_SESSION_IMAGE); - stage.setTitle(reader.toString()); + stage.setTitle(this.reader.toString()); owner.setOnHiding(e2 -> stage.close()); Scene scene = new Scene(anchorPane); stage.setScene(scene); @@ -110,6 +137,12 @@ public FFMPEGVideoViewer(Window owner, FFMPEGVideoDataReader reader, double defa stage.toFront(); stage.show(); }); + + offsetSpinner.valueProperty().addListener((observable, oldValue, newValue) -> + { + this.reader.setPlaybackOffset(Math.round(newValue * 1000000000.0)); + this.reader.readCurrentFrame(); + }); } private static Pane createImageViewPane(ImageView imageView) @@ -147,7 +180,8 @@ public void update() try { currentImage = this.frameConverter.convert(currentFrame); - } catch (RuntimeException e) + } + catch (RuntimeException e) { LogTools.error("Frame has {} image channels", currentFrame.imageChannels); } @@ -166,7 +200,7 @@ public void update() if (imageViewRootPane.get() != null) { - imageViewRootPane.get().setPadding(new Insets(16,16,16,16)); + imageViewRootPane.get().setPadding(new Insets(16, 16, 16, 16)); } } } diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/MCAPLogSessionManagerController.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/MCAPLogSessionManagerController.java index ea80636b5..065ce2ee9 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/MCAPLogSessionManagerController.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/session/mcap/MCAPLogSessionManagerController.java @@ -7,17 +7,23 @@ import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; +import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TextField; import javafx.scene.control.TextFormatter; import javafx.scene.control.TitledPane; +import javafx.scene.control.ToggleButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.FlowPane; +import javafx.scene.layout.Pane; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import javafx.stage.Stage; @@ -29,6 +35,8 @@ import us.ihmc.messager.TopicListener; import us.ihmc.messager.javafx.JavaFXMessager; import us.ihmc.scs2.session.SessionRobotDefinitionListChange; +import us.ihmc.scs2.session.mcap.MCAPLogCropper; +import us.ihmc.scs2.session.mcap.MCAPLogCropper.OutputFormat; import us.ihmc.scs2.session.mcap.MCAPLogFileReader; import us.ihmc.scs2.session.mcap.MCAPLogSession; import us.ihmc.scs2.sessionVisualizer.jfx.SessionVisualizerIOTools; @@ -36,12 +44,15 @@ import us.ihmc.scs2.sessionVisualizer.jfx.managers.BackgroundExecutorManager; import us.ihmc.scs2.sessionVisualizer.jfx.managers.SessionVisualizerToolkit; import us.ihmc.scs2.sessionVisualizer.jfx.session.SessionControlsController; +import us.ihmc.scs2.sessionVisualizer.jfx.session.log.LogSessionManagerController; import us.ihmc.scs2.sessionVisualizer.jfx.session.log.LogSessionManagerController.TimeStringBinding; import us.ihmc.scs2.sessionVisualizer.jfx.tools.JavaFXMissingTools; import us.ihmc.scs2.sessionVisualizer.jfx.tools.PositiveIntegerValueFilter; import us.ihmc.scs2.sharedMemory.interfaces.YoBufferPropertiesReadOnly; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -60,12 +71,24 @@ public class MCAPLogSessionManagerController implements SessionControlsControlle @FXML private AnchorPane mainPane; @FXML + private ProgressIndicator loadingSpinner; + @FXML private Button openSessionButton, endSessionButton; @FXML private Label sessionNameLabel, dateLabel, logPathLabel; @FXML + private Pane cropControlsContainer; + @FXML + private ToggleButton showTrimsButton; + @FXML + private Button startTrimToCurrentButton, endTrimToCurrentButton, resetTrimsButton, cropAndExportButton; + @FXML + private ComboBox outputFormatComboBox; // FIXME + @FXML private JFXTrimSlider logPositionSlider; @FXML + private Pane cropProgressMonitorPane; // FIXME + @FXML private JFXTextField currentModelFilePathTextField; @FXML private TextField desiredLogDTTextField; @@ -86,18 +109,6 @@ public class MCAPLogSessionManagerController implements SessionControlsControlle private File defaultRobotModelFile = null; - private static String getDate(String filename) - { // FIXME it seems that the timestamps in the MCAP file are epoch unix timestamp. Should use that. - String year = filename.substring(0, 4); - String month = filename.substring(4, 6); - String day = filename.substring(6, 8); - String hour = filename.substring(9, 11); - String minute = filename.substring(11, 13); - String second = filename.substring(13, 15); - - return year + "-" + month + "-" + day + " " + hour + ":" + minute + ":" + second; - } - @Override public void initialize(SessionVisualizerToolkit toolkit) { @@ -244,6 +255,24 @@ else if (newValue.getInitialRobotModelFile() == null) thumbnailsTitledPane.expandedProperty().addListener((o, oldValue, newValue) -> JavaFXMissingTools.runLater(getClass(), stage::sizeToScene)); consoleOutputTitledPane.expandedProperty().addListener((o, oldValue, newValue) -> JavaFXMissingTools.runLater(getClass(), stage::sizeToScene)); + logPositionSlider.showTrimProperty().bind(showTrimsButton.selectedProperty()); + logPositionSlider.showTrimProperty().addListener((o, oldValue, newValue) -> + { + if (newValue) + resetTrims(); + }); + startTrimToCurrentButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); + endTrimToCurrentButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); + resetTrimsButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); + outputFormatComboBox.disableProperty().bind(showTrimsButton.selectedProperty().not()); + cropAndExportButton.disableProperty().bind(showTrimsButton.selectedProperty().not()); + cropProgressMonitorPane.getChildren().addListener((ListChangeListener) c -> + { + c.getList().forEach(node -> JavaFXMissingTools.setAnchorConstraints(node, 0.0)); + stage.sizeToScene(); + }); + + loadingSpinner.visibleProperty().addListener((o, oldValue, newValue) -> openSessionButton.setDisable(newValue)); openSessionButton.setOnAction(e -> openLogFile()); endSessionButton.setOnAction(e -> @@ -258,6 +287,7 @@ else if (newValue.getInitialRobotModelFile() == null) { openSessionButton.setDisable(m); endSessionButton.setDisable(m); + cropControlsContainer.setDisable(m); logPositionSlider.setDisable(m); }); @@ -279,13 +309,14 @@ private void initializeControls(MCAPLogSession session) MCAPLogFileReader mcapLogFileReader = session.getMCAPLogFileReader(); sessionNameLabel.setText(session.getSessionName()); - dateLabel.setText(getDate(logFile.getName())); + dateLabel.setText(LogSessionManagerController.parseTimestamp(logFile.getName())); logPathLabel.setText(logFile.getAbsolutePath()); endSessionButton.setDisable(false); logPositionSlider.setDisable(false); logPositionSlider.setValue(0.0); logPositionSlider.setMin(0.0); logPositionSlider.setMax(mcapLogFileReader.getNumberOfEntries() - 1); + cropControlsContainer.setDisable(false); FFMPEGMultiVideoDataReader multiReader = new FFMPEGMultiVideoDataReader(logFile.getParentFile(), backgroundExecutorManager); multiReader.readVideoFrameNow(mcapLogFileReader.getCurrentRelativeTimestamp()); mcapLogFileReader.getCurrentTimestamp().addListener(v -> multiReader.readVideoFrameInBackground(mcapLogFileReader.getCurrentRelativeTimestamp())); @@ -309,6 +340,8 @@ private void clearControls() logPathLabel.setText("N/D"); endSessionButton.setDisable(true); logPositionSlider.setDisable(true); + showTrimsButton.setSelected(false); + cropControlsContainer.setDisable(true); multiVideoViewerObjectProperty.set(null); consoleOutputPaneController.stopSession(); } @@ -324,6 +357,7 @@ public void openLogFile() return; unloadSession(); + setIsLoading(true); backgroundExecutorManager.executeInBackground(() -> { @@ -340,6 +374,70 @@ public void openLogFile() catch (Exception ex) { ex.printStackTrace(); + setIsLoading(false); + } + }); + } + + public void setIsLoading(boolean isLoading) + { + loadingSpinner.setVisible(isLoading); + } + + @FXML + public void resetTrims() + { + logPositionSlider.setTrimStartValue(0.0); + logPositionSlider.setTrimEndValue(logPositionSlider.getMax()); + } + + @FXML + public void snapStartTrimToCurrent() + { + logPositionSlider.setTrimStartValue(logPositionSlider.getValue()); + } + + @FXML + public void snapEndTrimToCurrent() + { + logPositionSlider.setTrimEndValue(logPositionSlider.getValue()); + } + + @FXML + public void cropAndExport() throws IOException + { + MCAPLogFileReader mcapLogFileReader = activeSessionProperty.get().getMCAPLogFileReader(); + MCAPLogCropper cropper = new MCAPLogCropper(mcapLogFileReader.getMCAP()); + long startTimestamp = mcapLogFileReader.getTimestampAtIndex((int) logPositionSlider.getTrimStartValue()); + long endTimestamp = mcapLogFileReader.getTimestampAtIndex((int) logPositionSlider.getTrimEndValue()); + cropper.setStartTimestamp(startTimestamp); + cropper.setEndTimestamp(endTimestamp); + cropper.setOutputFormat(outputFormatComboBox.getValue()); + FileChooser fileChooser = new FileChooser(); + fileChooser.setInitialDirectory(SessionVisualizerIOTools.getDefaultFilePath(LOG_FILE_KEY)); + fileChooser.getExtensionFilters().add(new ExtensionFilter("MCAP Log file", "*.mcap")); + fileChooser.setTitle("Choose MCAP log file"); + File result = fileChooser.showSaveDialog(stage); + if (result == null) + return; + + backgroundExecutorManager.executeInBackground(() -> + { + setIsLoading(true); + messager.submitMessage(topics.getDisableUserControls(), true); + + try (FileOutputStream os = new FileOutputStream(result)) + { + cropper.crop(os); + } + catch (IOException e) + { + e.printStackTrace(); + } + finally + { + messager.submitMessage(topics.getDisableUserControls(), false); + setIsLoading(false); } }); } @@ -347,7 +445,7 @@ public void openLogFile() @Override public void notifySessionLoaded() { - // TODO Auto-generated method stub + setIsLoading(false); } @Override diff --git a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/yoGraphic/YoGraphicTools.java b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/yoGraphic/YoGraphicTools.java index a8801d9cb..232e03ff8 100644 --- a/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/yoGraphic/YoGraphicTools.java +++ b/scs2-session-visualizer-jfx/src/main/java/us/ihmc/scs2/sessionVisualizer/jfx/yoGraphic/YoGraphicTools.java @@ -20,18 +20,7 @@ import us.ihmc.mecano.multiBodySystem.interfaces.RigidBodyReadOnly; import us.ihmc.mecano.tools.MultiBodySystemTools; import us.ihmc.scs2.definition.collision.CollisionShapeDefinition; -import us.ihmc.scs2.definition.geometry.Box3DDefinition; -import us.ihmc.scs2.definition.geometry.Capsule3DDefinition; -import us.ihmc.scs2.definition.geometry.Cone3DDefinition; -import us.ihmc.scs2.definition.geometry.ConvexPolytope3DDefinition; -import us.ihmc.scs2.definition.geometry.Cylinder3DDefinition; -import us.ihmc.scs2.definition.geometry.Ellipsoid3DDefinition; -import us.ihmc.scs2.definition.geometry.ExtrudedPolygon2DDefinition; -import us.ihmc.scs2.definition.geometry.GeometryDefinition; -import us.ihmc.scs2.definition.geometry.Point3DDefinition; -import us.ihmc.scs2.definition.geometry.Ramp3DDefinition; -import us.ihmc.scs2.definition.geometry.STPBox3DDefinition; -import us.ihmc.scs2.definition.geometry.Sphere3DDefinition; +import us.ihmc.scs2.definition.geometry.*; import us.ihmc.scs2.definition.robot.RigidBodyDefinition; import us.ihmc.scs2.definition.robot.RobotDefinition; import us.ihmc.scs2.definition.terrain.TerrainObjectDefinition; @@ -50,17 +39,9 @@ import us.ihmc.scs2.sessionVisualizer.jfx.yoComposite.QuaternionProperty; import us.ihmc.scs2.sessionVisualizer.jfx.yoComposite.Tuple2DProperty; import us.ihmc.scs2.sessionVisualizer.jfx.yoComposite.Tuple3DProperty; -import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.BaseColorFX; -import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.SimpleColorFX; -import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.YoColorRGBADoubleFX; -import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.YoColorRGBAIntFX; -import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.YoColorRGBASingleFX; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Objects; +import us.ihmc.scs2.sessionVisualizer.jfx.yoGraphic.color.*; + +import java.util.*; import java.util.stream.Collectors; public class YoGraphicTools @@ -894,7 +875,7 @@ public static YoGroupFX convertRobotCollisionShapeDefinitions(RigidBodyReadOnly RigidBodyReadOnly rigidBody = MultiBodySystemTools.findRigidBody(rootBody, rigidBodyDefinition.getName()); ReferenceFrame robotFrame = rigidBody.isRootBody() ? rigidBody.getBodyFixedFrame() : rigidBody.getParentJoint().getFrameAfterJoint(); - ReferenceFrameWrapper referenceFrame = referenceFrameManager.getReferenceFrameFromFullname(robotFrame.getName()); + ReferenceFrameWrapper referenceFrame = referenceFrameManager.getReferenceFrameFromFullname(robotFrame.getNameId()); YoGroupFX collisionGroup = convertRigidBodyCollisionShapeDefinitions(referenceFrame, rigidBodyDefinition, color); if (collisionGroup != null) diff --git a/scs2-session-visualizer-jfx/src/main/resources/fxml/session/LogSessionManagerPane.fxml b/scs2-session-visualizer-jfx/src/main/resources/fxml/session/LogSessionManagerPane.fxml index 2ff44484d..c41e32d67 100644 --- a/scs2-session-visualizer-jfx/src/main/resources/fxml/session/LogSessionManagerPane.fxml +++ b/scs2-session-visualizer-jfx/src/main/resources/fxml/session/LogSessionManagerPane.fxml @@ -8,79 +8,81 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scs2-session-visualizer-jfx/src/main/resources/fxml/session/MCAPLogSessionManagerPane.fxml b/scs2-session-visualizer-jfx/src/main/resources/fxml/session/MCAPLogSessionManagerPane.fxml index 30c959662..b3f9775ae 100644 --- a/scs2-session-visualizer-jfx/src/main/resources/fxml/session/MCAPLogSessionManagerPane.fxml +++ b/scs2-session-visualizer-jfx/src/main/resources/fxml/session/MCAPLogSessionManagerPane.fxml @@ -1,97 +1,125 @@ + + - - - - - - - - - + + - + - - - + + + - - + + - - - - - + + + + + - - + + + + + + + + + + + + + + - - + - + - + - + - + - + - +