pixeltable 0.4.8__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.

@@ -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__)