pixeltable 0.4.9__py3-none-any.whl → 0.4.10__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 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')
@@ -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 pxt.functions.video._get_metadata(audio)
51
+ return av_utils.get_metadata(audio)
51
52
 
52
53
 
53
54
  __all__ = local_public_names(__name__)
@@ -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__
@@ -2,16 +2,24 @@
2
2
  Pixeltable [UDFs](https://pixeltable.readme.io/docs/user-defined-functions-udfs) for `VideoType`.
3
3
  """
4
4
 
5
- from typing import Any, Optional
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. Default is 25. This is set when the aggregator is created.
53
+ fps: Frames per second for the output video.
50
54
 
51
55
  Returns:
52
56
 
53
- - A `pxt.Video` containing the created video file path.
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.rotated_frame)
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.rotated_frame)
92
+ ... make_video(frames_view.pos, frames_view.frame.rotate(30))
95
93
  ... ).show()
96
94
  """
97
95
 
98
- container: Optional[av.container.OutputContainer]
99
- stream: Optional[av.video.stream.VideoStream]
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
- """follows https://pyav.org/docs/develop/cookbook/numpy.html#generating-video"""
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: Optional[str] = None
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 for.
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 _get_metadata(video)
225
-
226
-
227
- def _get_metadata(path: str) -> dict:
228
- with av.open(path) as container:
229
- assert isinstance(container, av.container.InputContainer)
230
- streams_info = [__get_stream_metadata(stream) for stream in container.streams]
231
- result = {
232
- 'bit_exact': getattr(container, 'bit_exact', False),
233
- 'bit_rate': container.bit_rate,
234
- 'size': container.size,
235
- 'metadata': container.metadata,
236
- 'streams': streams_info,
237
- }
238
- return result
239
-
240
-
241
- def __get_stream_metadata(stream: av.stream.Stream) -> dict:
242
- if stream.type not in ('audio', 'video'):
243
- return {'type': stream.type} # Currently unsupported
244
-
245
- codec_context = stream.codec_context
246
- codec_context_md: dict[str, Any] = {
247
- 'name': codec_context.name,
248
- 'codec_tag': codec_context.codec_tag.encode('unicode-escape').decode('utf-8'),
249
- 'profile': codec_context.profile,
250
- }
251
- metadata = {
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
- return metadata
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__)
@@ -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__
@@ -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
- names.append(obj.name)
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.9
3
+ Version: 0.4.10
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=EZXZPx-OKNo-QqOik1tZyJKSK0brM_b3p2r9ksS6JJs,42964
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=6_tUhSZgxhOQQJemvZYNEoKNjWdr3SgJsvLkKCSmtfw,1633
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=beGMdpcL55mLaMMJkIL_TZrGdLJRL1RP-GkEuC6m8fs,8781
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=Z0X0Z-oCS-c4cqjlfCPLUxvTUAkQdxDZ-tL-jAIKKA0,10590
107
+ pixeltable/functions/video.py,sha256=0Hgfi3PHA2BPVpgWEQ3RffFtvc2YkjX3UX3dXSzrEJk,27009
108
108
  pixeltable/functions/vision.py,sha256=17h9bOm3NJyQzFMBwXDHMqnkcuCspyQJgHdBOXV1Ip8,15380
109
- pixeltable/functions/whisper.py,sha256=c9E6trhc2UcShVaGaEBCUEpArke1ql3MV5We0qSgmuU,2960
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
@@ -130,7 +130,7 @@ pixeltable/iterators/base.py,sha256=ZC0ZvXL4iw6AmT8cu-Mdx-T2UG9nmJYV1C6LK4efAfw,
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=PKztCS_FEtu-AoHR6X-wJG6UJddX195lS-9eQp5ClGc,10810
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=SbG5OUF_fQAbOgGZHDuENijmbzisVqa4VS9guaZ0KtU,1231
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.9.dist-info/METADATA,sha256=OvTlQgjU2P7wXoyAQhd8p4MrQU1jv5btGrtIHhRF9so,24247
194
- pixeltable-0.4.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
195
- pixeltable-0.4.9.dist-info/entry_points.txt,sha256=rrKugZmxDtGnXCnEQ5UJMaaSYY7-g1cLjUZ4W1moIhM,98
196
- pixeltable-0.4.9.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
197
- pixeltable-0.4.9.dist-info/RECORD,,
194
+ pixeltable-0.4.10.dist-info/METADATA,sha256=I3iYbF6fjvaQwBtbUNqF_KUbvzirqnv21npnqAJmxjc,24248
195
+ pixeltable-0.4.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
196
+ pixeltable-0.4.10.dist-info/entry_points.txt,sha256=rrKugZmxDtGnXCnEQ5UJMaaSYY7-g1cLjUZ4W1moIhM,98
197
+ pixeltable-0.4.10.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
198
+ pixeltable-0.4.10.dist-info/RECORD,,