Skip to content

Commit 12e689e

Browse files
[format][python] Support video frame reads with persisted keyframe indexes
1 parent 1d368b4 commit 12e689e

19 files changed

Lines changed: 1162 additions & 123 deletions

File tree

‎docs/docs/concepts/spec/fileformat.md‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,15 +414,22 @@ the ordinary BLOB entry header, length trailer, or per-entry CRC:
414414
+----------------------------+
415415
| ... |
416416
+----------------------------+
417+
| Keyframe Index 1 | Optional compressed keyframe indices and PTS values
418+
+----------------------------+
419+
| Keyframe Index 2 |
420+
+----------------------------+
417421
| Physical Length Index | Delta-Varint video lengths
418422
+----------------------------+
423+
| Keyframe-Index Length Index | Delta-Varint keyframe-index lengths
424+
+----------------------------+
419425
| Run Length Index | Delta-Varint logical row counts
420426
+----------------------------+
421427
| Run Reference Index | Delta-Varint physical video ordinals
422428
+----------------------------+
423429
| Run First-Frame Index | Delta-Varint frame ordinals
424430
+----------------------------+
425431
| Physical Index Length | 4 bytes (Little Endian)
432+
| Keyframe Length-Index Size | 4 bytes (Little Endian)
426433
| Run-Length Index Length | 4 bytes (Little Endian)
427434
| Run-Reference Index Length | 4 bytes (Little Endian)
428435
| First-Frame Index Length | 4 bytes (Little Endian)
@@ -435,20 +442,32 @@ The run arrays have equal element counts. A non-negative run reference is an ord
435442
physical length index. For logical row `r` in a run beginning at logical row `s`, the returned
436443
`VideoFrameDescriptor` identifies the referenced raw video range and frame ordinal
437444
`run_first_frame + (r - s)`. `-1` is a NULL run and `-2` is a data-evolution placeholder run.
438-
Non-negative runs have fixed frame stride one in version 1; a discontinuity starts another run.
445+
Non-negative runs have fixed frame stride one; a discontinuity starts another run. Version 2 may
446+
store one sparse keyframe index per physical video. Version 1 remains readable and omits the
447+
keyframe-index blocks and their length index.
448+
449+
A keyframe-index block has a 13-byte little-endian header: version (`1`, uint8), magic
450+
(`0x564944454F4B4649`, uint64), and entry count (uint32).
451+
It is followed by zlib-compressed pairs of displayed frame ordinal and PTS (two int64 values).
452+
The index always describes the first video stream. PTS uses that stream's time base,
453+
read from the original container rather than stored again.
454+
Only keyframes have entries. The reader identifies an indexed decode start and counts decoded
455+
frames in presentation order to reach the target; it does not infer ordinals from average FPS.
439456

440457
The serialized `VideoFrameDescriptor` stored in an Arrow/data-file cell has its own versioned
441458
wire layout. All numeric values are little endian:
442459

443460
| Field | Size | Description |
444461
| --- | ---: | --- |
445-
| Version | 1 byte | Descriptor version, currently `1` |
462+
| Version | 1 byte | Descriptor version, currently `2` |
446463
| Magic | 8 bytes | `0x564944454F46524D` (`VIDEOFRM`) |
447464
| URI length | 4 bytes | UTF-8 URI byte length |
448465
| URI | variable | URI of the containing `.video` file |
449466
| Offset | 8 bytes | Start of the complete encoded-video payload |
450467
| Length | 8 bytes | Encoded-video payload length |
451468
| Frame index | 8 bytes | Zero-based presentation-order frame ordinal |
469+
| Keyframe-index offset | 8 bytes | Version 2 only: index offset in the `.video` file |
470+
| Keyframe-index length | 8 bytes | Version 2 only: index length |
452471

453472
Descriptor bytes are independently versioned from the `.video` container. Java and Python share
454473
canonical descriptor and container fixtures to keep both implementations byte-compatible.

