Video Frames
Store complete encoded videos and read their logical frame rows for training. First create a multimodal conn as shown in Multimodal Tables. Provide your own video files and frame metadata for the examples below.
Video Frame Storage
For a frame table, set video-frame-field to one or more comma-separated
scalar BLOB columns. Paimon stores frame ordinals in VideoFrameDescriptor
and compact .video indexes, so the normal data file does not need a
frame_index or frame_timestamp column:
import pyarrow as pa
import pypaimon.multimodal as pm
frames = conn.create_table(
"video_frames",
schema=pa.schema([
pa.field("episode_id", pa.int64()),
pa.field("state", pa.list_(pa.float32())),
pa.field("action", pa.list_(pa.float32())),
pa.field("video", pa.large_binary()),
]),
options={
"video-frame-field": "video",
},
)
add_video accepts a complete encoded-video source plus the ordinary columns
for its logical frame rows. Blob.from_local is descriptor-backed and streams
the source; the MP4 is not first loaded into Python memory:
video = pm.Blob.from_local("/data/episode-42.mp4")
frames.add_video(video, frame_rows, first_frame=0)
Use add_videos to keep one writer and one commit open across several source
videos. This is the path that lets one .video object pack multiple complete
MP4 payloads and reduces object/manifest count:
frames.add_videos([
(pm.Blob.from_local("/data/episode-42.mp4"), episode_42_rows),
(pm.Blob.from_local("/data/episode-43.mp4"), episode_43_rows),
])
Each item may also be (video, frame_rows, first_frame). Frame ordinals are
generated consecutively from first_frame. If application semantics require
PTS, wall-clock time, or a non-unit sampling map, retain that value in a normal
column; .video version 1 addresses presentation-order frames with stride one.
Remote sources can use an explicit descriptor. Its URI must be readable with
the table's configured FileIO credentials:
video = pm.BlobDescriptor(
"oss://source-bucket/episode-43.mp4",
offset=0,
length=video_size,
)
frames.add_video(video, episode_43_rows)
The writer deduplicates exact payload descriptor identity inside each .video
file. Its video grouping policy coordinates normal, BLOB, and vector rolling
at payload boundaries. A file may exceed its target before the next boundary.
The normal .blob format is unchanged.
Update frame rows and replace a video
Frame rows use the ordinary table update API for non-video columns. This writes
only the changed data-evolution columns and keeps the existing .video object
and frame descriptors unchanged:
frames.update(
where="episode_id = 42",
values={"state": corrected_states},
)
Writing a complete encoded video uses the specialized API. replace_video
selects the matching logical rows in _ROW_ID order, assigns consecutive frame
ordinals, and writes a video-column delta without rewriting the normal data
file:
frames.replace_video(
where="episode_id = 42",
video=pm.Blob.from_local("/data/episode-42-corrected.mp4"),
first_frame=0,
)
The generic update() API rejects assignments to the configured
video-frame-field; use replace_video() so Paimon can preserve the complete
video payload and its embedded row-to-frame mapping.
Read with PyTorch DataLoader
Install the PyTorch extra, then convert the multimodal scan directly. Unlike a
regular BLOB materialization, ScanQuery.to_torch always returns serialized
descriptors for BLOB columns. DataLoader workers receive Paimon splits and open
the selected video ranges themselves:
pip install 'pypaimon[torch]'
from torch.utils.data import DataLoader
dataset = (
frames.scan()
.select(["episode_id", "state", "action", "video"])
.to_torch(streaming=True)
)
loader = DataLoader(
dataset,
batch_size=32,
num_workers=4,
shuffle=False,
)
The video value is serialized VideoFrameDescriptor bytes, not repeated MP4
bytes. VideoFrameCollator owns a bounded, process-local LRU cache keyed by the
physical video range, so different frame descriptors reuse one decoder session
in each DataLoader worker. decoder_factory(stream) opens any codec library;
decode_fn(decoder, frame_index, row) receives the embedded frame ordinal.
import av
import torch
import pypaimon.multimodal as pm
from torch.utils.data import DataLoader
class SequentialPyAvDecoder:
def __init__(self, stream):
self.container = av.open(stream)
self._reset()
def _reset(self):
self.frames = iter(self.container.decode(video=0))
self.next_index = 0
def frame(self, index):
if index < self.next_index:
self.container.seek(0)
self._reset()
while self.next_index <= index:
frame = next(self.frames)
self.next_index += 1
array = frame.to_ndarray(format="rgb24")
return torch.from_numpy(array).permute(2, 0, 1)
def close(self):
self.container.close()
def decode_frame(decoder, frame_index, row):
return decoder.frame(frame_index)
collator = pm.VideoFrameCollator(
frames,
video_column="video",
decoder_factory=SequentialPyAvDecoder,
decode_fn=decode_frame,
output_column="frame",
max_open_videos=4,
)
loader = DataLoader(
dataset,
batch_size=32,
num_workers=4,
shuffle=False,
collate_fn=collator,
persistent_workers=True,
)
for batch in loader:
# batch["frame"] is [N, C, H, W].
train(batch["frame"], batch["episode_id"])
The example decoder additionally requires pip install av; PyAV is not a
PyPaimon dependency.
Define the decoder class and function at module scope when DataLoader uses the
spawn multiprocessing method. For sequential video decoding, keep
shuffle=False; row-buffer shuffle can turn monotonic frame access into seeks
and require more open decoder sessions. Paimon's worker sharding is split-level,
so it does not duplicate a split across workers.