pixeltable 0.4.16__py3-none-any.whl → 0.4.18__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/catalog/catalog.py +47 -32
- pixeltable/catalog/table.py +33 -14
- pixeltable/catalog/table_version.py +86 -46
- pixeltable/catalog/table_version_path.py +0 -11
- pixeltable/catalog/view.py +6 -0
- pixeltable/config.py +1 -0
- pixeltable/dataframe.py +1 -1
- pixeltable/env.py +12 -0
- pixeltable/exec/exec_context.py +15 -2
- pixeltable/exec/sql_node.py +3 -2
- pixeltable/exprs/arithmetic_expr.py +13 -7
- pixeltable/functions/huggingface.py +1031 -2
- pixeltable/functions/video.py +140 -31
- pixeltable/globals.py +23 -4
- pixeltable/io/globals.py +2 -2
- pixeltable/io/parquet.py +1 -1
- pixeltable/io/table_data_conduit.py +1 -1
- pixeltable/iterators/document.py +111 -42
- pixeltable/iterators/video.py +169 -62
- pixeltable/plan.py +2 -6
- pixeltable/share/packager.py +155 -26
- pixeltable/store.py +25 -5
- pixeltable/utils/arrow.py +6 -6
- pixeltable/utils/av.py +104 -11
- pixeltable/utils/object_stores.py +16 -1
- pixeltable/utils/s3_store.py +44 -11
- {pixeltable-0.4.16.dist-info → pixeltable-0.4.18.dist-info}/METADATA +30 -30
- {pixeltable-0.4.16.dist-info → pixeltable-0.4.18.dist-info}/RECORD +31 -31
- {pixeltable-0.4.16.dist-info → pixeltable-0.4.18.dist-info}/WHEEL +0 -0
- {pixeltable-0.4.16.dist-info → pixeltable-0.4.18.dist-info}/entry_points.txt +0 -0
- {pixeltable-0.4.16.dist-info → pixeltable-0.4.18.dist-info}/licenses/LICENSE +0 -0
pixeltable/iterators/video.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import glob
|
|
1
2
|
import logging
|
|
2
3
|
import math
|
|
3
|
-
import shutil
|
|
4
4
|
import subprocess
|
|
5
5
|
from fractions import Fraction
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Optional
|
|
7
|
+
from typing import Any, Iterator, Literal, Optional
|
|
8
8
|
|
|
9
9
|
import av
|
|
10
10
|
import pandas as pd
|
|
@@ -14,6 +14,7 @@ import pixeltable as pxt
|
|
|
14
14
|
import pixeltable.exceptions as excs
|
|
15
15
|
import pixeltable.type_system as ts
|
|
16
16
|
import pixeltable.utils.av as av_utils
|
|
17
|
+
from pixeltable.env import Env
|
|
17
18
|
from pixeltable.utils.local_store import TempStore
|
|
18
19
|
|
|
19
20
|
from .base import ComponentIterator
|
|
@@ -237,75 +238,120 @@ class VideoSplitter(ComponentIterator):
|
|
|
237
238
|
seconds.
|
|
238
239
|
|
|
239
240
|
Args:
|
|
240
|
-
|
|
241
|
-
overlap: Overlap between consecutive segments in seconds.
|
|
242
|
-
min_segment_duration: Drop the last segment if it is smaller than min_segment_duration
|
|
241
|
+
duration: Video segment duration in seconds
|
|
242
|
+
overlap: Overlap between consecutive segments in seconds. Only available for `mode='fast'`.
|
|
243
|
+
min_segment_duration: Drop the last segment if it is smaller than min_segment_duration.
|
|
244
|
+
mode: Segmentation mode:
|
|
245
|
+
- `'fast'`: Quick segmentation using stream copy (splits only at keyframes, approximate durations)
|
|
246
|
+
- `'accurate'`: Precise segmentation with re-encoding (exact durations, slower)
|
|
247
|
+
video_encoder: Video encoder to use. If not specified, uses the default encoder for the current platform.
|
|
248
|
+
Only available for `mode='accurate'`.
|
|
249
|
+
video_encoder_args: Additional arguments to pass to the video encoder. Only available for `mode='accurate'`.
|
|
243
250
|
"""
|
|
244
251
|
|
|
245
252
|
# Input parameters
|
|
246
253
|
video_path: Path
|
|
247
|
-
segment_duration: float
|
|
254
|
+
segment_duration: float | None
|
|
255
|
+
segment_times: list[float] | None
|
|
248
256
|
overlap: float
|
|
249
257
|
min_segment_duration: float
|
|
258
|
+
video_encoder: str | None
|
|
259
|
+
video_encoder_args: dict[str, Any] | None
|
|
250
260
|
|
|
251
261
|
# Video metadata
|
|
252
262
|
video_duration: float
|
|
253
263
|
video_time_base: Fraction
|
|
254
264
|
video_start_time: int
|
|
255
265
|
|
|
256
|
-
|
|
257
|
-
next_segment_start: float
|
|
258
|
-
next_segment_start_pts: int
|
|
266
|
+
output_iter: Iterator[dict[str, Any]]
|
|
259
267
|
|
|
260
|
-
def __init__(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
video: str,
|
|
271
|
+
*,
|
|
272
|
+
duration: float | None = None,
|
|
273
|
+
overlap: float | None = None,
|
|
274
|
+
min_segment_duration: float | None = None,
|
|
275
|
+
segment_times: list[float] | None = None,
|
|
276
|
+
mode: Literal['fast', 'accurate'] = 'accurate',
|
|
277
|
+
video_encoder: str | None = None,
|
|
278
|
+
video_encoder_args: dict[str, Any] | None = None,
|
|
279
|
+
):
|
|
280
|
+
Env.get().require_binary('ffmpeg')
|
|
281
|
+
assert (duration is not None) != (segment_times is not None)
|
|
282
|
+
if segment_times is not None:
|
|
283
|
+
assert len(segment_times) > 0
|
|
284
|
+
if duration is not None:
|
|
285
|
+
assert duration > 0.0
|
|
286
|
+
assert duration >= min_segment_duration
|
|
287
|
+
assert overlap is None or overlap < duration
|
|
264
288
|
|
|
265
289
|
video_path = Path(video)
|
|
266
290
|
assert video_path.exists() and video_path.is_file()
|
|
267
291
|
|
|
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
292
|
self.video_path = video_path
|
|
272
|
-
self.segment_duration =
|
|
273
|
-
self.overlap = overlap
|
|
274
|
-
self.min_segment_duration = min_segment_duration
|
|
293
|
+
self.segment_duration = duration
|
|
294
|
+
self.overlap = overlap if overlap is not None else 0.0
|
|
295
|
+
self.min_segment_duration = min_segment_duration if min_segment_duration is not None else 0.0
|
|
296
|
+
self.segment_times = segment_times
|
|
297
|
+
self.video_encoder = video_encoder
|
|
298
|
+
self.video_encoder_args = video_encoder_args
|
|
275
299
|
|
|
276
300
|
with av.open(str(video_path)) as container:
|
|
277
301
|
video_stream = container.streams.video[0]
|
|
278
302
|
self.video_time_base = video_stream.time_base
|
|
279
303
|
self.video_start_time = video_stream.start_time or 0
|
|
280
304
|
|
|
281
|
-
self.
|
|
282
|
-
self.next_segment_start_pts = self.video_start_time
|
|
305
|
+
self.output_iter = self.fast_iter() if mode == 'fast' else self.accurate_iter()
|
|
283
306
|
|
|
284
307
|
@classmethod
|
|
285
308
|
def input_schema(cls) -> dict[str, ts.ColumnType]:
|
|
286
309
|
return {
|
|
287
310
|
'video': ts.VideoType(nullable=False),
|
|
288
|
-
'
|
|
311
|
+
'duration': ts.FloatType(nullable=True),
|
|
289
312
|
'overlap': ts.FloatType(nullable=True),
|
|
290
313
|
'min_segment_duration': ts.FloatType(nullable=True),
|
|
314
|
+
'segment_times': ts.JsonType(nullable=True),
|
|
315
|
+
'mode': ts.StringType(nullable=False),
|
|
316
|
+
'video_encoder': ts.StringType(nullable=True),
|
|
317
|
+
'video_encoder_args': ts.JsonType(nullable=True),
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
@classmethod
|
|
294
321
|
def output_schema(cls, *args: Any, **kwargs: Any) -> tuple[dict[str, ts.ColumnType], list[str]]:
|
|
295
|
-
param_names = ['
|
|
322
|
+
param_names = ['duration', 'overlap', 'min_segment_duration', 'segment_times']
|
|
296
323
|
params = dict(zip(param_names, args))
|
|
297
324
|
params.update(kwargs)
|
|
298
325
|
|
|
299
|
-
segment_duration = params
|
|
300
|
-
|
|
301
|
-
overlap = params.get('overlap'
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if segment_duration
|
|
306
|
-
raise excs.Error('
|
|
307
|
-
if
|
|
308
|
-
raise excs.Error('
|
|
326
|
+
segment_duration = params.get('duration')
|
|
327
|
+
segment_times = params.get('segment_times')
|
|
328
|
+
overlap = params.get('overlap')
|
|
329
|
+
min_segment_duration = params.get('min_segment_duration')
|
|
330
|
+
mode = params.get('mode', 'fast')
|
|
331
|
+
|
|
332
|
+
if segment_duration is None and segment_times is None:
|
|
333
|
+
raise excs.Error('Must specify either duration or segment_times')
|
|
334
|
+
if segment_duration is not None and segment_times is not None:
|
|
335
|
+
raise excs.Error('duration and segment_times cannot both be specified')
|
|
336
|
+
if segment_times is not None:
|
|
337
|
+
if len(segment_times) == 0:
|
|
338
|
+
raise excs.Error('segment_times cannot be empty')
|
|
339
|
+
if overlap is not None:
|
|
340
|
+
raise excs.Error('overlap cannot be specified with segment_times')
|
|
341
|
+
if segment_duration is not None:
|
|
342
|
+
if segment_duration <= 0.0:
|
|
343
|
+
raise excs.Error('duration must be a positive number')
|
|
344
|
+
if min_segment_duration is not None and segment_duration < min_segment_duration:
|
|
345
|
+
raise excs.Error('duration must be at least min_segment_duration')
|
|
346
|
+
if overlap is not None and overlap >= segment_duration:
|
|
347
|
+
raise excs.Error('overlap must be less than duration')
|
|
348
|
+
if mode == 'accurate' and overlap is not None:
|
|
349
|
+
raise excs.Error("Cannot specify overlap for mode='accurate'")
|
|
350
|
+
if mode == 'fast':
|
|
351
|
+
if params.get('video_encoder') is not None:
|
|
352
|
+
raise excs.Error("Cannot specify video_encoder for mode='fast'")
|
|
353
|
+
if params.get('video_encoder_args') is not None:
|
|
354
|
+
raise excs.Error("Cannot specify video_encoder_args for mode='fast'")
|
|
309
355
|
|
|
310
356
|
return {
|
|
311
357
|
'segment_start': ts.FloatType(nullable=False),
|
|
@@ -315,48 +361,109 @@ class VideoSplitter(ComponentIterator):
|
|
|
315
361
|
'video_segment': ts.VideoType(nullable=False),
|
|
316
362
|
}, []
|
|
317
363
|
|
|
318
|
-
def
|
|
319
|
-
segment_path =
|
|
364
|
+
def fast_iter(self) -> Iterator[dict[str, Any]]:
|
|
365
|
+
segment_path: str = ''
|
|
320
366
|
try:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
367
|
+
start_time = 0.0
|
|
368
|
+
start_pts = 0
|
|
369
|
+
segment_idx = 0
|
|
370
|
+
while True:
|
|
371
|
+
target_duration: float | None
|
|
372
|
+
if self.segment_duration is not None:
|
|
373
|
+
target_duration = self.segment_duration
|
|
374
|
+
elif self.segment_times is not None and segment_idx < len(self.segment_times):
|
|
375
|
+
target_duration = self.segment_times[segment_idx] - start_time
|
|
376
|
+
else:
|
|
377
|
+
target_duration = None # the rest of the video
|
|
378
|
+
|
|
379
|
+
segment_path = str(TempStore.create_path(extension='.mp4'))
|
|
380
|
+
cmd = av_utils.ffmpeg_clip_cmd(str(self.video_path), segment_path, start_time, target_duration)
|
|
381
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
382
|
+
|
|
383
|
+
# use the actual duration
|
|
384
|
+
segment_duration = av_utils.get_video_duration(segment_path)
|
|
385
|
+
if segment_duration - self.overlap == 0.0 or segment_duration < self.min_segment_duration:
|
|
386
|
+
# we're done
|
|
387
|
+
Path(segment_path).unlink()
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
segment_end = start_time + segment_duration
|
|
391
|
+
segment_end_pts = start_pts + round(segment_duration / self.video_time_base)
|
|
392
|
+
result = {
|
|
393
|
+
'segment_start': start_time,
|
|
394
|
+
'segment_start_pts': start_pts,
|
|
395
|
+
'segment_end': segment_end,
|
|
396
|
+
'segment_end_pts': segment_end_pts,
|
|
397
|
+
'video_segment': segment_path,
|
|
398
|
+
}
|
|
399
|
+
yield result
|
|
325
400
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if segment_duration - self.overlap == 0.0:
|
|
329
|
-
# we're done
|
|
330
|
-
Path(segment_path).unlink()
|
|
331
|
-
raise StopIteration
|
|
401
|
+
start_time = segment_end - self.overlap
|
|
402
|
+
start_pts = segment_end_pts - round(self.overlap / self.video_time_base)
|
|
332
403
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
404
|
+
segment_idx += 1
|
|
405
|
+
if self.segment_times is not None and segment_idx > len(self.segment_times):
|
|
406
|
+
# We've created all segments including the final segment after the last segment_time
|
|
407
|
+
break
|
|
336
408
|
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
except subprocess.CalledProcessError as e:
|
|
410
|
+
if segment_path and Path(segment_path).exists():
|
|
411
|
+
Path(segment_path).unlink()
|
|
412
|
+
error_msg = f'ffmpeg failed with return code {e.returncode}'
|
|
413
|
+
if e.stderr:
|
|
414
|
+
error_msg += f': {e.stderr.strip()}'
|
|
415
|
+
raise pxt.Error(error_msg) from e
|
|
339
416
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
417
|
+
def accurate_iter(self) -> Iterator[dict[str, Any]]:
|
|
418
|
+
base_path = TempStore.create_path(extension='')
|
|
419
|
+
# Use ffmpeg -f segment for accurate segmentation with re-encoding
|
|
420
|
+
output_pattern = f'{base_path}_segment_%04d.mp4'
|
|
421
|
+
cmd = av_utils.ffmpeg_segment_cmd(
|
|
422
|
+
str(self.video_path),
|
|
423
|
+
output_pattern,
|
|
424
|
+
segment_duration=self.segment_duration,
|
|
425
|
+
segment_times=self.segment_times,
|
|
426
|
+
video_encoder=self.video_encoder,
|
|
427
|
+
video_encoder_args=self.video_encoder_args,
|
|
428
|
+
)
|
|
349
429
|
|
|
350
|
-
|
|
430
|
+
try:
|
|
431
|
+
_ = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
432
|
+
output_paths = sorted(glob.glob(f'{base_path}_segment_*.mp4'))
|
|
433
|
+
# TODO: is this actually an error?
|
|
434
|
+
# if len(output_paths) == 0:
|
|
435
|
+
# stderr_output = result.stderr.strip() if result.stderr is not None else ''
|
|
436
|
+
# raise pxt.Error(
|
|
437
|
+
# f'ffmpeg failed to create output files for commandline: {" ".join(cmd)}\n{stderr_output}'
|
|
438
|
+
# )
|
|
439
|
+
start_time = 0.0
|
|
440
|
+
start_pts = 0
|
|
441
|
+
for segment_path in output_paths:
|
|
442
|
+
segment_duration = av_utils.get_video_duration(segment_path)
|
|
443
|
+
if segment_duration < self.min_segment_duration:
|
|
444
|
+
Path(segment_path).unlink()
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
result = {
|
|
448
|
+
'segment_start': start_time,
|
|
449
|
+
'segment_start_pts': start_pts,
|
|
450
|
+
'segment_end': start_time + segment_duration,
|
|
451
|
+
'segment_end_pts': start_pts + round(segment_duration / self.video_time_base),
|
|
452
|
+
'video_segment': segment_path,
|
|
453
|
+
}
|
|
454
|
+
yield result
|
|
455
|
+
start_time += segment_duration
|
|
456
|
+
start_pts += round(segment_duration / self.video_time_base)
|
|
351
457
|
|
|
352
458
|
except subprocess.CalledProcessError as e:
|
|
353
|
-
if Path(segment_path).exists():
|
|
354
|
-
Path(segment_path).unlink()
|
|
355
459
|
error_msg = f'ffmpeg failed with return code {e.returncode}'
|
|
356
460
|
if e.stderr:
|
|
357
461
|
error_msg += f': {e.stderr.strip()}'
|
|
358
462
|
raise pxt.Error(error_msg) from e
|
|
359
463
|
|
|
464
|
+
def __next__(self) -> dict[str, Any]:
|
|
465
|
+
return next(self.output_iter)
|
|
466
|
+
|
|
360
467
|
def close(self) -> None:
|
|
361
468
|
pass
|
|
362
469
|
|
pixeltable/plan.py
CHANGED
|
@@ -93,18 +93,13 @@ class SampleClause:
|
|
|
93
93
|
seed: Optional[int]
|
|
94
94
|
stratify_exprs: Optional[list[exprs.Expr]]
|
|
95
95
|
|
|
96
|
-
# This seed value is used if one is not supplied
|
|
97
|
-
DEFAULT_SEED = 0
|
|
98
|
-
|
|
99
96
|
# The version of the hashing algorithm used for ordering and fractional sampling.
|
|
100
97
|
CURRENT_VERSION = 1
|
|
101
98
|
|
|
102
99
|
def __post_init__(self) -> None:
|
|
103
|
-
|
|
100
|
+
# If no version was provided, provide the default version
|
|
104
101
|
if self.version is None:
|
|
105
102
|
self.version = self.CURRENT_VERSION
|
|
106
|
-
if self.seed is None:
|
|
107
|
-
self.seed = self.DEFAULT_SEED
|
|
108
103
|
|
|
109
104
|
@property
|
|
110
105
|
def is_stratified(self) -> bool:
|
|
@@ -1006,6 +1001,7 @@ class Planner:
|
|
|
1006
1001
|
analyzer.window_fn_calls
|
|
1007
1002
|
)
|
|
1008
1003
|
ctx = exec.ExecContext(row_builder)
|
|
1004
|
+
|
|
1009
1005
|
combined_ordering = cls._create_combined_ordering(analyzer, verify_agg=is_python_agg)
|
|
1010
1006
|
cls._verify_join_clauses(analyzer)
|
|
1011
1007
|
|