eclise-clips 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: eclise-clips
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: numpy>=1.24
6
+ Requires-Dist: h5py>=3.10
7
+ Requires-Dist: imageio[ffmpeg]>=2.36
8
+ Requires-Dist: av>=13.1
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest~=8.4.0; extra == "test"
@@ -0,0 +1,113 @@
1
+ # eclise.clips
2
+
3
+ A shared on-disk contract for video clips carrying per-frame state labels.
4
+
5
+ This package sits between anything that **produces** clips and anything that
6
+ **consumes** them:
7
+
8
+ ```
9
+ data/phyworld ──┐ ┌── video_world_models
10
+ ├── eclise.clips shards ──┤
11
+ HF downloads ──┘ └── (future consumers)
12
+ ```
13
+
14
+ Producers and consumers live in separate conda envs with incompatible pins and
15
+ never import each other. They agree on this package instead, which is what lets
16
+ generated data be consumed the same way real video will be.
17
+
18
+ ## Reading
19
+
20
+ ```python
21
+ from eclise.clips import ClipDataset
22
+
23
+ data = ClipDataset("data/phyworld/data/collision_30K")
24
+ item = data[0]
25
+ item["pixels"] # [3, T, H, W] float32 in [-1, 1]
26
+ item["positions"] # [T, n_objects, 2] world units, y up
27
+ item["velocity"] # [2] (vx, vy) px/frame, y down (image convention)
28
+ item["index"] # global clip index, survives the default collate
29
+ data.source_fps # 1 / frame_dt
30
+ ```
31
+
32
+ `ClipDataset` is a plain torch `Dataset`; hand it to a `DataLoader` as usual.
33
+ `iter_clip_batches` yields `(clips [B,3,T,H,W], velocities [B,2])` for feeding a
34
+ frozen latent encoder.
35
+
36
+ Upstream phyworld's published HDF5 carries no metadata, so its family has to be
37
+ named (or inferred from the filename):
38
+
39
+ ```python
40
+ from eclise.clips.readers import phyworld_hdf5
41
+
42
+ shards = phyworld_hdf5.open_shard_set("collision_30K.hdf5", family="collision")
43
+ data = ClipDataset(shards, velocity_object=0)
44
+ ```
45
+
46
+ ## Writing
47
+
48
+ ```python
49
+ from eclise.clips import ShardMeta, ShardWriter, position_spec, params_spec
50
+
51
+ meta = ShardMeta(
52
+ family="uniform_motion",
53
+ num_clips=0, # overwritten with the realized count on close
54
+ clip_len=32,
55
+ image_size=(256, 256),
56
+ frame_dt=0.1, # seconds of simulated time between stored frames
57
+ world_scale=10.0,
58
+ labels={"positions": position_spec(), "params": params_spec(("r1", "v1"))},
59
+ )
60
+
61
+ with ShardWriter("shard_000.hdf5", meta) as w:
62
+ for frames, positions, params in scenes:
63
+ w.append(frames, positions=positions, params=params)
64
+ ```
65
+
66
+ A shard is one HDF5 file; a dataset is a directory of them. `ShardSet` presents a
67
+ directory as one flat ordered clip sequence, so nothing needs a merge pass.
68
+
69
+ ## Design notes
70
+
71
+ **Storage.** Clips are mp4 bytestreams in a variable-length `uint8` dataset, one
72
+ row per clip. A dense `uint8` array of 30k 32-frame 256px clips would be ~190GB;
73
+ mp4 brings that to a few GB. Inherited from upstream deliberately, so its
74
+ published files stay readable. Encoding is lossy — consumers see H.264
75
+ artifacts, which is why state-recovery checks run after a decode round-trip.
76
+
77
+ **`frame_dt`, not `fps`.** An mp4 header's frame rate is a playback preference.
78
+ For simulation output the number that matters is the simulated time between
79
+ stored frames, and upstream never records it: it writes clips with
80
+ `iio.imwrite(..., extension='.mp4')` and no `fps` at all, while the real spacing
81
+ is `timestep * stride = 0.01 * 10 = 0.1s`, set in code the reader never sees. So
82
+ `frame_dt` is required and authoritative, and `source_fps` is derived from it.
83
+ There is no `fps` field, because two rates can disagree.
84
+
85
+ **Labels are self-describing.** `[N, T, 2, 2]` could be `(clip, frame, ball, xy)`
86
+ in world units, pixels, or normalized coordinates — differing by factors that
87
+ silently rescale any derived velocity. Every label array carries a `LabelSpec`
88
+ recording axis names, units, and whether its y axis points up.
89
+
90
+ **The y-axis flip.** phyworld's stored positions have y increasing *upward* while
91
+ image rows increase downward: its generators render from normalized positions and
92
+ then reverse the row axis. `positions_to_pixels` and `velocity_from_state` read
93
+ `y_axis_up` off the spec and apply the flip, so no caller has to remember.
94
+
95
+ ## Layout
96
+
97
+ | Module | Role |
98
+ |---|---|
99
+ | `schema.py` | `ShardMeta`, `LabelSpec`, required metadata, validation |
100
+ | `codec.py` | in-memory mp4 encode/decode |
101
+ | `shards.py` | `ShardWriter`, `ShardReader`, `ShardSet` |
102
+ | `labels.py` | world→pixel conversion, velocity, collision timing |
103
+ | `dataset.py` | `ClipDataset` (the only module needing torch) |
104
+ | `readers/` | adapters for layouts we don't control |
105
+
106
+ `schema`, `codec`, `shards`, `readers`, and `labels` are pure numpy, so a
107
+ generator env need not install torch.
108
+
109
+ ## Tests
110
+
111
+ ```bash
112
+ cd data/clips && python -m pytest
113
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eclise-clips"
7
+ version = "0.1.0"
8
+ # 3.9, not 3.10: a producer sim may be pinned to an old Python by its simulator.
9
+ # data/phyworld's PHYRE-backed family runs in a 3.9 env because PHYRE's only
10
+ # prebuilt wheel is cp39 (see that project's README), and it must be able to
11
+ # write shards through this package's ShardWriter rather than a second
12
+ # implementation of the contract.
13
+ requires-python = ">=3.9"
14
+ dynamic = ["dependencies"]
15
+
16
+ [project.optional-dependencies]
17
+ test = ["pytest~=8.4.0"]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+ include = ["eclise.clips*"]
22
+
23
+ [tool.setuptools.dynamic]
24
+ dependencies = {file = ["requirements.txt"]}
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
28
+ pythonpath = ["src"]
@@ -0,0 +1,17 @@
1
+ # Runtime dependencies for eclise.clips.
2
+ #
3
+ # The on-disk clip contract (HDF5 shards holding mp4 bytestreams) plus the torch
4
+ # Dataset that decodes them. Deliberately light: a *producer* (a sim) and a
5
+ # *consumer* (a training project) install this into otherwise-incompatible envs,
6
+ # so anything heavier than these belongs in the consuming project.
7
+
8
+ numpy>=1.24
9
+ h5py>=3.10
10
+ # mp4 decode/encode of in-memory bytestreams. imageio.v3 does the byte-level
11
+ # imread/imwrite; av (PyAV) is the ffmpeg binding it dispatches to.
12
+ imageio[ffmpeg]>=2.36
13
+ av>=13.1
14
+
15
+ # torch is an *optional* dependency: schema/shards/readers are pure numpy, and
16
+ # only `eclise.clips.dataset` needs torch. Consumers all have their own pinned
17
+ # torch, so pinning one here would fight them. See dataset.py's import guard.
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,97 @@
1
+ """A shared on-disk contract for video clips with per-frame state labels.
2
+
3
+ Sits between anything that *produces* clips (a simulator, a download of a
4
+ published dataset, eventually a real-video ingest) and anything that *consumes*
5
+ them (a training project). Producers and consumers live in separate conda envs
6
+ and never import each other; they agree on this package instead.
7
+
8
+ from eclise.clips import ClipDataset
9
+
10
+ data = ClipDataset("data/phyworld/data/collision_30K")
11
+ item = data[0]
12
+ item["pixels"] # [3, T, H, W] float32 in [-1, 1]
13
+ item["positions"] # [T, n_objects, 2] world units, y up
14
+ item["velocity"] # [2] px/frame, y down (image convention)
15
+ data.source_fps # 1 / frame_dt -- authoritative, not the mp4 header
16
+
17
+ Upstream phyworld's published HDF5 carries no metadata, so it needs its family
18
+ named:
19
+
20
+ from eclise.clips.readers import phyworld_hdf5
21
+
22
+ shards = phyworld_hdf5.open_shard_set("collision_30K.hdf5", family="collision")
23
+ data = ClipDataset(shards, velocity_object=0)
24
+
25
+ Writing is the mirror image: declare a
26
+ :class:`~eclise.clips.schema.ShardMeta`, then append clips.
27
+
28
+ from eclise.clips import ShardMeta, ShardWriter, position_spec
29
+
30
+ meta = ShardMeta(
31
+ family="uniform_motion", num_clips=0, clip_len=32,
32
+ image_size=(256, 256), frame_dt=0.1, world_scale=10.0,
33
+ labels={"positions": position_spec()},
34
+ )
35
+ with ShardWriter("shard_000.hdf5", meta) as w:
36
+ w.append(frames, positions=positions)
37
+
38
+ Torch is needed only for :class:`~eclise.clips.dataset.ClipDataset`; the schema,
39
+ shard, and reader layers are pure numpy.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from .codec import decode_mp4, encode_mp4
45
+ from .labels import (
46
+ collision_frame,
47
+ positions_to_pixels,
48
+ velocities_per_frame,
49
+ velocity_from_state,
50
+ )
51
+ from .schema import (
52
+ LABEL_GROUP,
53
+ META_ATTR,
54
+ SCHEMA_VERSION,
55
+ VIDEO_DATASET,
56
+ LabelSpec,
57
+ SchemaError,
58
+ ShardMeta,
59
+ object_attrs_spec,
60
+ params_spec,
61
+ position_spec,
62
+ velocity_field_spec,
63
+ )
64
+ from .shards import ShardReader, ShardSet, ShardWriter, write_shard
65
+
66
+ __all__ = [
67
+ "LABEL_GROUP",
68
+ "META_ATTR",
69
+ "SCHEMA_VERSION",
70
+ "VIDEO_DATASET",
71
+ "LabelSpec",
72
+ "SchemaError",
73
+ "ShardMeta",
74
+ "ShardReader",
75
+ "ShardSet",
76
+ "ShardWriter",
77
+ "collision_frame",
78
+ "decode_mp4",
79
+ "encode_mp4",
80
+ "object_attrs_spec",
81
+ "params_spec",
82
+ "position_spec",
83
+ "positions_to_pixels",
84
+ "velocities_per_frame",
85
+ "velocity_field_spec",
86
+ "velocity_from_state",
87
+ "write_shard",
88
+ ]
89
+
90
+
91
+ def __getattr__(name: str):
92
+ """Expose :class:`~eclise.clips.dataset.ClipDataset` without importing torch eagerly."""
93
+ if name in ("ClipDataset", "iter_clip_batches", "PIXEL_RANGES"):
94
+ from . import dataset
95
+
96
+ return getattr(dataset, name)
97
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,85 @@
1
+ """In-memory mp4 encode/decode for clip bytestreams.
2
+
3
+ Upstream phyworld carries a copy of this logic in every generator script (four
4
+ copies of ``convert_frames_to_mp4_bytestream_wo_disk``, plus disk-roundtripping
5
+ variants that were left behind after the in-memory version replaced them). This
6
+ is the one home for it.
7
+
8
+ Encoding is lossy. H.264 on synthetic content -- flat backgrounds with a few
9
+ hard-edged coloured circles -- is close to visually lossless at sane bitrates,
10
+ but "close" is not "exact", and every consumer of a decoded clip is looking at
11
+ compression artifacts. That is a property of the storage format we inherit, not
12
+ something this module can fix; it is why
13
+ ``data/phyworld/scripts/verify_dataset.py`` measures state recovery *after* a
14
+ decode round-trip rather than trusting the pre-encode frames.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from io import BytesIO
20
+
21
+ import numpy as np
22
+
23
+ #: Encoder quality knob. imageio/ffmpeg's default CRF is 23, visibly soft on
24
+ #: hard synthetic edges; 18 is near-transparent for this content at a modest
25
+ #: size increase. Applies only to clips this package writes.
26
+ DEFAULT_CRF = 18
27
+
28
+
29
+ def encode_mp4(frames: np.ndarray, *, crf: int = DEFAULT_CRF, fps: float = 24.0) -> np.ndarray:
30
+ """Encode ``[T, H, W, 3]`` uint8 frames to an mp4 bytestream as ``[n] uint8``.
31
+
32
+ ``fps`` lands in the container header only. It is *not* the physics frame
33
+ rate -- that is :attr:`eclise.clips.schema.ShardMeta.frame_dt`, recorded
34
+ separately. A value is passed anyway so the header is at least a valid,
35
+ deliberate number rather than an encoder default (upstream passes nothing,
36
+ which is how its stored clips ended up with a meaningless rate).
37
+
38
+ H.264 requires even frame dimensions; odd ones are rejected here rather than
39
+ silently rescaled, since a resized clip would invalidate every pixel-unit
40
+ label in the shard.
41
+ """
42
+ import imageio.v3 as iio
43
+
44
+ arr = np.asarray(frames)
45
+ if arr.ndim != 4 or arr.shape[-1] != 3:
46
+ raise ValueError(f"expected frames [T, H, W, 3], got {arr.shape}")
47
+ if arr.dtype != np.uint8:
48
+ raise ValueError(f"expected uint8 frames, got {arr.dtype}")
49
+ h, w = arr.shape[1:3]
50
+ if h % 2 or w % 2:
51
+ raise ValueError(
52
+ f"H.264 needs even frame dimensions; got {h}x{w}. Render at an even "
53
+ "size rather than resizing here, which would invalidate pixel labels."
54
+ )
55
+ with BytesIO() as buffer:
56
+ iio.imwrite(
57
+ buffer,
58
+ arr,
59
+ extension=".mp4",
60
+ fps=fps,
61
+ codec="libx264",
62
+ # The pixel format goes through imageio's own keyword, not
63
+ # output_params: the ffmpeg plugin always emits a -pix_fmt of its own,
64
+ # so passing a second one makes ffmpeg warn once per encoded clip --
65
+ # millions of stderr lines on a full generation run, for a flag that
66
+ # was already set to the same value.
67
+ pixelformat="yuv420p",
68
+ output_params=["-crf", str(crf)],
69
+ )
70
+ payload = buffer.getvalue()
71
+ return np.frombuffer(payload, dtype=np.uint8)
72
+
73
+
74
+ def decode_mp4(stream: np.ndarray | bytes) -> np.ndarray:
75
+ """Decode an mp4 bytestream to ``[T, H, W, 3]`` uint8 frames."""
76
+ import imageio.v3 as iio
77
+
78
+ payload = stream.tobytes() if isinstance(stream, np.ndarray) else stream
79
+ frames = iio.imread(payload, index=None, extension=".mp4")
80
+ arr = np.asarray(frames)
81
+ if arr.ndim == 3: # a single-frame clip decodes without the leading axis
82
+ arr = arr[None]
83
+ if arr.shape[-1] == 4: # some decoders hand back RGBA
84
+ arr = arr[..., :3]
85
+ return arr
@@ -0,0 +1,235 @@
1
+ """Torch ``Dataset`` over clip shards.
2
+
3
+ This is the consumer-facing end of the contract. A training project points
4
+ :class:`ClipDataset` at a directory of shards -- generated by a sim, downloaded
5
+ from upstream, or (later) holding real video -- and gets pixel clips plus labels,
6
+ without knowing which.
7
+
8
+ ``torch`` is imported lazily: :mod:`eclise.clips.schema`, :mod:`.shards`, and
9
+ :mod:`.readers` are pure numpy so a generator-side env need not install torch at
10
+ all, while consumers bring their own pinned version.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ from .labels import velocity_from_state
22
+ from .schema import SchemaError, ShardMeta
23
+ from .shards import ShardSet
24
+
25
+ #: How decoded uint8 pixels are mapped to floats.
26
+ #:
27
+ #: - ``"tanh"``: ``[-1, 1]``. The default, because it is what latent video
28
+ #: encoders (Wan VAE and friends) consume, and what
29
+ #: ``video_world_models``' own procedural stimuli already produce.
30
+ #: - ``"unit"``: ``[0, 1]``.
31
+ #: - ``"uint8"``: untouched ``0..255``, dtype preserved.
32
+ PIXEL_RANGES = ("tanh", "unit", "uint8")
33
+
34
+
35
+ def _to_pixels(frames: np.ndarray, pixel_range: str) -> Any:
36
+ import torch
37
+
38
+ if pixel_range not in PIXEL_RANGES:
39
+ raise ValueError(
40
+ f"pixel_range must be one of {PIXEL_RANGES}; got {pixel_range!r}"
41
+ )
42
+ arr = np.ascontiguousarray(frames)
43
+ tensor = torch.from_numpy(arr) # [T, H, W, 3] uint8
44
+ tensor = tensor.permute(3, 0, 1, 2) # -> [3, T, H, W]
45
+ if pixel_range == "uint8":
46
+ return tensor.contiguous()
47
+ tensor = tensor.to(torch.float32) / 255.0
48
+ if pixel_range == "tanh":
49
+ tensor = tensor * 2.0 - 1.0
50
+ return tensor.contiguous()
51
+
52
+
53
+ class ClipDataset:
54
+ """Clips from one or more shards, as ``[3, T, H, W]`` tensors plus labels.
55
+
56
+ Each item is a dict:
57
+
58
+ - ``"pixels"``: ``[3, T, H, W]``, float32 in the configured range (or uint8).
59
+ - ``"index"``: global clip index, so a batched metric can be bucketed by any
60
+ per-clip property after the fact. Labels that are strings or ragged cannot
61
+ survive the default collate; an index always can.
62
+ - ``"velocity"``: ``[2]`` ``(vx, vy)`` px/frame, present when the shard has
63
+ positions and the family has one object (see ``velocity_object``).
64
+ - one entry per stored label, e.g. ``"positions"`` ``[T, n_obj, 2]``,
65
+ ``"params"`` ``[K]``.
66
+
67
+ ``meta`` (family, ``frame_dt``, ``world_scale``, ...) is a dataset-level
68
+ attribute, not per item -- it is identical for every clip, and putting it in
69
+ the item would multiply it by the batch size through the collate.
70
+
71
+ Args:
72
+ source: a shard directory, a single shard file, a list of files, or an
73
+ already-built :class:`~eclise.clips.shards.ShardSet`.
74
+ pixel_range: see :data:`PIXEL_RANGES`.
75
+ labels: which stored labels to include; ``None`` includes all.
76
+ velocity: emit a ``"velocity"`` entry derived from positions.
77
+ velocity_object: which object's velocity to use for multi-object
78
+ families. Required there, since a mean over a head-on collision is
79
+ ~0 (see :func:`eclise.clips.labels.velocity_from_state`).
80
+ opener: reader class for non-canonical shards, e.g.
81
+ ``functools.partial(phyworld_hdf5.open_shard, family="collision")``.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ source: str | Path | Sequence[str | Path] | ShardSet,
87
+ *,
88
+ pixel_range: str = "tanh",
89
+ labels: Sequence[str] | None = None,
90
+ velocity: bool = True,
91
+ velocity_object: int | None = None,
92
+ opener: Any = None,
93
+ ) -> None:
94
+ if pixel_range not in PIXEL_RANGES:
95
+ raise ValueError(
96
+ f"pixel_range must be one of {PIXEL_RANGES}; got {pixel_range!r}"
97
+ )
98
+ self.shards = _resolve_source(source, opener=opener)
99
+ self.pixel_range = pixel_range
100
+ self.meta: ShardMeta = self.shards.meta
101
+
102
+ available = set(self.shards.label_names())
103
+ if labels is None:
104
+ self.labels = tuple(sorted(available))
105
+ else:
106
+ missing = set(labels) - available
107
+ if missing:
108
+ raise SchemaError(
109
+ f"requested labels {sorted(missing)} are not in these shards; "
110
+ f"available: {sorted(available)}"
111
+ )
112
+ self.labels = tuple(labels)
113
+
114
+ self._velocity_object = velocity_object
115
+ self.emits_velocity = bool(velocity and "positions" in available)
116
+ if velocity and not self.emits_velocity:
117
+ raise SchemaError(
118
+ "velocity=True needs a 'positions' label, which these shards do "
119
+ f"not have (available: {sorted(available)}). Pass velocity=False, "
120
+ "or use a family that records positions."
121
+ )
122
+ if self.emits_velocity:
123
+ num_objects = int(self.meta.extra.get("num_objects", 1) or 1)
124
+ if num_objects > 1 and velocity_object is None:
125
+ raise SchemaError(
126
+ f"family {self.meta.family!r} has {num_objects} objects, so a "
127
+ "single per-clip velocity is ambiguous. Pass velocity_object=, "
128
+ "or velocity=False and derive from the 'positions' label."
129
+ )
130
+
131
+ @property
132
+ def source_fps(self) -> float:
133
+ """``1 / frame_dt``, for a latent encoder's FPS-aware path."""
134
+ return self.meta.source_fps
135
+
136
+ def manifest_key(self, *, length: int = 16) -> str:
137
+ """Stable hash of the underlying shard set, for addressing derived caches."""
138
+ return self.shards.manifest_key(length=length)
139
+
140
+ def manifest(self) -> dict[str, Any]:
141
+ return self.shards.manifest()
142
+
143
+ def __len__(self) -> int:
144
+ return len(self.shards)
145
+
146
+ def __getitem__(self, index: int) -> dict[str, Any]:
147
+ import torch
148
+
149
+ item: dict[str, Any] = {
150
+ "pixels": _to_pixels(self.shards.frames(index), self.pixel_range),
151
+ "index": index,
152
+ }
153
+ for name in self.labels:
154
+ value = np.asarray(self.shards.label(name, index))
155
+ item[name] = torch.from_numpy(np.ascontiguousarray(value)).to(torch.float32)
156
+ if self.emits_velocity:
157
+ positions = np.asarray(self.shards.label("positions", index))
158
+ item["velocity"] = torch.from_numpy(
159
+ velocity_from_state(
160
+ positions,
161
+ self.shards.spec("positions"),
162
+ image_size=self.meta.image_size,
163
+ world_scale=self.meta.world_scale,
164
+ object_index=self._velocity_object,
165
+ )
166
+ )
167
+ return item
168
+
169
+ def close(self) -> None:
170
+ self.shards.close()
171
+
172
+ def __enter__(self) -> ClipDataset:
173
+ return self
174
+
175
+ def __exit__(self, exc_type, exc, tb) -> None:
176
+ self.close()
177
+
178
+ def __repr__(self) -> str:
179
+ return (
180
+ f"ClipDataset(family={self.meta.family!r}, split={self.meta.split!r}, "
181
+ f"clips={len(self)}, clip_len={self.meta.clip_len}, "
182
+ f"image_size={self.meta.image_size}, frame_dt={self.meta.frame_dt}, "
183
+ f"pixel_range={self.pixel_range!r})"
184
+ )
185
+
186
+
187
+ def _resolve_source(
188
+ source: str | Path | Sequence[str | Path] | ShardSet, *, opener: Any
189
+ ) -> ShardSet:
190
+ if isinstance(source, ShardSet):
191
+ return source
192
+ if isinstance(source, (str, Path)):
193
+ path = Path(source)
194
+ if path.is_dir():
195
+ return ShardSet.from_dir(path, opener=opener)
196
+ return ShardSet([path], opener=opener)
197
+ return ShardSet(list(source), opener=opener)
198
+
199
+
200
+ def iter_clip_batches(
201
+ dataset: ClipDataset,
202
+ *,
203
+ batch_size: int = 8,
204
+ velocity_fallback: bool = True,
205
+ ) -> Any:
206
+ """Yield ``(clips [B, 3, T, H, W], velocities [B, 2])`` batches.
207
+
208
+ The shape a frozen latent encoder wants. Kept here rather than in the
209
+ consumer so that "render a clip grid" and "read clips off disk" present the
210
+ same interface to an encode loop.
211
+
212
+ With ``velocity_fallback`` and no velocity available, zeros are emitted --
213
+ appropriate when velocity is only a diagnostic label, not a training target.
214
+ """
215
+ import torch
216
+
217
+ clips: list[Any] = []
218
+ vels: list[Any] = []
219
+ for index in range(len(dataset)):
220
+ item = dataset[index]
221
+ clips.append(item["pixels"])
222
+ if "velocity" in item:
223
+ vels.append(item["velocity"])
224
+ elif velocity_fallback:
225
+ vels.append(torch.zeros(2, dtype=torch.float32))
226
+ else:
227
+ raise SchemaError(
228
+ "dataset emits no velocity; pass velocity_fallback=True to get "
229
+ "zeros, or construct the ClipDataset with velocity=True"
230
+ )
231
+ if len(clips) == batch_size:
232
+ yield torch.stack(clips), torch.stack(vels)
233
+ clips, vels = [], []
234
+ if clips:
235
+ yield torch.stack(clips), torch.stack(vels)