pixeltable 0.4.9__py3-none-any.whl → 0.4.11__py3-none-any.whl
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.
Potentially problematic release.
This version of pixeltable might be problematic. Click here for more details.
- pixeltable/env.py +38 -0
- pixeltable/functions/audio.py +2 -1
- pixeltable/functions/gemini.py +8 -0
- pixeltable/functions/video.py +534 -81
- pixeltable/functions/whisper.py +8 -0
- pixeltable/iterators/__init__.py +1 -1
- pixeltable/iterators/video.py +138 -0
- pixeltable/utils/av.py +111 -0
- pixeltable/utils/code.py +2 -1
- {pixeltable-0.4.9.dist-info → pixeltable-0.4.11.dist-info}/METADATA +1 -1
- {pixeltable-0.4.9.dist-info → pixeltable-0.4.11.dist-info}/RECORD +14 -13
- {pixeltable-0.4.9.dist-info → pixeltable-0.4.11.dist-info}/WHEEL +0 -0
- {pixeltable-0.4.9.dist-info → pixeltable-0.4.11.dist-info}/entry_points.txt +0 -0
- {pixeltable-0.4.9.dist-info → pixeltable-0.4.11.dist-info}/licenses/LICENSE +0 -0
pixeltable/env.py
CHANGED
|
@@ -11,6 +11,7 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
import platform
|
|
13
13
|
import shutil
|
|
14
|
+
import subprocess
|
|
14
15
|
import sys
|
|
15
16
|
import threading
|
|
16
17
|
import types
|
|
@@ -82,6 +83,7 @@ class Env:
|
|
|
82
83
|
_file_cache_size_g: float
|
|
83
84
|
_pxt_api_key: Optional[str]
|
|
84
85
|
_stdout_handler: logging.StreamHandler
|
|
86
|
+
_default_video_encoder: str | None
|
|
85
87
|
_initialized: bool
|
|
86
88
|
|
|
87
89
|
_resource_pool_info: dict[str, Any]
|
|
@@ -132,6 +134,7 @@ class Env:
|
|
|
132
134
|
self._spacy_nlp = None
|
|
133
135
|
self._httpd = None
|
|
134
136
|
self._http_address = None
|
|
137
|
+
self._default_video_encoder = None
|
|
135
138
|
|
|
136
139
|
# logging-related state
|
|
137
140
|
self._logger = logging.getLogger('pixeltable')
|
|
@@ -677,6 +680,41 @@ class Env:
|
|
|
677
680
|
self._start_web_server()
|
|
678
681
|
self.__register_packages()
|
|
679
682
|
|
|
683
|
+
@property
|
|
684
|
+
def default_video_encoder(self) -> str | None:
|
|
685
|
+
if self._default_video_encoder is None:
|
|
686
|
+
self._default_video_encoder = self._determine_default_video_encoder()
|
|
687
|
+
return self._default_video_encoder
|
|
688
|
+
|
|
689
|
+
def _determine_default_video_encoder(self) -> str | None:
|
|
690
|
+
"""
|
|
691
|
+
Returns the first available encoder from a list of candidates.
|
|
692
|
+
|
|
693
|
+
TODO:
|
|
694
|
+
- the user might prefer a hardware-accelerated encoder (eg, h264_nvenc or h264_videotoolbox)
|
|
695
|
+
- allow user override via a config option 'video_encoder'
|
|
696
|
+
"""
|
|
697
|
+
# look for available encoders, in this order
|
|
698
|
+
candidates = [
|
|
699
|
+
'libx264', # GPL, best quality
|
|
700
|
+
'libopenh264', # BSD
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
# Get list of available encoders
|
|
705
|
+
result = subprocess.run(['ffmpeg', '-encoders'], capture_output=True, text=True, timeout=10, check=True)
|
|
706
|
+
|
|
707
|
+
if result.returncode == 0:
|
|
708
|
+
available_encoders = result.stdout
|
|
709
|
+
for encoder in candidates:
|
|
710
|
+
# ffmpeg -encoders output format: " V..... encoder_name description"
|
|
711
|
+
if f' {encoder} ' in available_encoders:
|
|
712
|
+
_logger.debug(f'Using H.264 encoder: {encoder}')
|
|
713
|
+
return encoder
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
return None
|
|
717
|
+
|
|
680
718
|
def __register_packages(self) -> None:
|
|
681
719
|
"""Declare optional packages that are utilized by some parts of the code."""
|
|
682
720
|
self.__register_package('anthropic')
|
pixeltable/functions/audio.py
CHANGED
|
@@ -3,6 +3,7 @@ Pixeltable [UDFs](https://pixeltable.readme.io/docs/user-defined-functions-udfs)
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import pixeltable as pxt
|
|
6
|
+
import pixeltable.utils.av as av_utils
|
|
6
7
|
from pixeltable.utils.code import local_public_names
|
|
7
8
|
|
|
8
9
|
|
|
@@ -47,7 +48,7 @@ def get_metadata(audio: pxt.Audio) -> dict:
|
|
|
47
48
|
|
|
48
49
|
>>> tbl.select(tbl.audio_col.get_metadata()).collect()
|
|
49
50
|
"""
|
|
50
|
-
return
|
|
51
|
+
return av_utils.get_metadata(audio)
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
__all__ = local_public_names(__name__)
|
pixeltable/functions/gemini.py
CHANGED
|
@@ -14,6 +14,7 @@ import PIL.Image
|
|
|
14
14
|
|
|
15
15
|
import pixeltable as pxt
|
|
16
16
|
from pixeltable import env, exceptions as excs, exprs
|
|
17
|
+
from pixeltable.utils.code import local_public_names
|
|
17
18
|
from pixeltable.utils.media_store import TempStore
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
@@ -232,3 +233,10 @@ async def generate_videos(
|
|
|
232
233
|
@generate_videos.resource_pool
|
|
233
234
|
def _(model: str) -> str:
|
|
234
235
|
return f'request-rate:veo:{model}'
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = local_public_names(__name__)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def __dir__() -> list[str]:
|
|
242
|
+
return __all__
|
pixeltable/functions/video.py
CHANGED
|
@@ -2,16 +2,24 @@
|
|
|
2
2
|
Pixeltable [UDFs](https://pixeltable.readme.io/docs/user-defined-functions-udfs) for `VideoType`.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import logging
|
|
6
|
+
import pathlib
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Literal, NoReturn
|
|
6
10
|
|
|
7
11
|
import av
|
|
12
|
+
import av.stream
|
|
8
13
|
import numpy as np
|
|
9
14
|
import PIL.Image
|
|
10
15
|
|
|
11
16
|
import pixeltable as pxt
|
|
17
|
+
import pixeltable.utils.av as av_utils
|
|
18
|
+
from pixeltable.env import Env
|
|
12
19
|
from pixeltable.utils.code import local_public_names
|
|
13
20
|
from pixeltable.utils.media_store import TempStore
|
|
14
21
|
|
|
22
|
+
_logger = logging.getLogger('pixeltable')
|
|
15
23
|
_format_defaults: dict[str, tuple[str, str]] = { # format -> (codec, ext)
|
|
16
24
|
'wav': ('pcm_s16le', 'wav'),
|
|
17
25
|
'mp3': ('libmp3lame', 'mp3'),
|
|
@@ -39,21 +47,17 @@ _format_defaults: dict[str, tuple[str, str]] = { # format -> (codec, ext)
|
|
|
39
47
|
@pxt.uda(requires_order_by=True)
|
|
40
48
|
class make_video(pxt.Aggregator):
|
|
41
49
|
"""
|
|
42
|
-
Aggregator that creates a video from a sequence of images.
|
|
43
|
-
|
|
44
|
-
Creates an H.264 encoded MP4 video from a sequence of PIL Image frames. This aggregator requires the input
|
|
45
|
-
frames to be ordered (typically by frame position) and is commonly used with `FrameIterator` views to
|
|
46
|
-
reconstruct videos from processed frames.
|
|
50
|
+
Aggregator that creates a video from a sequence of images, using the default video encoder and yuv420p pixel format.
|
|
47
51
|
|
|
48
52
|
Args:
|
|
49
|
-
fps: Frames per second for the output video.
|
|
53
|
+
fps: Frames per second for the output video.
|
|
50
54
|
|
|
51
55
|
Returns:
|
|
52
56
|
|
|
53
|
-
-
|
|
57
|
+
- The created video.
|
|
54
58
|
|
|
55
59
|
Examples:
|
|
56
|
-
Create a video from frames extracted using FrameIterator
|
|
60
|
+
Create a video from frames extracted using `FrameIterator`:
|
|
57
61
|
|
|
58
62
|
>>> import pixeltable as pxt
|
|
59
63
|
>>> from pixeltable.functions.video import make_video
|
|
@@ -76,31 +80,29 @@ class make_video(pxt.Aggregator):
|
|
|
76
80
|
|
|
77
81
|
Apply transformations to frames before creating a video:
|
|
78
82
|
|
|
79
|
-
>>> # Add computed column with transformed frames
|
|
80
|
-
>>> frames_view.add_computed_column(
|
|
81
|
-
... rotated_frame=frames_view.frame.rotate(30),
|
|
82
|
-
... stored=True
|
|
83
|
-
... )
|
|
84
|
-
>>>
|
|
85
83
|
>>> # Create video from transformed frames
|
|
86
84
|
>>> frames_view.group_by(videos_table).select(
|
|
87
|
-
... make_video(frames_view.pos, frames_view.
|
|
85
|
+
... make_video(frames_view.pos, frames_view.frame.rotate(30))
|
|
88
86
|
... ).show()
|
|
89
87
|
|
|
90
88
|
Compare multiple processed versions side-by-side:
|
|
91
89
|
|
|
92
90
|
>>> frames_view.group_by(videos_table).select(
|
|
93
91
|
... make_video(frames_view.pos, frames_view.frame),
|
|
94
|
-
... make_video(frames_view.pos, frames_view.
|
|
92
|
+
... make_video(frames_view.pos, frames_view.frame.rotate(30))
|
|
95
93
|
... ).show()
|
|
96
94
|
"""
|
|
97
95
|
|
|
98
|
-
container:
|
|
99
|
-
stream:
|
|
96
|
+
container: av.container.OutputContainer | None
|
|
97
|
+
stream: av.video.stream.VideoStream | None
|
|
100
98
|
fps: int
|
|
101
99
|
|
|
102
100
|
def __init__(self, fps: int = 25):
|
|
103
|
-
"""
|
|
101
|
+
"""
|
|
102
|
+
Follows https://pyav.org/docs/develop/cookbook/numpy.html#generating-video
|
|
103
|
+
|
|
104
|
+
TODO: provide parameters for video_encoder and pix_fmt
|
|
105
|
+
"""
|
|
104
106
|
self.container = None
|
|
105
107
|
self.stream = None
|
|
106
108
|
self.fps = fps
|
|
@@ -129,7 +131,7 @@ class make_video(pxt.Aggregator):
|
|
|
129
131
|
|
|
130
132
|
@pxt.udf(is_method=True)
|
|
131
133
|
def extract_audio(
|
|
132
|
-
video_path: pxt.Video, stream_idx: int = 0, format: str = 'wav', codec:
|
|
134
|
+
video_path: pxt.Video, stream_idx: int = 0, format: str = 'wav', codec: str | None = None
|
|
133
135
|
) -> pxt.Audio:
|
|
134
136
|
"""
|
|
135
137
|
Extract an audio stream from a video.
|
|
@@ -176,7 +178,7 @@ def get_metadata(video: pxt.Video) -> dict:
|
|
|
176
178
|
Gets various metadata associated with a video file and returns it as a dictionary.
|
|
177
179
|
|
|
178
180
|
Args:
|
|
179
|
-
video: The video to get metadata
|
|
181
|
+
video: The video for which to get metadata.
|
|
180
182
|
|
|
181
183
|
Returns:
|
|
182
184
|
A `dict` such as the following:
|
|
@@ -221,66 +223,517 @@ def get_metadata(video: pxt.Video) -> dict:
|
|
|
221
223
|
|
|
222
224
|
>>> tbl.select(tbl.video_col.get_metadata()).collect()
|
|
223
225
|
"""
|
|
224
|
-
return
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
'type': stream.type,
|
|
253
|
-
'duration': stream.duration,
|
|
254
|
-
'time_base': float(stream.time_base) if stream.time_base is not None else None,
|
|
255
|
-
'duration_seconds': float(stream.duration * stream.time_base)
|
|
256
|
-
if stream.duration is not None and stream.time_base is not None
|
|
257
|
-
else None,
|
|
258
|
-
'frames': stream.frames,
|
|
259
|
-
'metadata': stream.metadata,
|
|
260
|
-
'codec_context': codec_context_md,
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if stream.type == 'audio':
|
|
264
|
-
# Additional metadata for audio
|
|
265
|
-
channels = getattr(stream.codec_context, 'channels', None)
|
|
266
|
-
codec_context_md['channels'] = int(channels) if channels is not None else None
|
|
267
|
-
else:
|
|
268
|
-
assert stream.type == 'video'
|
|
269
|
-
assert isinstance(stream, av.video.stream.VideoStream)
|
|
270
|
-
# Additional metadata for video
|
|
271
|
-
codec_context_md['pix_fmt'] = getattr(stream.codec_context, 'pix_fmt', None)
|
|
272
|
-
metadata.update(
|
|
273
|
-
**{
|
|
274
|
-
'width': stream.width,
|
|
275
|
-
'height': stream.height,
|
|
276
|
-
'frames': stream.frames,
|
|
277
|
-
'average_rate': float(stream.average_rate) if stream.average_rate is not None else None,
|
|
278
|
-
'base_rate': float(stream.base_rate) if stream.base_rate is not None else None,
|
|
279
|
-
'guessed_rate': float(stream.guessed_rate) if stream.guessed_rate is not None else None,
|
|
280
|
-
}
|
|
281
|
-
)
|
|
226
|
+
return av_utils.get_metadata(video)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pxt.udf(is_method=True)
|
|
230
|
+
def get_duration(video: pxt.Video) -> float | None:
|
|
231
|
+
"""
|
|
232
|
+
Get video duration in seconds.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
video: The video for which to get the duration.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The duration in seconds, or None if the duration cannot be determined.
|
|
239
|
+
"""
|
|
240
|
+
return av_utils.get_video_duration(video)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pxt.udf(is_method=True)
|
|
244
|
+
def extract_frame(video: pxt.Video, *, timestamp: float) -> PIL.Image.Image | None:
|
|
245
|
+
"""
|
|
246
|
+
Extract a single frame from a video at a specific timestamp.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
video: The video from which to extract the frame.
|
|
250
|
+
timestamp: Extract frame at this timestamp (in seconds).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The extracted frame as a PIL Image, or None if the timestamp is beyond the video duration.
|
|
282
254
|
|
|
283
|
-
|
|
255
|
+
Examples:
|
|
256
|
+
Extract the first frame from each video in the `video` column of the table `tbl`:
|
|
257
|
+
|
|
258
|
+
>>> tbl.select(tbl.video.extract_frame(0.0)).collect()
|
|
259
|
+
|
|
260
|
+
Extract a frame close to the end of each video in the `video` column of the table `tbl`:
|
|
261
|
+
|
|
262
|
+
>>> tbl.select(tbl.video.extract_frame(tbl.video.get_metadata().streams[0].duration_seconds - 0.1)).collect()
|
|
263
|
+
"""
|
|
264
|
+
if timestamp < 0:
|
|
265
|
+
raise ValueError("'timestamp' must be non-negative")
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
with av.open(str(video)) as container:
|
|
269
|
+
video_stream = container.streams.video[0]
|
|
270
|
+
time_base = float(video_stream.time_base)
|
|
271
|
+
start_time = video_stream.start_time or 0
|
|
272
|
+
duration = video_stream.duration
|
|
273
|
+
|
|
274
|
+
# Check if timestamp is beyond video duration
|
|
275
|
+
if duration is not None:
|
|
276
|
+
duration_seconds = float(duration * time_base)
|
|
277
|
+
if timestamp > duration_seconds:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
# Convert timestamp to stream time base units
|
|
281
|
+
target_pts = int(timestamp / time_base) + start_time
|
|
282
|
+
|
|
283
|
+
# Seek to the nearest keyframe *before* our target timestamp
|
|
284
|
+
container.seek(target_pts, backward=True, stream=video_stream)
|
|
285
|
+
|
|
286
|
+
# Decode frames until we reach or pass the target timestamp
|
|
287
|
+
for frame in container.decode(video=0):
|
|
288
|
+
frame_pts = frame.pts
|
|
289
|
+
if frame_pts is None:
|
|
290
|
+
continue
|
|
291
|
+
frame_timestamp = (frame_pts - start_time) * time_base
|
|
292
|
+
if frame_timestamp >= timestamp:
|
|
293
|
+
return frame.to_image()
|
|
294
|
+
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
raise pxt.Error(f'extract_frame(): failed to extract frame: {e}') from e
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _handle_ffmpeg_error(e: subprocess.CalledProcessError) -> NoReturn:
|
|
302
|
+
error_msg = f'ffmpeg failed with return code {e.returncode}'
|
|
303
|
+
if e.stderr is not None:
|
|
304
|
+
error_msg += f':\n{e.stderr.strip()}'
|
|
305
|
+
raise pxt.Error(error_msg) from e
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pxt.udf(is_method=True)
|
|
309
|
+
def clip(
|
|
310
|
+
video: pxt.Video, *, start_time: float, end_time: float | None = None, duration: float | None = None
|
|
311
|
+
) -> pxt.Video | None:
|
|
312
|
+
"""
|
|
313
|
+
Extract a clip from a video, specified by `start_time` and either `end_time` or `duration` (in seconds).
|
|
314
|
+
|
|
315
|
+
If `start_time` is beyond the end of the video, returns None. Can only specify one of `end_time` and `duration`.
|
|
316
|
+
If both `end_time` and `duration` are None, the clip goes to the end of the video.
|
|
317
|
+
|
|
318
|
+
__Requirements:__
|
|
319
|
+
|
|
320
|
+
- `ffmpeg` needs to be installed and in PATH
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
video: Input video file
|
|
324
|
+
start_time: Start time in seconds
|
|
325
|
+
end_time: End time in seconds
|
|
326
|
+
duration: Duration of the clip in seconds
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
New video containing only the specified time range or None if start_time is beyond the end of the video.
|
|
330
|
+
"""
|
|
331
|
+
if start_time < 0:
|
|
332
|
+
raise pxt.Error(f'start_time must be non-negative, got {start_time}')
|
|
333
|
+
if end_time is not None and end_time <= start_time:
|
|
334
|
+
raise pxt.Error(f'end_time ({end_time}) must be greater than start_time ({start_time})')
|
|
335
|
+
if duration is not None and duration <= 0:
|
|
336
|
+
raise pxt.Error(f'duration must be positive, got {duration}')
|
|
337
|
+
if end_time is not None and duration is not None:
|
|
338
|
+
raise pxt.Error('end_time and duration cannot both be specified')
|
|
339
|
+
if not shutil.which('ffmpeg'):
|
|
340
|
+
raise pxt.Error('ffmpeg is not installed or not in PATH. Please install ffmpeg to use get_clip().')
|
|
341
|
+
|
|
342
|
+
video_duration = av_utils.get_video_duration(video)
|
|
343
|
+
if video_duration is not None and start_time > video_duration:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
output_path = str(TempStore.create_path(extension='.mp4'))
|
|
347
|
+
|
|
348
|
+
if end_time is not None:
|
|
349
|
+
duration = end_time - start_time
|
|
350
|
+
cmd = av_utils.ffmpeg_clip_cmd(str(video), output_path, start_time, duration)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
354
|
+
output_file = pathlib.Path(output_path)
|
|
355
|
+
if not output_file.exists() or output_file.stat().st_size == 0:
|
|
356
|
+
stderr_output = result.stderr.strip() if result.stderr is not None else ''
|
|
357
|
+
raise pxt.Error(f'ffmpeg failed to create output file for commandline: {" ".join(cmd)}\n{stderr_output}')
|
|
358
|
+
return output_path
|
|
359
|
+
except subprocess.CalledProcessError as e:
|
|
360
|
+
_handle_ffmpeg_error(e)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@pxt.udf(is_method=True)
|
|
364
|
+
def segment_video(video: pxt.Video, *, duration: float) -> list[str]:
|
|
365
|
+
"""
|
|
366
|
+
Split a video into fixed-size segments.
|
|
367
|
+
|
|
368
|
+
__Requirements:__
|
|
369
|
+
|
|
370
|
+
- `ffmpeg` needs to be installed and in PATH
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
video: Input video file to segment
|
|
374
|
+
duration: Approximate duration of each segment (in seconds).
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
List of file paths for the generated video segments.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
pxt.Error: If the video is missing timing information.
|
|
381
|
+
|
|
382
|
+
Examples:
|
|
383
|
+
Split a video at 1 minute intervals
|
|
384
|
+
|
|
385
|
+
>>> tbl.select(segment_paths=tbl.video.segment_video(duration=60)).collect()
|
|
386
|
+
|
|
387
|
+
Split video into two parts at the midpoint:
|
|
388
|
+
|
|
389
|
+
>>> duration = tbl.video.get_duration()
|
|
390
|
+
>>> tbl.select(segment_paths=tbl.video.segment_video(duration=duration / 2 + 1)).collect()
|
|
391
|
+
"""
|
|
392
|
+
if duration <= 0:
|
|
393
|
+
raise pxt.Error(f'duration must be positive, got {duration}')
|
|
394
|
+
if not shutil.which('ffmpeg'):
|
|
395
|
+
raise pxt.Error('ffmpeg is not installed or not in PATH. Please install ffmpeg to use segment_video().')
|
|
396
|
+
|
|
397
|
+
base_path = TempStore.create_path(extension='')
|
|
398
|
+
|
|
399
|
+
# we extract consecutive clips instead of running ffmpeg -f segment, which is inexplicably much slower
|
|
400
|
+
start_time = 0.0
|
|
401
|
+
result: list[str] = []
|
|
402
|
+
try:
|
|
403
|
+
while True:
|
|
404
|
+
segment_path = f'{base_path}_segment_{len(result)}.mp4'
|
|
405
|
+
cmd = av_utils.ffmpeg_clip_cmd(str(video), segment_path, start_time, duration)
|
|
406
|
+
|
|
407
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
408
|
+
segment_duration = av_utils.get_video_duration(segment_path)
|
|
409
|
+
if segment_duration == 0.0:
|
|
410
|
+
# we're done
|
|
411
|
+
pathlib.Path(segment_path).unlink()
|
|
412
|
+
return result
|
|
413
|
+
result.append(segment_path)
|
|
414
|
+
start_time += segment_duration # use the actual segment duration here, it won't match duration exactly
|
|
415
|
+
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
except subprocess.CalledProcessError as e:
|
|
419
|
+
# clean up partial results
|
|
420
|
+
for segment_path in result:
|
|
421
|
+
pathlib.Path(segment_path).unlink()
|
|
422
|
+
_handle_ffmpeg_error(e)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@pxt.udf(is_method=True)
|
|
426
|
+
def concat_videos(videos: list[pxt.Video]) -> pxt.Video:
|
|
427
|
+
"""
|
|
428
|
+
Merge multiple videos into a single video.
|
|
429
|
+
|
|
430
|
+
__Requirements:__
|
|
431
|
+
|
|
432
|
+
- `ffmpeg` needs to be installed and in PATH
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
videos: List of videos to merge.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
A new video containing the merged videos.
|
|
439
|
+
"""
|
|
440
|
+
if len(videos) == 0:
|
|
441
|
+
raise pxt.Error('concat_videos(): empty argument list')
|
|
442
|
+
if not shutil.which('ffmpeg'):
|
|
443
|
+
raise pxt.Error('ffmpeg is not installed or not in PATH. Please install ffmpeg to use concat_videos().')
|
|
444
|
+
|
|
445
|
+
# Check that all videos have the same resolution
|
|
446
|
+
resolutions: list[tuple[int, int]] = []
|
|
447
|
+
for video in videos:
|
|
448
|
+
metadata = av_utils.get_metadata(str(video))
|
|
449
|
+
video_stream = next((stream for stream in metadata['streams'] if stream['type'] == 'video'), None)
|
|
450
|
+
if video_stream is None:
|
|
451
|
+
raise pxt.Error(f'concat_videos(): file {video!r} has no video stream')
|
|
452
|
+
resolutions.append((video_stream['width'], video_stream['height']))
|
|
453
|
+
|
|
454
|
+
# check for divergence
|
|
455
|
+
x0, y0 = resolutions[0]
|
|
456
|
+
for i, (x, y) in enumerate(resolutions[1:], start=1):
|
|
457
|
+
if (x0, y0) != (x, y):
|
|
458
|
+
raise pxt.Error(
|
|
459
|
+
f'concat_videos(): requires that all videos have the same resolution, but:'
|
|
460
|
+
f'\n video 0 ({videos[0]!r}): {x0}x{y0}'
|
|
461
|
+
f'\n video {i} ({videos[i]!r}): {x}x{y}.'
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# ffmpeg -f concat needs an input file list
|
|
465
|
+
filelist_path = TempStore.create_path(extension='.txt')
|
|
466
|
+
with filelist_path.open('w', encoding='utf-8') as f:
|
|
467
|
+
for video in videos:
|
|
468
|
+
f.write(f'file {video!r}\n')
|
|
469
|
+
|
|
470
|
+
output_path = TempStore.create_path(extension='.mp4')
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
# First attempt: fast copy without re-encoding (works for compatible formats)
|
|
474
|
+
cmd = ['ffmpeg', '-f', 'concat', '-safe', '0', '-i', str(filelist_path), '-c', 'copy', '-y', str(output_path)]
|
|
475
|
+
_logger.debug(f'concat_videos(): {" ".join(cmd)}')
|
|
476
|
+
try:
|
|
477
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
478
|
+
return str(output_path)
|
|
479
|
+
except subprocess.CalledProcessError:
|
|
480
|
+
# Expected for mixed formats - continue to fallback
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
# we might have some corrupted output
|
|
484
|
+
if output_path.exists():
|
|
485
|
+
output_path.unlink()
|
|
486
|
+
|
|
487
|
+
# general approach: re-encode with -f filter_complex
|
|
488
|
+
#
|
|
489
|
+
# example: 2 videos with audio:
|
|
490
|
+
# ffmpeg -i video1.mp4 -i video2.mp4
|
|
491
|
+
# -filter_complex "[0:v:0][1:v:0]concat=n=2:v=1:a=0[outv];[0:a:0][1:a:0]concat=n=2:v=0:a=1[outa]"
|
|
492
|
+
# -map "[outv]" -map "[outa]"
|
|
493
|
+
# ...
|
|
494
|
+
# breakdown:
|
|
495
|
+
# - [0:v:0][1:v:0] - video stream 0 from inputs 0 and 1
|
|
496
|
+
# - concat=n=2:v=1:a=0[outv] - concat 2 inputs, 1 video stream, 0 audio, output to [outv]
|
|
497
|
+
# - [0:a:0][1:a:0] - audio stream 0 from inputs 0 and 1
|
|
498
|
+
# - concat=n=2:v=0:a=1[outa] - concat 2 inputs, 0 video, 1 audio stream, output to [outa]
|
|
499
|
+
|
|
500
|
+
cmd = ['ffmpeg']
|
|
501
|
+
for video in videos:
|
|
502
|
+
cmd.extend(['-i', video])
|
|
503
|
+
|
|
504
|
+
all_have_audio = all(av_utils.has_audio_stream(str(video)) for video in videos)
|
|
505
|
+
video_inputs = ''.join([f'[{i}:v:0]' for i in range(len(videos))])
|
|
506
|
+
# concat video streams
|
|
507
|
+
filter_str = f'{video_inputs}concat=n={len(videos)}:v=1:a=0[outv]'
|
|
508
|
+
if all_have_audio:
|
|
509
|
+
# also concat audio streams
|
|
510
|
+
audio_inputs = ''.join([f'[{i}:a:0]' for i in range(len(videos))])
|
|
511
|
+
filter_str += f';{audio_inputs}concat=n={len(videos)}:v=0:a=1[outa]'
|
|
512
|
+
cmd.extend(['-filter_complex', filter_str, '-map', '[outv]'])
|
|
513
|
+
if all_have_audio:
|
|
514
|
+
cmd.extend(['-map', '[outa]'])
|
|
515
|
+
|
|
516
|
+
video_encoder = Env.get().default_video_encoder
|
|
517
|
+
if video_encoder is not None:
|
|
518
|
+
cmd.extend(['-c:v', video_encoder])
|
|
519
|
+
if all_have_audio:
|
|
520
|
+
cmd.extend(['-c:a', 'aac'])
|
|
521
|
+
cmd.extend(['-pix_fmt', 'yuv420p', str(output_path)])
|
|
522
|
+
|
|
523
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
524
|
+
return str(output_path)
|
|
525
|
+
|
|
526
|
+
except subprocess.CalledProcessError as e:
|
|
527
|
+
_handle_ffmpeg_error(e)
|
|
528
|
+
finally:
|
|
529
|
+
filelist_path.unlink()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@pxt.udf(is_method=True)
|
|
533
|
+
def overlay_text(
|
|
534
|
+
video: pxt.Video,
|
|
535
|
+
text: str,
|
|
536
|
+
*,
|
|
537
|
+
font: str | None = None,
|
|
538
|
+
font_size: int = 24,
|
|
539
|
+
color: str = 'white',
|
|
540
|
+
opacity: float = 1.0,
|
|
541
|
+
horizontal_align: Literal['left', 'center', 'right'] = 'center',
|
|
542
|
+
horizontal_margin: int = 0,
|
|
543
|
+
vertical_align: Literal['top', 'center', 'bottom'] = 'center',
|
|
544
|
+
vertical_margin: int = 0,
|
|
545
|
+
box: bool = False,
|
|
546
|
+
box_color: str = 'black',
|
|
547
|
+
box_opacity: float = 1.0,
|
|
548
|
+
box_border: list[int] | None = None,
|
|
549
|
+
) -> pxt.Video:
|
|
550
|
+
"""
|
|
551
|
+
Overlay text on a video with customizable positioning and styling.
|
|
552
|
+
|
|
553
|
+
__Requirements:__
|
|
554
|
+
|
|
555
|
+
- `ffmpeg` needs to be installed and in PATH
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
video: Input video to overlay text on.
|
|
559
|
+
text: The text string to overlay on the video.
|
|
560
|
+
font: Font family or path to font file. If None, uses the system default.
|
|
561
|
+
font_size: Size of the text in points.
|
|
562
|
+
color: Text color (e.g., `'white'`, `'red'`, `'#FF0000'`).
|
|
563
|
+
opacity: Text opacity from 0.0 (transparent) to 1.0 (opaque).
|
|
564
|
+
horizontal_align: Horizontal text alignment (`'left'`, `'center'`, `'right'`).
|
|
565
|
+
horizontal_margin: Horizontal margin in pixels from the alignment edge.
|
|
566
|
+
vertical_align: Vertical text alignment (`'top'`, `'center'`, `'bottom'`).
|
|
567
|
+
vertical_margin: Vertical margin in pixels from the alignment edge.
|
|
568
|
+
box: Whether to draw a background box behind the text.
|
|
569
|
+
box_color: Background box color as a string.
|
|
570
|
+
box_opacity: Background box opacity from 0.0 to 1.0.
|
|
571
|
+
box_border: Padding around text in the box in pixels.
|
|
572
|
+
|
|
573
|
+
- `[10]`: 10 pixels on all sides
|
|
574
|
+
- `[10, 20]`: 10 pixels on top/bottom, 20 on left/right
|
|
575
|
+
- `[10, 20, 30]`: 10 pixels on top, 20 on left/right, 30 on bottom
|
|
576
|
+
- `[10, 20, 30, 40]`: 10 pixels on top, 20 on right, 30 on bottom, 40 on left
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
A new video with the text overlay applied.
|
|
580
|
+
|
|
581
|
+
Examples:
|
|
582
|
+
Add a simple text overlay to videos in a table:
|
|
583
|
+
|
|
584
|
+
>>> tbl.select(tbl.video.overlay_text('Sample Text')).collect()
|
|
585
|
+
|
|
586
|
+
Add a YouTube-style caption:
|
|
587
|
+
|
|
588
|
+
>>> tbl.select(
|
|
589
|
+
... tbl.video.overlay_text(
|
|
590
|
+
... 'Caption text',
|
|
591
|
+
... font_size=32,
|
|
592
|
+
... color='white',
|
|
593
|
+
... opacity=1.0,
|
|
594
|
+
... box=True,
|
|
595
|
+
... box_color='black',
|
|
596
|
+
... box_opacity=0.8,
|
|
597
|
+
... box_border=[6, 14],
|
|
598
|
+
... horizontal_margin=10,
|
|
599
|
+
... vertical_align='bottom',
|
|
600
|
+
... vertical_margin=70
|
|
601
|
+
... )
|
|
602
|
+
... ).collect()
|
|
603
|
+
|
|
604
|
+
Add text with a semi-transparent background box:
|
|
605
|
+
|
|
606
|
+
>>> tbl.select(
|
|
607
|
+
... tbl.video.overlay_text(
|
|
608
|
+
... 'Important Message',
|
|
609
|
+
... font_size=32,
|
|
610
|
+
... color='yellow',
|
|
611
|
+
... box=True,
|
|
612
|
+
... box_color='black',
|
|
613
|
+
... box_opacity=0.6,
|
|
614
|
+
... box_border=[20, 10]
|
|
615
|
+
... )
|
|
616
|
+
... ).collect()
|
|
617
|
+
"""
|
|
618
|
+
if not shutil.which('ffmpeg'):
|
|
619
|
+
raise pxt.Error('ffmpeg is not installed or not in PATH. Please install ffmpeg to use overlay_text().')
|
|
620
|
+
if font_size <= 0:
|
|
621
|
+
raise pxt.Error(f'font_size must be positive, got {font_size}')
|
|
622
|
+
if opacity < 0.0 or opacity > 1.0:
|
|
623
|
+
raise pxt.Error(f'opacity must be between 0.0 and 1.0, got {opacity}')
|
|
624
|
+
if horizontal_margin < 0:
|
|
625
|
+
raise pxt.Error(f'horizontal_margin must be non-negative, got {horizontal_margin}')
|
|
626
|
+
if vertical_margin < 0:
|
|
627
|
+
raise pxt.Error(f'vertical_margin must be non-negative, got {vertical_margin}')
|
|
628
|
+
if box_opacity < 0.0 or box_opacity > 1.0:
|
|
629
|
+
raise pxt.Error(f'box_opacity must be between 0.0 and 1.0, got {box_opacity}')
|
|
630
|
+
if box_border is not None and not (
|
|
631
|
+
isinstance(box_border, (list, tuple))
|
|
632
|
+
and len(box_border) >= 1
|
|
633
|
+
and len(box_border) <= 4
|
|
634
|
+
and all(isinstance(x, int) for x in box_border)
|
|
635
|
+
and all(x >= 0 for x in box_border)
|
|
636
|
+
):
|
|
637
|
+
raise pxt.Error(f'box_border must be a list or tuple of 1-4 non-negative ints, got {box_border!s} instead')
|
|
638
|
+
|
|
639
|
+
output_path = str(TempStore.create_path(extension='.mp4'))
|
|
640
|
+
|
|
641
|
+
drawtext_params = _create_drawtext_params(
|
|
642
|
+
text,
|
|
643
|
+
font,
|
|
644
|
+
font_size,
|
|
645
|
+
color,
|
|
646
|
+
opacity,
|
|
647
|
+
horizontal_align,
|
|
648
|
+
horizontal_margin,
|
|
649
|
+
vertical_align,
|
|
650
|
+
vertical_margin,
|
|
651
|
+
box,
|
|
652
|
+
box_color,
|
|
653
|
+
box_opacity,
|
|
654
|
+
box_border,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
cmd = [
|
|
658
|
+
'ffmpeg',
|
|
659
|
+
'-i',
|
|
660
|
+
str(video),
|
|
661
|
+
'-vf',
|
|
662
|
+
'drawtext=' + ':'.join(drawtext_params),
|
|
663
|
+
'-c:a',
|
|
664
|
+
'copy', # Copy audio stream unchanged
|
|
665
|
+
'-loglevel',
|
|
666
|
+
'error', # Only show errors
|
|
667
|
+
output_path,
|
|
668
|
+
]
|
|
669
|
+
_logger.debug(f'overlay_text(): {" ".join(cmd)}')
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
673
|
+
output_file = pathlib.Path(output_path)
|
|
674
|
+
if not output_file.exists() or output_file.stat().st_size == 0:
|
|
675
|
+
stderr_output = result.stderr.strip() if result.stderr is not None else ''
|
|
676
|
+
raise pxt.Error(f'ffmpeg failed to create output file for commandline: {" ".join(cmd)}\n{stderr_output}')
|
|
677
|
+
return output_path
|
|
678
|
+
except subprocess.CalledProcessError as e:
|
|
679
|
+
_handle_ffmpeg_error(e)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _create_drawtext_params(
|
|
683
|
+
text: str,
|
|
684
|
+
font: str | None,
|
|
685
|
+
font_size: int,
|
|
686
|
+
color: str,
|
|
687
|
+
opacity: float,
|
|
688
|
+
horizontal_align: str,
|
|
689
|
+
horizontal_margin: int,
|
|
690
|
+
vertical_align: str,
|
|
691
|
+
vertical_margin: int,
|
|
692
|
+
box: bool,
|
|
693
|
+
box_color: str,
|
|
694
|
+
box_opacity: float,
|
|
695
|
+
box_border: list[int] | None,
|
|
696
|
+
) -> list[str]:
|
|
697
|
+
"""Construct parameters for the ffmpeg drawtext filter"""
|
|
698
|
+
drawtext_params: list[str] = []
|
|
699
|
+
escaped_text = text.replace('\\', '\\\\').replace(':', '\\:').replace("'", "\\'")
|
|
700
|
+
drawtext_params.append(f"text='{escaped_text}'")
|
|
701
|
+
drawtext_params.append(f'fontsize={font_size}')
|
|
702
|
+
|
|
703
|
+
if font is not None:
|
|
704
|
+
if pathlib.Path(font).exists():
|
|
705
|
+
drawtext_params.append(f"fontfile='{font}'")
|
|
706
|
+
else:
|
|
707
|
+
drawtext_params.append(f"font='{font}'")
|
|
708
|
+
if opacity < 1.0:
|
|
709
|
+
drawtext_params.append(f'fontcolor={color}@{opacity}')
|
|
710
|
+
else:
|
|
711
|
+
drawtext_params.append(f'fontcolor={color}')
|
|
712
|
+
|
|
713
|
+
if horizontal_align == 'left':
|
|
714
|
+
x_expr = str(horizontal_margin)
|
|
715
|
+
elif horizontal_align == 'center':
|
|
716
|
+
x_expr = '(w-text_w)/2'
|
|
717
|
+
else: # right
|
|
718
|
+
x_expr = f'w-text_w-{horizontal_margin}' if horizontal_margin != 0 else 'w-text_w'
|
|
719
|
+
if vertical_align == 'top':
|
|
720
|
+
y_expr = str(vertical_margin)
|
|
721
|
+
elif vertical_align == 'center':
|
|
722
|
+
y_expr = '(h-text_h)/2'
|
|
723
|
+
else: # bottom
|
|
724
|
+
y_expr = f'h-text_h-{vertical_margin}' if vertical_margin != 0 else 'h-text_h'
|
|
725
|
+
drawtext_params.extend([f'x={x_expr}', f'y={y_expr}'])
|
|
726
|
+
|
|
727
|
+
if box:
|
|
728
|
+
drawtext_params.append('box=1')
|
|
729
|
+
if box_opacity < 1.0:
|
|
730
|
+
drawtext_params.append(f'boxcolor={box_color}@{box_opacity}')
|
|
731
|
+
else:
|
|
732
|
+
drawtext_params.append(f'boxcolor={box_color}')
|
|
733
|
+
if box_border is not None:
|
|
734
|
+
drawtext_params.append(f'boxborderw={"|".join(map(str, box_border))}')
|
|
735
|
+
|
|
736
|
+
return drawtext_params
|
|
284
737
|
|
|
285
738
|
|
|
286
739
|
__all__ = local_public_names(__name__)
|
pixeltable/functions/whisper.py
CHANGED
|
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Optional, Sequence
|
|
|
10
10
|
|
|
11
11
|
import pixeltable as pxt
|
|
12
12
|
from pixeltable.env import Env
|
|
13
|
+
from pixeltable.utils.code import local_public_names
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
from whisper import Whisper # type: ignore[import-untyped]
|
|
@@ -90,3 +91,10 @@ def _lookup_model(model_id: str, device: str) -> 'Whisper':
|
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
_model_cache: dict[tuple[str, str], 'Whisper'] = {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = local_public_names(__name__)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def __dir__() -> list[str]:
|
|
100
|
+
return __all__
|
pixeltable/iterators/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ from .base import ComponentIterator
|
|
|
5
5
|
from .document import DocumentSplitter
|
|
6
6
|
from .image import TileIterator
|
|
7
7
|
from .string import StringSplitter
|
|
8
|
-
from .video import FrameIterator
|
|
8
|
+
from .video import FrameIterator, VideoSplitter
|
|
9
9
|
|
|
10
10
|
__default_dir = {symbol for symbol in dir() if not symbol.startswith('_')}
|
|
11
11
|
__removed_symbols = {'base', 'document', 'video'}
|
pixeltable/iterators/video.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import math
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
3
5
|
from fractions import Fraction
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
from typing import Any, Optional
|
|
@@ -8,8 +10,11 @@ import av
|
|
|
8
10
|
import pandas as pd
|
|
9
11
|
import PIL.Image
|
|
10
12
|
|
|
13
|
+
import pixeltable as pxt
|
|
11
14
|
import pixeltable.exceptions as excs
|
|
12
15
|
import pixeltable.type_system as ts
|
|
16
|
+
import pixeltable.utils.av as av_utils
|
|
17
|
+
from pixeltable.utils.media_store import TempStore
|
|
13
18
|
|
|
14
19
|
from .base import ComponentIterator
|
|
15
20
|
|
|
@@ -224,3 +229,136 @@ class FrameIterator(ComponentIterator):
|
|
|
224
229
|
# then the iterator will step forward to the desired frame on the subsequent call to next().
|
|
225
230
|
self.container.seek(seek_pos, backward=True, stream=self.container.streams.video[0])
|
|
226
231
|
self.next_pos = pos
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class VideoSplitter(ComponentIterator):
|
|
235
|
+
"""
|
|
236
|
+
Iterator over segments of a video file, which is split into fixed-size segments of length `segment_duration`
|
|
237
|
+
seconds.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
segment_duration: Video segment duration in seconds
|
|
241
|
+
overlap: Overlap between consecutive segments in seconds.
|
|
242
|
+
min_segment_duration: Drop the last segment if it is smaller than min_segment_duration
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# Input parameters
|
|
246
|
+
video_path: Path
|
|
247
|
+
segment_duration: float
|
|
248
|
+
overlap: float
|
|
249
|
+
min_segment_duration: float
|
|
250
|
+
|
|
251
|
+
# Video metadata
|
|
252
|
+
video_duration: float
|
|
253
|
+
video_time_base: Fraction
|
|
254
|
+
video_start_time: int
|
|
255
|
+
|
|
256
|
+
# position tracking
|
|
257
|
+
next_segment_start: float
|
|
258
|
+
next_segment_start_pts: int
|
|
259
|
+
|
|
260
|
+
def __init__(self, video: str, segment_duration: float, *, overlap: float = 0.0, min_segment_duration: float = 0.0):
|
|
261
|
+
assert segment_duration > 0.0
|
|
262
|
+
assert segment_duration >= min_segment_duration
|
|
263
|
+
assert overlap < segment_duration
|
|
264
|
+
|
|
265
|
+
video_path = Path(video)
|
|
266
|
+
assert video_path.exists() and video_path.is_file()
|
|
267
|
+
|
|
268
|
+
if not shutil.which('ffmpeg'):
|
|
269
|
+
raise pxt.Error('ffmpeg is not installed or not in PATH. Please install ffmpeg to use VideoSplitter.')
|
|
270
|
+
|
|
271
|
+
self.video_path = video_path
|
|
272
|
+
self.segment_duration = segment_duration
|
|
273
|
+
self.overlap = overlap
|
|
274
|
+
self.min_segment_duration = min_segment_duration
|
|
275
|
+
|
|
276
|
+
with av.open(str(video_path)) as container:
|
|
277
|
+
video_stream = container.streams.video[0]
|
|
278
|
+
self.video_time_base = video_stream.time_base
|
|
279
|
+
self.video_start_time = video_stream.start_time or 0
|
|
280
|
+
|
|
281
|
+
self.next_segment_start = float(self.video_start_time * self.video_time_base)
|
|
282
|
+
self.next_segment_start_pts = self.video_start_time
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def input_schema(cls) -> dict[str, ts.ColumnType]:
|
|
286
|
+
return {
|
|
287
|
+
'video': ts.VideoType(nullable=False),
|
|
288
|
+
'segment_duration': ts.FloatType(nullable=False),
|
|
289
|
+
'overlap': ts.FloatType(nullable=True),
|
|
290
|
+
'min_segment_duration': ts.FloatType(nullable=True),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def output_schema(cls, *args: Any, **kwargs: Any) -> tuple[dict[str, ts.ColumnType], list[str]]:
|
|
295
|
+
param_names = ['segment_duration', 'overlap', 'min_segment_duration']
|
|
296
|
+
params = dict(zip(param_names, args))
|
|
297
|
+
params.update(kwargs)
|
|
298
|
+
|
|
299
|
+
segment_duration = params['segment_duration']
|
|
300
|
+
min_segment_duration = params.get('min_segment_duration', 0.0)
|
|
301
|
+
overlap = params.get('overlap', 0.0)
|
|
302
|
+
|
|
303
|
+
if segment_duration <= 0.0:
|
|
304
|
+
raise excs.Error('segment_duration must be a positive number')
|
|
305
|
+
if segment_duration < min_segment_duration:
|
|
306
|
+
raise excs.Error('segment_duration must be at least min_segment_duration')
|
|
307
|
+
if overlap >= segment_duration:
|
|
308
|
+
raise excs.Error('overlap must be less than segment_duration')
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
'segment_start': ts.FloatType(nullable=False),
|
|
312
|
+
'segment_start_pts': ts.IntType(nullable=False),
|
|
313
|
+
'segment_end': ts.FloatType(nullable=False),
|
|
314
|
+
'segment_end_pts': ts.IntType(nullable=False),
|
|
315
|
+
'video_segment': ts.VideoType(nullable=False),
|
|
316
|
+
}, []
|
|
317
|
+
|
|
318
|
+
def __next__(self) -> dict[str, Any]:
|
|
319
|
+
segment_path = str(TempStore.create_path(extension='.mp4'))
|
|
320
|
+
try:
|
|
321
|
+
cmd = av_utils.ffmpeg_clip_cmd(
|
|
322
|
+
str(self.video_path), segment_path, self.next_segment_start, self.segment_duration
|
|
323
|
+
)
|
|
324
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
325
|
+
|
|
326
|
+
# use the actual duration
|
|
327
|
+
segment_duration = av_utils.get_video_duration(segment_path)
|
|
328
|
+
if segment_duration - self.overlap == 0.0:
|
|
329
|
+
# we're done
|
|
330
|
+
Path(segment_path).unlink()
|
|
331
|
+
raise StopIteration
|
|
332
|
+
|
|
333
|
+
if segment_duration < self.min_segment_duration:
|
|
334
|
+
Path(segment_path).unlink()
|
|
335
|
+
raise StopIteration
|
|
336
|
+
|
|
337
|
+
segment_end = self.next_segment_start + segment_duration
|
|
338
|
+
segment_end_pts = self.next_segment_start_pts + round(segment_duration / self.video_time_base)
|
|
339
|
+
|
|
340
|
+
result = {
|
|
341
|
+
'segment_start': self.next_segment_start,
|
|
342
|
+
'segment_start_pts': self.next_segment_start_pts,
|
|
343
|
+
'segment_end': segment_end,
|
|
344
|
+
'segment_end_pts': segment_end_pts,
|
|
345
|
+
'video_segment': segment_path,
|
|
346
|
+
}
|
|
347
|
+
self.next_segment_start = segment_end - self.overlap
|
|
348
|
+
self.next_segment_start_pts = segment_end_pts - round(self.overlap / self.video_time_base)
|
|
349
|
+
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
except subprocess.CalledProcessError as e:
|
|
353
|
+
if Path(segment_path).exists():
|
|
354
|
+
Path(segment_path).unlink()
|
|
355
|
+
error_msg = f'ffmpeg failed with return code {e.returncode}'
|
|
356
|
+
if e.stderr:
|
|
357
|
+
error_msg += f': {e.stderr.strip()}'
|
|
358
|
+
raise pxt.Error(error_msg) from e
|
|
359
|
+
|
|
360
|
+
def close(self) -> None:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
def set_pos(self, pos: int) -> None:
|
|
364
|
+
pass
|
pixeltable/utils/av.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import av
|
|
4
|
+
import av.stream
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_metadata(path: str) -> dict:
|
|
8
|
+
with av.open(path) as container:
|
|
9
|
+
assert isinstance(container, av.container.InputContainer)
|
|
10
|
+
streams_info = [__get_stream_metadata(stream) for stream in container.streams]
|
|
11
|
+
result = {
|
|
12
|
+
'bit_exact': getattr(container, 'bit_exact', False),
|
|
13
|
+
'bit_rate': container.bit_rate,
|
|
14
|
+
'size': container.size,
|
|
15
|
+
'metadata': container.metadata,
|
|
16
|
+
'streams': streams_info,
|
|
17
|
+
}
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def __get_stream_metadata(stream: av.stream.Stream) -> dict:
|
|
22
|
+
if stream.type not in ('audio', 'video'):
|
|
23
|
+
return {'type': stream.type} # Currently unsupported
|
|
24
|
+
|
|
25
|
+
codec_context = stream.codec_context
|
|
26
|
+
codec_context_md: dict[str, Any] = {
|
|
27
|
+
'name': codec_context.name,
|
|
28
|
+
'codec_tag': codec_context.codec_tag.encode('unicode-escape').decode('utf-8'),
|
|
29
|
+
'profile': codec_context.profile,
|
|
30
|
+
}
|
|
31
|
+
metadata = {
|
|
32
|
+
'type': stream.type,
|
|
33
|
+
'duration': stream.duration,
|
|
34
|
+
'time_base': float(stream.time_base) if stream.time_base is not None else None,
|
|
35
|
+
'duration_seconds': float(stream.duration * stream.time_base)
|
|
36
|
+
if stream.duration is not None and stream.time_base is not None
|
|
37
|
+
else None,
|
|
38
|
+
'frames': stream.frames,
|
|
39
|
+
'metadata': stream.metadata,
|
|
40
|
+
'codec_context': codec_context_md,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if stream.type == 'audio':
|
|
44
|
+
# Additional metadata for audio
|
|
45
|
+
channels = getattr(stream.codec_context, 'channels', None)
|
|
46
|
+
codec_context_md['channels'] = int(channels) if channels is not None else None
|
|
47
|
+
else:
|
|
48
|
+
assert stream.type == 'video'
|
|
49
|
+
assert isinstance(stream, av.video.stream.VideoStream)
|
|
50
|
+
# Additional metadata for video
|
|
51
|
+
codec_context_md['pix_fmt'] = getattr(stream.codec_context, 'pix_fmt', None)
|
|
52
|
+
metadata.update(
|
|
53
|
+
**{
|
|
54
|
+
'width': stream.width,
|
|
55
|
+
'height': stream.height,
|
|
56
|
+
'frames': stream.frames,
|
|
57
|
+
'average_rate': float(stream.average_rate) if stream.average_rate is not None else None,
|
|
58
|
+
'base_rate': float(stream.base_rate) if stream.base_rate is not None else None,
|
|
59
|
+
'guessed_rate': float(stream.guessed_rate) if stream.guessed_rate is not None else None,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return metadata
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_video_duration(path: str) -> float | None:
|
|
67
|
+
"""Return video duration in seconds."""
|
|
68
|
+
with av.open(path) as container:
|
|
69
|
+
video_stream = container.streams.video[0]
|
|
70
|
+
if video_stream is None:
|
|
71
|
+
return None
|
|
72
|
+
if video_stream.duration is not None:
|
|
73
|
+
return float(video_stream.duration * video_stream.time_base)
|
|
74
|
+
|
|
75
|
+
# if duration is not in the header, look for it in the last packet
|
|
76
|
+
last_pts: int | None = None
|
|
77
|
+
for packet in container.demux(video_stream):
|
|
78
|
+
if packet.pts is not None:
|
|
79
|
+
last_pts = packet.pts
|
|
80
|
+
if last_pts is not None:
|
|
81
|
+
return float(last_pts * video_stream.time_base)
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def has_audio_stream(path: str) -> bool:
|
|
87
|
+
"""Check if video has audio stream using PyAV."""
|
|
88
|
+
md = get_metadata(path)
|
|
89
|
+
return any(stream['type'] == 'audio' for stream in md['streams'])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ffmpeg_clip_cmd(input_path: str, output_path: str, start_time: float, duration: float | None = None) -> list[str]:
|
|
93
|
+
# the order of arguments is critical: -ss <start> -t <duration> -i <input>
|
|
94
|
+
cmd = ['ffmpeg', '-ss', str(start_time)]
|
|
95
|
+
if duration is not None:
|
|
96
|
+
cmd.extend(['-t', str(duration)])
|
|
97
|
+
cmd.extend(
|
|
98
|
+
[
|
|
99
|
+
'-i', # Input file
|
|
100
|
+
input_path,
|
|
101
|
+
'-y', # Overwrite output file
|
|
102
|
+
'-loglevel',
|
|
103
|
+
'error', # Only show errors
|
|
104
|
+
'-c',
|
|
105
|
+
'copy', # Stream copy (no re-encoding)
|
|
106
|
+
'-map',
|
|
107
|
+
'0', # Copy all streams from input
|
|
108
|
+
output_path,
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
return cmd
|
pixeltable/utils/code.py
CHANGED
|
@@ -21,7 +21,8 @@ def local_public_names(mod_name: str, exclude: Optional[list[str]] = None) -> li
|
|
|
21
21
|
for obj in mod.__dict__.values():
|
|
22
22
|
if isinstance(obj, Function):
|
|
23
23
|
# Pixeltable function
|
|
24
|
-
|
|
24
|
+
if not obj.name.startswith('_'):
|
|
25
|
+
names.append(obj.name)
|
|
25
26
|
elif isinstance(obj, types.FunctionType):
|
|
26
27
|
# Python function
|
|
27
28
|
if obj.__module__ == mod.__name__ and not obj.__name__.startswith('_'):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pixeltable
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.11
|
|
4
4
|
Summary: AI Data Infrastructure: Declarative, Multimodal, and Incremental
|
|
5
5
|
Project-URL: homepage, https://pixeltable.com/
|
|
6
6
|
Project-URL: repository, https://github.com/pixeltable/pixeltable
|
|
@@ -2,7 +2,7 @@ pixeltable/__init__.py,sha256=wJ_4oQdkBAaaVKM8XiZKKSsWPnoemZxh34o6_5vDcxk,1562
|
|
|
2
2
|
pixeltable/__version__.py,sha256=LnMIuAxx6nAQDMev_jnZyUdgsaiE3F8lulfXQBRl9qQ,112
|
|
3
3
|
pixeltable/config.py,sha256=-aoSVF0Aak83IC-u-XANw3if76TDq5VnnWNWoFDR5Hc,8390
|
|
4
4
|
pixeltable/dataframe.py,sha256=I6iEJGD4pivUN-cPVFq_rcniZN7C55xpr37sMJ2BIdE,62986
|
|
5
|
-
pixeltable/env.py,sha256=
|
|
5
|
+
pixeltable/env.py,sha256=vmqDgsfonYLYubsR1N4n5H7aSo4MXtlnBN1Z8xFOeFI,44443
|
|
6
6
|
pixeltable/exceptions.py,sha256=Gm8d3TL2iiv6Pj2DLd29wp_j41qNBhxXL9iTQnL4Nk4,1116
|
|
7
7
|
pixeltable/globals.py,sha256=8NijkEmtjY5me6J8zF4G-t1v5_z4q7btOK2yjUREUak,39118
|
|
8
8
|
pixeltable/plan.py,sha256=4yAe7ExAqaSvkFxwK7LPH_HpmoumwqoLeOo7czJ8CyQ,48001
|
|
@@ -83,12 +83,12 @@ pixeltable/func/tools.py,sha256=2_M_u0Jiy5-uToZziB4O54aTuJeaytPmh71q3I2ydNw,6062
|
|
|
83
83
|
pixeltable/func/udf.py,sha256=6tKpMt37t3BmXwRyA5fFAd6OM4D5EPEd2KuAr7DQhr0,13231
|
|
84
84
|
pixeltable/functions/__init__.py,sha256=ZeRB7ksbzjdrvePXtd_mNxyP2RhjvN0ayl5nv7TdWcQ,613
|
|
85
85
|
pixeltable/functions/anthropic.py,sha256=2Ja-pryC_3Yd1sXW-pibRuvKjgyfYqOhhl6nBWNOBt0,10504
|
|
86
|
-
pixeltable/functions/audio.py,sha256=
|
|
86
|
+
pixeltable/functions/audio.py,sha256=S9xSg45Fx5kmB4NxOTSG99_5Kxc8kFfxuawV7qjMeS8,1660
|
|
87
87
|
pixeltable/functions/bedrock.py,sha256=lTCFHjYunF3minBGWcjXR90yJ8resFjXr4niyKhfxms,4217
|
|
88
88
|
pixeltable/functions/date.py,sha256=qs1svJ9FVod3OTa5hQNKIuashb6tVhW_2EAEXYGQX74,5308
|
|
89
89
|
pixeltable/functions/deepseek.py,sha256=iw59TKKcw3VqbHMHB2ugtcTPeTVKuHp_3pfkjF6DYmE,3550
|
|
90
90
|
pixeltable/functions/fireworks.py,sha256=q7eWlYfiWbA0d9r3CB_NAe1fw3q-Z7qsw2gyGJNgWLQ,4786
|
|
91
|
-
pixeltable/functions/gemini.py,sha256=
|
|
91
|
+
pixeltable/functions/gemini.py,sha256=igtpGBiVekkaWtVE6X04pQ7C9md8nY42W7xU_XuMayE,8924
|
|
92
92
|
pixeltable/functions/globals.py,sha256=OyPJUJ4S6VWyzxstxIzk3xzYBGIEMwgk1RmSTWTZzdI,5106
|
|
93
93
|
pixeltable/functions/groq.py,sha256=FpR_LJpfZfzyhEvoBMMbQpQ-VQSRzBsS9U21qaINwww,3593
|
|
94
94
|
pixeltable/functions/huggingface.py,sha256=Y-io3EungSs5ibr43vLEXs4dz_Ej20F1nglD0fyLrXA,20371
|
|
@@ -104,9 +104,9 @@ pixeltable/functions/string.py,sha256=LdBNOna5PUSPmM5VlJ_qhmwzyFhumW0k6Dvx2rXSZt
|
|
|
104
104
|
pixeltable/functions/timestamp.py,sha256=3GVCVIWdry96Qk5XXuvbJ58Tp30iY5snBibzl2CHjQc,9143
|
|
105
105
|
pixeltable/functions/together.py,sha256=A8J19BXywyWQ6a2_n05-8uIG5jquOBGqPmW3mb-NrIc,8842
|
|
106
106
|
pixeltable/functions/util.py,sha256=uQNkyBSkTVMe1wbUI2Q0nz-mM3qPVTF86yK8c9OFIcE,954
|
|
107
|
-
pixeltable/functions/video.py,sha256=
|
|
107
|
+
pixeltable/functions/video.py,sha256=0Hgfi3PHA2BPVpgWEQ3RffFtvc2YkjX3UX3dXSzrEJk,27009
|
|
108
108
|
pixeltable/functions/vision.py,sha256=17h9bOm3NJyQzFMBwXDHMqnkcuCspyQJgHdBOXV1Ip8,15380
|
|
109
|
-
pixeltable/functions/whisper.py,sha256=
|
|
109
|
+
pixeltable/functions/whisper.py,sha256=u2QcDU7JdtgLIImCkFPkzjWEjLTJIrlSkAWqeITyIJw,3103
|
|
110
110
|
pixeltable/functions/whisperx.py,sha256=BT9gwXEf5V1lgDxynkrrH6gsuCLqjCzfMJKj5DaOtSM,7661
|
|
111
111
|
pixeltable/functions/yolox.py,sha256=ZdYr6WIqTCHOJoZSoXe4CbME54dYeeeOhkOi1I7VtcE,3518
|
|
112
112
|
pixeltable/index/__init__.py,sha256=97aFuxiP_oz1ldn5iq8IWApkOV8XG6ZIBW5-9rkS0vM,122
|
|
@@ -124,13 +124,13 @@ pixeltable/io/pandas.py,sha256=xQmkwbqE9_fjbbPUgeG5yNICrbVVK73UHxDL-cgrQw0,9007
|
|
|
124
124
|
pixeltable/io/parquet.py,sha256=qoVDuCoW-Tq14IlzN_psoNP7z83hIQ3ZEg_pKzHSqoY,7796
|
|
125
125
|
pixeltable/io/table_data_conduit.py,sha256=--UWwG6agBtOA5PLPfjxp2XKoAQ-f5nSPJqOgA5DAAI,22062
|
|
126
126
|
pixeltable/io/utils.py,sha256=qzBTmqdIawXMt2bfXQOraYnEstL69eC2Z33nl8RrwJk,4244
|
|
127
|
-
pixeltable/iterators/__init__.py,sha256=
|
|
127
|
+
pixeltable/iterators/__init__.py,sha256=hI937cmBRU3eWbfJ7miFthAGUo_xmcYciw6gAjOCg9g,470
|
|
128
128
|
pixeltable/iterators/audio.py,sha256=HYE8JcqaJsTGdrq4NkwV5tn7lcyMp6Fjrm59efOLzb0,9671
|
|
129
129
|
pixeltable/iterators/base.py,sha256=ZC0ZvXL4iw6AmT8cu-Mdx-T2UG9nmJYV1C6LK4efAfw,1669
|
|
130
130
|
pixeltable/iterators/document.py,sha256=7NIN5W5jHVm4v5_FzGsH0XJigtPCm8DfXJUc3_hEtHQ,20073
|
|
131
131
|
pixeltable/iterators/image.py,sha256=RrFdf5cnFIQzWKJk4uYi1m1p2qAiz909THYhRQ27DbY,3603
|
|
132
132
|
pixeltable/iterators/string.py,sha256=URj5edWp-CsorjN_8nnfWGvtIFs_Zh4VPm6htlJbFkU,1257
|
|
133
|
-
pixeltable/iterators/video.py,sha256=
|
|
133
|
+
pixeltable/iterators/video.py,sha256=aKT2YxZGUsAifkWK434RDnqZj_gGtcQ1waN9AV98fMA,16105
|
|
134
134
|
pixeltable/metadata/__init__.py,sha256=oTO9kN6h4xJ2lsk4a2bq6ejAD-4wToy7b5_i3Pq1Qnc,3289
|
|
135
135
|
pixeltable/metadata/notes.py,sha256=3fdZDFpL1-b194Ejv0Y0YP-vbnV-XvVP9wOmZM9XARA,1545
|
|
136
136
|
pixeltable/metadata/schema.py,sha256=fs9W2SLh32Ehxc9AChVH7YCtlSSnQkgGMbEyOh0B4W0,13416
|
|
@@ -172,8 +172,9 @@ pixeltable/share/packager.py,sha256=5rSKnQCs3YP5h48d79bXEK4L8tLUSeTSbXaB8X9SmBI,
|
|
|
172
172
|
pixeltable/share/publish.py,sha256=KS_R59AuVkHWkXHwELP74xgSHs8Z5z8SBPMcjzttt44,11469
|
|
173
173
|
pixeltable/utils/__init__.py,sha256=45qEM20L2VuIe-Cc3BTKWFqQb-S7A8qDtmmgl77zYK0,1728
|
|
174
174
|
pixeltable/utils/arrow.py,sha256=Rooa02GL5k--D2utlKATtYKrrlsHbbi6JmkarXMux1M,6384
|
|
175
|
+
pixeltable/utils/av.py,sha256=omJufz62dzaTTwlR7quKfcT7apf8KkBLJ9cQ9240dt0,4016
|
|
175
176
|
pixeltable/utils/coco.py,sha256=Y1DWVYguZD4VhKyf7JruYfHWvhkJLq39fzbiSm5cdyY,7304
|
|
176
|
-
pixeltable/utils/code.py,sha256=
|
|
177
|
+
pixeltable/utils/code.py,sha256=3CZMVJm69JIG5sxmd56mjB4Fo4L-s0_Y8YvQeJIj0F0,1280
|
|
177
178
|
pixeltable/utils/console_output.py,sha256=x23iDnNwUbsr7Ec20BQ7BLATTsrQZflxc9NucAt_sVU,1150
|
|
178
179
|
pixeltable/utils/coroutine.py,sha256=d87kLlkVIZq2u0kTE7kJ5Tc_yjEkdGi5sXAuxjLLxXY,896
|
|
179
180
|
pixeltable/utils/dbms.py,sha256=cuQqlzLF7WON_mkJZ4QWlfX6lCxA97V32lhtMcOlDLg,2018
|
|
@@ -190,8 +191,8 @@ pixeltable/utils/pytorch.py,sha256=564VHRdDHwD9h0v5lBHEDTJ8c6zx8wuzWYx8ZYjBxlI,3
|
|
|
190
191
|
pixeltable/utils/s3.py,sha256=pxip2MlCqd2Qon2dzJXzfxvwtZyc-BAsjAnLL4J_OXY,587
|
|
191
192
|
pixeltable/utils/sql.py,sha256=Sa4Lh-VGe8GToU5W7DRiWf2lMl9B6saPqemiT0ZdHEc,806
|
|
192
193
|
pixeltable/utils/transactional_directory.py,sha256=OFKmu90oP7KwBAljwjnzP_w8euGdAXob3y4Nx9SCNHA,1357
|
|
193
|
-
pixeltable-0.4.
|
|
194
|
-
pixeltable-0.4.
|
|
195
|
-
pixeltable-0.4.
|
|
196
|
-
pixeltable-0.4.
|
|
197
|
-
pixeltable-0.4.
|
|
194
|
+
pixeltable-0.4.11.dist-info/METADATA,sha256=HVVzENixWm3Kc_3PBsO-sDo7M3LF8IpNwwcuU4bUvQk,24248
|
|
195
|
+
pixeltable-0.4.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
196
|
+
pixeltable-0.4.11.dist-info/entry_points.txt,sha256=rrKugZmxDtGnXCnEQ5UJMaaSYY7-g1cLjUZ4W1moIhM,98
|
|
197
|
+
pixeltable-0.4.11.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
198
|
+
pixeltable-0.4.11.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|