‎docs/docs/multimodal-table/video.mdx‎

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,32 @@ to write complete videos and their logical frame rows.
8383

8484
Each logical value is a `VideoFrameDescriptor`: its URI range identifies one complete encoded
8585
video and its frame ordinal selects a frame inside that video. On write, a `.video` data region
86-
concatenates raw video payloads without ordinary BLOB entry wrappers. Four embedded delta-varint
87-
indexes record physical video lengths, logical run lengths, run-to-video references, and the first
88-
frame ordinal of each run. Consecutive frames therefore need one run entry rather than one index
89-
entry per row. NULL and data-evolution placeholders use negative run references.
86+
concatenates raw video payloads without ordinary BLOB entry wrappers. Compact indexes map rows to
87+
videos and frame ordinals. Version 2 additionally stores compressed keyframe indices and PTS values
88+
once per physical video, not per-frame mappings. Consecutive frames share one run entry; NULL and data-evolution
89+
placeholders use negative run references.
90+
91+
<pre>
92+
.video (version 2, in file order)
93+
├─ Complete encoded videos A, B, ...
94+
├─ <strong>NEW: Keyframe index blocks A, B, ...</strong>
95+
│ └─ Compressed keyframe indices and PTS values
96+
├─ Video payload lengths
97+
├─ <strong>NEW: Keyframe index block lengths</strong>
98+
├─ Consecutive-row counts
99+
├─ Video references for those rows
100+
├─ Starting frame ordinals for those rows
101+
└─ Footer
102+
├─ Byte lengths of the five index arrays above
103+
│ └─ <strong>NEW: Length of the keyframe-block-length index</strong>
104+
└─ Magic and format version
105+
</pre>
106+
107+
Each video has its own index block; blocks follow payload order. Their lengths locate individual
108+
blocks without reading the other videos' indexes. The footer locates the index arrays, not individual
109+
frames. Row indexes select a video and target frame; keyframe indexes select where decoding starts.
110+
Index generation and indexed reads use the first video stream. Its stream index and PTS time base
111+
are obtained from the original container, not stored in the index.
90112

91113
## Reuse, Rolling, and Compaction
92114

@@ -102,9 +124,10 @@ together. The resulting file group remains row-aligned and a single large episod
102124
configured target.
103125

