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.

@@ -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
- 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
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
- # position tracking
257
- next_segment_start: float
258
- next_segment_start_pts: int
266
+ output_iter: Iterator[dict[str, Any]]
259
267
 
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
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 = 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.next_segment_start = float(self.video_start_time * self.video_time_base)
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
- 'segment_duration': ts.FloatType(nullable=False),
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 = ['segment_duration', 'overlap', 'min_segment_duration']
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['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')
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 __next__(self) -> dict[str, Any]:
319
- segment_path = str(TempStore.create_path(extension='.mp4'))
364
+ def fast_iter(self) -> Iterator[dict[str, Any]]:
365
+ segment_path: str = ''
320
366
  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)
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
- # 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
401
+ start_time = segment_end - self.overlap
402
+ start_pts = segment_end_pts - round(self.overlap / self.video_time_base)
332
403
 
333
- if segment_duration < self.min_segment_duration:
334
- Path(segment_path).unlink()
335
- raise StopIteration
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
- segment_end = self.next_segment_start + segment_duration
338
- segment_end_pts = self.next_segment_start_pts + round(segment_duration / self.video_time_base)
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
- 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)
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
- return result
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
- """If no version was provided, provide the default version"""
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