104126
When BLOB compaction is enabled, it byte-copies the complete encoded-video ranges into a new self-contained `.video` pack
105-
and rebuilds the embedded indexes; it does not decode or re-encode frames. The aligned normal data
127+
and rebuilds the embedded indexes; it does not decode or re-encode frames. Persisted keyframe indexes
128+
are copied into the new pack. The aligned normal data
106129
file contains only application columns such as `episode_id`, state, and action. Paimon stores the
107-
frame mapping in the `.video` descriptor/index path.
130+
keyframe index in the `.video` descriptor/index path.
108131
`blob-compaction.enabled` defaults to `false`; ordinary normal-file compaction can
109132
leave the video packs unchanged. See [Data Evolution Maintenance](./data-evolution-maintenance#ordinary-compaction).
110133

@@ -115,8 +138,7 @@ leave the video packs unchanged. See [Data Evolution Maintenance](./data-evoluti
115138
`.blob`.
116139
- Non-null writes must be exact descriptor-backed `BlobRef` values containing a
117140
`VideoFrameDescriptor`. Inline bytes and ordinary `BlobDescriptor` values are rejected.
118-
- Version 1 addresses frames by zero-based presentation-order ordinal with stride one. It does not
119-
store PTS values or parse codec/container metadata.
141+
- Version 1 remains readable but has no persisted keyframe index.
120142
- A `BlobConsumer` callback is not supported for the video field.
121143

122144
## Read Frames
@@ -130,6 +152,27 @@ not contain decoded image pixels. Read behavior differs by API:
130152
| PyPaimon reads of `.video` fields | Return serialized `VideoFrameDescriptor` values even when `blob-as-descriptor=false`. |
131153
| Java internal-row API | `row.getBlob(position)` returns a descriptor-backed `BlobRef`; call `toDescriptor()` to access the frame descriptor. `toData()` reads the complete video payload. |
132154

155+
PyPaimon decodes supported videos once during ingestion to record accurate keyframe ordinals.
156+
PyAV reuses this sparse index: seek to an indexed start, verify its timestamp, and count decoded
157+
frames to the target. Missing indexes use the existing scan path. Explicitly selecting TorchCodec
158+
keeps its exact mode, including its initialization scan; it does not consume the sparse index.
159+
Indexed TorchCodec reads also require PyAV to resolve the first video stream from the container header.
160+
161+
```text
162+
Table row
163+
→ Video A, target frame 1003
164+
→ A's keyframe index: preceding keyframe 1000, PTS t
165+
→ Seek and verify the decoded start against the index
166+
→ Decode in presentation order: 1000 → 1001 → 1002 → 1003
167+
→ Return frame 1003
168+
```
169+
170+
Frame ordinals come from ingestion-time decoding, not timestamp × average frame rate.
171+
If seeking skips the selected start, PyAV retries an earlier indexed start; an unidentifiable
172+
start fails rather than returning a guessed frame. This avoids rebuilding the frame index on
173+
first access, but still decodes the target's dependencies. Index storage grows with keyframe count,
174+
not total frame count. Unsupported index generation falls back to unindexed storage.
175+
133176
After ingesting data, query descriptors without loading a complete video for every row:
134177

135178
<Tabs groupId="video-read">

‎docs/docs/pypaimon/lerobot.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,12 @@ loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)
223223
```
224224

225225
Without `tag_name`, the latest snapshots are used. Frame lookups use the BTree
226-
on `index`; payloads remain lazy. Video decoding prefers TorchCodec, falls back
227-
to PyAV, and reuses a bounded decoder cache. Set `video_backend` to force
228-
either decoder.
226+
on `index`; payloads remain lazy. With a persisted keyframe index, automatic video
227+
decoding prefers PyAV to seek and count frames without an initialization scan.
228+
Without an index, it prefers TorchCodec and falls back to PyAV. Both reuse a bounded
229+
decoder cache. If PyAV is unavailable, or `video_backend="torchcodec"` is explicit,
230+
TorchCodec retains exact mode and its
231+
initialization scan; `video_backend="pyav"` uses the sparse index when available.
229232

230233
Subclass `PaimonDatasetReader` for a custom logical frame layout:
231234

‎paimon-common/src/main/java/org/apache/paimon/data/VideoFrameDescriptor.java‎

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,58 @@ public class VideoFrameDescriptor extends BlobDescriptor {
3535

3636
private static final long serialVersionUID = 1L;
3737
private static final long MAGIC = 0x564944454F46524DL; // "VIDEOFRM"
38-
private static final byte CURRENT_VERSION = 1;
39-
private static final int FIXED_LENGTH =
38+
private static final byte CURRENT_VERSION = 2;
39+
private static final int V1_FIXED_LENGTH =
4040
Byte.BYTES + Long.BYTES + Integer.BYTES + 3 * Long.BYTES;
41+
private static final int V2_FIXED_LENGTH = V1_FIXED_LENGTH + 2 * Long.BYTES;
4142

4243
private final long frameIndex;
44+
private final long keyframeIndexOffset;
45+
private final long keyframeIndexLength;
46+
private final byte version;
4347

4448
public VideoFrameDescriptor(String uri, long offset, long length, long frameIndex) {
49+
this(uri, offset, length, frameIndex, -1, 0, (byte) 1);
50+
}
51+
52+
public VideoFrameDescriptor(
53+
String uri,
54+
long offset,
55+
long length,
56+
long frameIndex,
57+
long keyframeIndexOffset,
58+
long keyframeIndexLength) {
59+
this(
60+
uri,
61+
offset,
62+
length,
63+
frameIndex,
64+
keyframeIndexOffset,
65+
keyframeIndexLength,
66+
keyframeIndexLength == 0 ? (byte) 1 : CURRENT_VERSION);
67+
}
68+
69+
private VideoFrameDescriptor(
70+
String uri,
71+
long offset,
72+
long length,
73+
long frameIndex,
74+
long keyframeIndexOffset,
75+
long keyframeIndexLength,
76+
byte version) {
4577
super(uri, offset, length);
4678
checkArgument(
4779
frameIndex >= 0, "Video frame index must be non-negative, but was %s.", frameIndex);
80+
checkArgument(
81+
keyframeIndexLength >= 0, "Video keyframe index length must be non-negative.");
82+
checkArgument(
83+
(keyframeIndexLength == 0 && keyframeIndexOffset == -1)
84+
|| (keyframeIndexLength > 0 && keyframeIndexOffset >= 0),
85+
"Invalid video keyframe index range.");
4886
this.frameIndex = frameIndex;
87+
this.keyframeIndexOffset = keyframeIndexOffset;
88+
this.keyframeIndexLength = keyframeIndexLength;
89+
this.version = version;
4990
}
5091

5192
public long frameIndex() {
@@ -57,6 +98,22 @@ public BlobDescriptor payloadDescriptor() {
5798
return new BlobDescriptor(uri(), offset(), length());
5899
}
59100

101+
public @Nullable BlobDescriptor keyframeIndexDescriptor() {
102+
return keyframeIndexLength == 0
103+
? null
104+
: new BlobDescriptor(uri(), keyframeIndexOffset, keyframeIndexLength);
105+
}
106+
107+
/** Returns the persisted keyframe index carried by an exact frame reference. */
108+
public static @Nullable Blob keyframeIndexBlob(Blob blob) {
109+
VideoFrameDescriptor frame = fromBlob(blob);
110+
BlobDescriptor mapping = frame == null ? null : frame.keyframeIndexDescriptor();
111+
if (mapping == null) {
112+
return null;
113+
}
114+
return Blob.fromDescriptor(((BlobRef) blob).uriReader(), mapping);
115+
}
116+
60117
/** Returns the video frame carried by an exact lazy blob reference, or {@code null}. */
61118
public static @Nullable VideoFrameDescriptor fromBlob(@Nullable Blob blob) {
62119
if (blob == null || blob.getClass() != BlobRef.class) {
@@ -77,30 +134,35 @@ public BlobDescriptor payloadDescriptor() {
77134
@Override
78135
public byte[] serialize() {
79136
byte[] uriBytes = uri().getBytes(StandardCharsets.UTF_8);
137+
int fixedLength = version == 1 ? V1_FIXED_LENGTH : V2_FIXED_LENGTH;
80138
ByteBuffer buffer =
81-
ByteBuffer.allocate(FIXED_LENGTH + uriBytes.length).order(ByteOrder.LITTLE_ENDIAN);
82-
buffer.put(CURRENT_VERSION);
139+
ByteBuffer.allocate(fixedLength + uriBytes.length).order(ByteOrder.LITTLE_ENDIAN);
140+
buffer.put(version);
83141
buffer.putLong(MAGIC);
84142
buffer.putInt(uriBytes.length);
85143
buffer.put(uriBytes);
86144
buffer.putLong(offset());
87145
buffer.putLong(length());
88146
buffer.putLong(frameIndex);
147+
if (version >= 2) {
148+
buffer.putLong(keyframeIndexOffset);
149+
buffer.putLong(keyframeIndexLength);
150+
}
89151
return buffer.array();
90152
}
91153

92154
public static VideoFrameDescriptor deserialize(byte[] bytes) {
93-
if (bytes == null || bytes.length < FIXED_LENGTH) {
155+
if (bytes == null || bytes.length < V1_FIXED_LENGTH) {
94156
throw invalidPayload("too short");
95157
}
96158

97159
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
98160
byte version = buffer.get();
99-
if (version != CURRENT_VERSION) {
161+
if (version < 1 || version > CURRENT_VERSION) {
100162
throw new UnsupportedOperationException(
101-
"Expecting VideoFrameDescriptor version to be "
163+
"Expecting VideoFrameDescriptor version in [1, "
102164
+ CURRENT_VERSION
103-
+ ", but found "
165+
+ "], but found "
104166
+ version
105167
+ ".");
106168
}
@@ -117,7 +179,8 @@ public static VideoFrameDescriptor deserialize(byte[] bytes) {
117179
if (uriLength > buffer.remaining()) {
118180
throw invalidPayload("URI length exceeds data size");
119181
}
120-
if (buffer.remaining() - uriLength < 3 * Long.BYTES) {
182+
int trailingLength = version == 1 ? 3 * Long.BYTES : 5 * Long.BYTES;
183+
if (buffer.remaining() - uriLength < trailingLength) {
121184
throw invalidPayload("missing offset/length/frame index");
122185
}
123186

@@ -127,21 +190,29 @@ public static VideoFrameDescriptor deserialize(byte[] bytes) {
127190
long offset = buffer.getLong();
128191
long length = buffer.getLong();
129192
long frameIndex = buffer.getLong();
193+
long keyframeIndexOffset = -1;
194+
long keyframeIndexLength = 0;
195+
if (version >= 2) {
196+
keyframeIndexOffset = buffer.getLong();
197+
keyframeIndexLength = buffer.getLong();
198+
}
130199
if (buffer.hasRemaining()) {
131200
throw invalidPayload("trailing bytes");
132201
}
133202
if (frameIndex < 0) {
134203
throw invalidPayload("negative frame index: " + frameIndex);
135204
}
136-
return new VideoFrameDescriptor(uri, offset, length, frameIndex);
205+
return new VideoFrameDescriptor(
206+
uri, offset, length, frameIndex, keyframeIndexOffset, keyframeIndexLength, version);
137207
}
138208

139209
public static boolean isVideoFrameDescriptor(byte[] bytes) {
140210
if (bytes == null || bytes.length < Byte.BYTES + Long.BYTES) {
141211
return false;
142212
}
143213
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
144-
return buffer.get() == CURRENT_VERSION && buffer.getLong() == MAGIC;
214+
byte version = buffer.get();
215+
return version >= 1 && version <= CURRENT_VERSION && buffer.getLong() == MAGIC;
145216
}
146217

147218
@Override
@@ -154,12 +225,13 @@ public boolean equals(Object o) {
154225
}
155226
VideoFrameDescriptor that = (VideoFrameDescriptor) o;
156227
return frameIndex == that.frameIndex
157-
&& payloadDescriptor().equals(that.payloadDescriptor());
228+
&& payloadDescriptor().equals(that.payloadDescriptor())
229+
&& Objects.equals(keyframeIndexDescriptor(), that.keyframeIndexDescriptor());
158230
}
159231

160232
@Override
161233
public int hashCode() {
162-
return Objects.hash(payloadDescriptor(), frameIndex);
234+
return Objects.hash(payloadDescriptor(), frameIndex, keyframeIndexDescriptor());
163235
}
164236

165237
@Override

‎paimon-common/src/test/java/org/apache/paimon/data/VideoFrameDescriptorTest.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ public void testRoundTripAndPayloadIdentity() {
4949
new VideoFrameDescriptor("oss://bucket/source.mp4", 17, 103, 43);
5050
assertThat(next).isNotEqualTo(frame);
5151
assertThat(next.payloadDescriptor()).isEqualTo(frame.payloadDescriptor());
52+
53+
VideoFrameDescriptor indexed =
54+
new VideoFrameDescriptor("oss://bucket/source.mp4", 17, 103, 42, 120, 8);
55+
assertThat(VideoFrameDescriptor.deserialize(indexed.serialize())).isEqualTo(indexed);
56+
assertThat(indexed.keyframeIndexDescriptor())
57+
.isEqualTo(new BlobDescriptor("oss://bucket/source.mp4", 120, 8));
5258
}
5359

5460
@Test

0 commit comments

Comments
 (0)