auto-editor 26.3.3__py3-none-any.whl → 27.0.0__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.
auto_editor/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "26.3.3"
1
+ __version__ = "27.0.0"
auto_editor/__main__.py CHANGED
@@ -76,6 +76,7 @@ class Args:
76
76
  # Audio Rendering
77
77
  audio_codec: str = "auto"
78
78
  audio_bitrate: str = "auto"
79
+ mix_audio_streams: bool = False
79
80
  keep_tracks_separate: bool = False
80
81
  audio_normalize: str = "#f"
81
82
 
@@ -341,10 +342,13 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
341
342
  metavar="BITRATE",
342
343
  help="Set the number of bits per second for audio",
343
344
  )
345
+ parser.add_argument(
346
+ "--mix-audio-streams", flag=True, help="Mix all audio streams together into one"
347
+ )
344
348
  parser.add_argument(
345
349
  "--keep-tracks-separate",
346
350
  flag=True,
347
- help="Don't mix all audio tracks into one when exporting",
351
+ help="Don't mix all audio streams into one when exporting (default)",
348
352
  )
349
353
  parser.add_argument(
350
354
  "--audio-normalize",
@@ -448,15 +452,15 @@ def main() -> None:
448
452
  if args.debug and not args.input:
449
453
  buf = StringIO()
450
454
  buf.write(f"OS: {plat.system()} {plat.release()} {plat.machine().lower()}\n")
451
- buf.write(f"Python: {plat.python_version()}\nPyAV: ")
455
+ buf.write(f"Python: {plat.python_version()}\nAV: ")
452
456
  try:
453
- import av
457
+ import bv
454
458
  except (ModuleNotFoundError, ImportError):
455
459
  buf.write("not found")
456
460
  else:
457
461
  try:
458
- buf.write(f"{av.__version__} ")
459
- license = av._core.library_meta["libavcodec"]["license"]
462
+ buf.write(f"{bv.__version__} ")
463
+ license = bv._core.library_meta["libavcodec"]["license"]
460
464
  buf.write(f"({license})")
461
465
  except AttributeError:
462
466
  buf.write("error")
auto_editor/analyze.py CHANGED
@@ -9,10 +9,10 @@ from math import ceil
9
9
  from tempfile import gettempdir
10
10
  from typing import TYPE_CHECKING
11
11
 
12
- import av
12
+ import bv
13
13
  import numpy as np
14
- from av.audio.fifo import AudioFifo
15
- from av.subtitles.subtitle import AssSubtitle
14
+ from bv.audio.fifo import AudioFifo
15
+ from bv.subtitles.subtitle import AssSubtitle
16
16
 
17
17
  from auto_editor import __version__
18
18
 
@@ -72,7 +72,7 @@ def mut_remove_large(
72
72
  active = False
73
73
 
74
74
 
75
- def iter_audio(audio_stream: av.AudioStream, tb: Fraction) -> Iterator[np.float32]:
75
+ def iter_audio(audio_stream: bv.AudioStream, tb: Fraction) -> Iterator[np.float32]:
76
76
  fifo = AudioFifo()
77
77
  sr = audio_stream.rate
78
78
 
@@ -80,10 +80,10 @@ def iter_audio(audio_stream: av.AudioStream, tb: Fraction) -> Iterator[np.float3
80
80
  accumulated_error = Fraction(0)
81
81
 
82
82
  # Resample so that audio data is between [-1, 1]
83
- resampler = av.AudioResampler(av.AudioFormat("flt"), audio_stream.layout, sr)
83
+ resampler = bv.AudioResampler(bv.AudioFormat("flt"), audio_stream.layout, sr)
84
84
 
85
85
  container = audio_stream.container
86
- assert isinstance(container, av.container.InputContainer)
86
+ assert isinstance(container, bv.container.InputContainer)
87
87
 
88
88
  for frame in container.decode(audio_stream):
89
89
  frame.pts = None # Skip time checks
@@ -103,7 +103,7 @@ def iter_audio(audio_stream: av.AudioStream, tb: Fraction) -> Iterator[np.float3
103
103
 
104
104
 
105
105
  def iter_motion(
106
- video: av.VideoStream, tb: Fraction, blur: int, width: int
106
+ video: bv.VideoStream, tb: Fraction, blur: int, width: int
107
107
  ) -> Iterator[np.float32]:
108
108
  video.thread_type = "AUTO"
109
109
 
@@ -113,7 +113,7 @@ def iter_motion(
113
113
  index = 0
114
114
  prev_index = -1
115
115
 
116
- graph = av.filter.Graph()
116
+ graph = bv.filter.Graph()
117
117
  graph.link_nodes(
118
118
  graph.add_buffer(template=video),
119
119
  graph.add("scale", f"{width}:-1"),
@@ -123,7 +123,7 @@ def iter_motion(
123
123
  ).configure()
124
124
 
125
125
  container = video.container
126
- assert isinstance(container, av.container.InputContainer)
126
+ assert isinstance(container, bv.container.InputContainer)
127
127
 
128
128
  for unframe in container.decode(video):
129
129
  if unframe.pts is None:
@@ -154,7 +154,7 @@ def iter_motion(
154
154
 
155
155
  @dataclass(slots=True)
156
156
  class Levels:
157
- container: av.container.InputContainer
157
+ container: bv.container.InputContainer
158
158
  name: str
159
159
  mod_time: int
160
160
  tb: Fraction
@@ -258,7 +258,7 @@ class Levels:
258
258
  if audio.duration is not None and audio.time_base is not None:
259
259
  inaccurate_dur = int(audio.duration * audio.time_base * self.tb)
260
260
  elif container.duration is not None:
261
- inaccurate_dur = int(container.duration / av.time_base * self.tb)
261
+ inaccurate_dur = int(container.duration / bv.time_base * self.tb)
262
262
  else:
263
263
  inaccurate_dur = 1024
264
264
 
@@ -343,15 +343,9 @@ class Levels:
343
343
  for packet in container.demux(subtitle_stream):
344
344
  if packet.pts is None or packet.duration is None:
345
345
  continue
346
- for subset in packet.decode():
347
- # See definition of `AVSubtitle`
348
- # in: https://ffmpeg.org/doxygen/trunk/avcodec_8h_source.html
349
- start = float(packet.pts * subtitle_stream.time_base)
350
- dur = float(packet.duration * subtitle_stream.time_base)
351
-
352
- end = round((start + dur) * self.tb)
353
- sub_length = max(sub_length, end)
346
+ sub_length = max(sub_length, packet.pts + packet.duration)
354
347
 
348
+ sub_length = round(sub_length * subtitle_stream.time_base * self.tb)
355
349
  result = np.zeros((sub_length), dtype=np.bool_)
356
350
  del sub_length
357
351
 
@@ -363,25 +357,25 @@ class Levels:
363
357
  continue
364
358
  if early_exit:
365
359
  break
366
- for subset in packet.decode():
367
- if max_count is not None and count >= max_count:
368
- early_exit = True
369
- break
370
360
 
371
- start = float(packet.pts * subtitle_stream.time_base)
372
- dur = float(packet.duration * subtitle_stream.time_base)
361
+ if max_count is not None and count >= max_count:
362
+ early_exit = True
363
+ break
364
+
365
+ start = float(packet.pts * subtitle_stream.time_base)
366
+ dur = float(packet.duration * subtitle_stream.time_base)
373
367
 
374
- san_start = round(start * self.tb)
375
- san_end = round((start + dur) * self.tb)
368
+ san_start = round(start * self.tb)
369
+ san_end = round((start + dur) * self.tb)
376
370
 
377
- for sub in subset:
378
- if not isinstance(sub, AssSubtitle):
379
- continue
371
+ for sub in packet.decode():
372
+ if not isinstance(sub, AssSubtitle):
373
+ continue
380
374
 
381
- line = sub.dialogue.decode(errors="ignore")
382
- if line and re.search(re_pattern, line):
383
- result[san_start:san_end] = 1
384
- count += 1
375
+ line = sub.dialogue.decode(errors="ignore")
376
+ if line and re.search(re_pattern, line):
377
+ result[san_start:san_end] = 1
378
+ count += 1
385
379
 
386
380
  container.seek(0)
387
381
  return result
@@ -391,8 +385,8 @@ def initLevels(
391
385
  src: FileInfo, tb: Fraction, bar: Bar, no_cache: bool, log: Log
392
386
  ) -> Levels:
393
387
  try:
394
- container = av.open(src.path)
395
- except av.FFmpegError as e:
388
+ container = bv.open(src.path)
389
+ except bv.FFmpegError as e:
396
390
  log.error(e)
397
391
 
398
392
  mod_time = int(src.path.stat().st_mtime)
auto_editor/cmds/info.py CHANGED
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from typing import Any, Literal, TypedDict
7
7
 
8
8
  from auto_editor.ffwrapper import initFileInfo
9
- from auto_editor.lang.json import dump
9
+ from auto_editor.json import dump
10
10
  from auto_editor.make_layers import make_sane_timebase
11
11
  from auto_editor.timeline import v3
12
12
  from auto_editor.utils.func import aspect_ratio
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
5
5
  from fractions import Fraction
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- import av
8
+ import bv
9
9
  import numpy as np
10
10
 
11
11
  from auto_editor.analyze import *
@@ -134,7 +134,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
134
134
  if (arr := levels.read_cache("audio", (obj["stream"],))) is not None:
135
135
  print_arr(arr)
136
136
  else:
137
- container = av.open(src.path, "r")
137
+ container = bv.open(src.path, "r")
138
138
  audio_stream = container.streams.audio[obj["stream"]]
139
139
 
140
140
  values = []
@@ -155,7 +155,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
155
155
  if (arr := levels.read_cache("motion", mobj)) is not None:
156
156
  print_arr(arr)
157
157
  else:
158
- container = av.open(src.path, "r")
158
+ container = bv.open(src.path, "r")
159
159
  video_stream = container.streams.video[obj["stream"]]
160
160
 
161
161
  values = []
@@ -1,18 +1,72 @@
1
1
  import sys
2
+ from dataclasses import dataclass, field
2
3
 
3
- import av
4
- from av.subtitles.subtitle import AssSubtitle
4
+ import bv
5
+ from bv.subtitles.subtitle import AssSubtitle
6
+
7
+ from auto_editor.json import dump
8
+ from auto_editor.vanparse import ArgumentParser
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class SubdumpArgs:
13
+ help: bool = False
14
+ input: list[str] = field(default_factory=list)
15
+ json: bool = False
5
16
 
6
17
 
7
18
  def main(sys_args: list[str] = sys.argv[1:]) -> None:
8
- for i, input_file in enumerate(sys_args):
9
- with av.open(input_file) as container:
19
+ parser = ArgumentParser("subdump")
20
+ parser.add_required("input", nargs="*")
21
+ parser.add_argument("--json", flag=True)
22
+ args = parser.parse_args(SubdumpArgs, sys_args)
23
+
24
+ do_filter = True
25
+
26
+ if args.json:
27
+ data = {}
28
+ for input_file in args.input:
29
+ container = bv.open(input_file)
10
30
  for s in range(len(container.streams.subtitles)):
11
- print(f"file: {input_file} ({s}:{container.streams.subtitles[s].name})")
12
- for subset in container.decode(subtitles=s):
13
- for sub in subset:
31
+ entry_data = []
32
+
33
+ input_stream = container.streams.subtitles[s]
34
+ assert input_stream.time_base is not None
35
+ for packet in container.demux(input_stream):
36
+ if (
37
+ packet.dts is None
38
+ or packet.pts is None
39
+ or packet.duration is None
40
+ ):
41
+ continue
42
+
43
+ start = packet.pts * input_stream.time_base
44
+ end = start + packet.duration * input_stream.time_base
45
+
46
+ startf = round(float(start), 3)
47
+ endf = round(float(end), 3)
48
+
49
+ if do_filter and endf - startf <= 0.02:
50
+ continue
51
+
52
+ for sub in packet.decode():
14
53
  if isinstance(sub, AssSubtitle):
15
- print(sub.ass.decode("utf-8", errors="ignore"))
54
+ content = sub.dialogue.decode("utf-8", errors="ignore")
55
+ entry_data.append([startf, endf, content])
56
+
57
+ data[f"{input_file}:{s}"] = entry_data
58
+ container.close()
59
+
60
+ dump(data, sys.stdout, indent=4)
61
+ return
62
+
63
+ for i, input_file in enumerate(args.input):
64
+ with bv.open(input_file) as container:
65
+ for s in range(len(container.streams.subtitles)):
66
+ print(f"file: {input_file} ({s}:{container.streams.subtitles[s].name})")
67
+ for sub2 in container.decode(subtitles=s):
68
+ if isinstance(sub2, AssSubtitle):
69
+ print(sub2.ass.decode("utf-8", errors="ignore"))
16
70
  print("------")
17
71
 
18
72
 
auto_editor/cmds/test.py CHANGED
@@ -11,7 +11,7 @@ from hashlib import sha256
11
11
  from tempfile import mkdtemp
12
12
  from time import perf_counter
13
13
 
14
- import av
14
+ import bv
15
15
  import numpy as np
16
16
 
17
17
  from auto_editor.ffwrapper import FileInfo, initFileInfo
@@ -73,6 +73,10 @@ def calculate_sha256(filename: str) -> str:
73
73
  return sha256_hash.hexdigest()
74
74
 
75
75
 
76
+ class SkipTest(Exception):
77
+ pass
78
+
79
+
76
80
  class Runner:
77
81
  def __init__(self) -> None:
78
82
  self.program = [sys.executable, "-m", "auto_editor"]
@@ -157,35 +161,35 @@ class Runner:
157
161
  file = "resources/testsrc.mp4"
158
162
  out = self.main([file], ["--faststart"]) + ".mp4"
159
163
  fast = calculate_sha256(out)
160
- with av.open(out) as container:
161
- assert isinstance(container.streams[0], av.VideoStream)
162
- assert isinstance(container.streams[1], av.AudioStream)
164
+ with bv.open(out) as container:
165
+ assert isinstance(container.streams[0], bv.VideoStream)
166
+ assert isinstance(container.streams[1], bv.AudioStream)
163
167
 
164
168
  out = self.main([file], ["--no-faststart"]) + ".mp4"
165
169
  nofast = calculate_sha256(out)
166
- with av.open(out) as container:
167
- assert isinstance(container.streams[0], av.VideoStream)
168
- assert isinstance(container.streams[1], av.AudioStream)
170
+ with bv.open(out) as container:
171
+ assert isinstance(container.streams[0], bv.VideoStream)
172
+ assert isinstance(container.streams[1], bv.AudioStream)
169
173
 
170
174
  out = self.main([file], ["--fragmented"]) + ".mp4"
171
175
  frag = calculate_sha256(out)
172
- with av.open(out) as container:
173
- assert isinstance(container.streams[0], av.VideoStream)
174
- assert isinstance(container.streams[1], av.AudioStream)
176
+ with bv.open(out) as container:
177
+ assert isinstance(container.streams[0], bv.VideoStream)
178
+ assert isinstance(container.streams[1], bv.AudioStream)
175
179
 
176
180
  assert fast != nofast, "+faststart is not being applied"
177
181
  assert frag not in (fast, nofast), "fragmented output should diff."
178
182
 
179
183
  def test_example(self) -> None:
180
184
  out = self.main(["example.mp4"], [], output="example_ALTERED.mp4")
181
- with av.open(out) as container:
185
+ with bv.open(out) as container:
182
186
  assert container.duration is not None
183
187
  assert container.duration > 17300000 and container.duration < 2 << 24
184
188
 
185
189
  video = container.streams[0]
186
190
  audio = container.streams[1]
187
- assert isinstance(video, av.VideoStream)
188
- assert isinstance(audio, av.AudioStream)
191
+ assert isinstance(video, bv.VideoStream)
192
+ assert isinstance(audio, bv.AudioStream)
189
193
  assert video.base_rate == 30
190
194
  assert video.average_rate is not None
191
195
  assert video.average_rate == 30, video.average_rate
@@ -261,19 +265,19 @@ class Runner:
261
265
  self.check([path, "--no-open"], "must have an extension")
262
266
 
263
267
  def test_silent_threshold(self):
264
- with av.open("resources/new-commentary.mp3") as container:
265
- assert container.duration / av.time_base == 6.732
268
+ with bv.open("resources/new-commentary.mp3") as container:
269
+ assert container.duration / bv.time_base == 6.732
266
270
 
267
271
  out = self.main(
268
272
  ["resources/new-commentary.mp3"], ["--edit", "audio:threshold=0.1"]
269
273
  )
270
274
  out += ".mp3"
271
275
 
272
- with av.open(out) as container:
273
- assert container.duration / av.time_base == 6.552
276
+ with bv.open(out) as container:
277
+ assert container.duration / bv.time_base == 6.552
274
278
 
275
279
  def test_track(self):
276
- out = self.main(["resources/multi-track.mov"], ["--keep_tracks_seperate"], "te")
280
+ out = self.main(["resources/multi-track.mov"], []) + ".mov"
277
281
  assert len(fileinfo(out).audios) == 2
278
282
 
279
283
  def test_export_json(self):
@@ -357,30 +361,24 @@ class Runner:
357
361
  ["--edit", "audio:stream=1"],
358
362
  "multi-track_ALTERED.mov",
359
363
  )
360
- assert len(fileinfo(out).audios) == 1
364
+ assert len(fileinfo(out).audios) == 2
361
365
 
362
366
  def test_concat(self):
363
367
  out = self.main(["example.mp4"], ["--cut-out", "0,171"], "hmm.mp4")
364
368
  self.main(["example.mp4", out], ["--debug"])
365
369
 
366
370
  def test_concat_mux_tracks(self):
367
- out = self.main(
368
- ["example.mp4", "resources/multi-track.mov"], [], "concat_mux.mov"
369
- )
371
+ inputs = ["example.mp4", "resources/multi-track.mov"]
372
+ out = self.main(inputs, ["--mix-audio-streams"], "concat_mux.mov")
370
373
  assert len(fileinfo(out).audios) == 1
371
374
 
372
375
  def test_concat_multi_tracks(self):
373
376
  out = self.main(
374
- ["resources/multi-track.mov", "resources/multi-track.mov"],
375
- ["--keep-tracks-separate"],
376
- "out.mov",
377
+ ["resources/multi-track.mov", "resources/multi-track.mov"], [], "out.mov"
377
378
  )
378
379
  assert len(fileinfo(out).audios) == 2
379
- out = self.main(
380
- ["example.mp4", "resources/multi-track.mov"],
381
- ["--keep-tracks-separate"],
382
- "out.mov",
383
- )
380
+ inputs = ["example.mp4", "resources/multi-track.mov"]
381
+ out = self.main(inputs, [], "out.mov")
384
382
  assert len(fileinfo(out).audios) == 2
385
383
 
386
384
  def test_frame_rate(self):
@@ -464,6 +462,21 @@ class Runner:
464
462
  out2 = self.main([out], ["-c:v", "prores"], "prores2.mkv")
465
463
  assert fileinfo(out2).videos[0].pix_fmt == "yuv422p10le"
466
464
 
465
+ def test_decode_hevc(self):
466
+ out = self.main(["resources/testsrc-hevc.mp4"], ["-c:v", "h264"]) + ".mp4"
467
+ output = fileinfo(out)
468
+ assert output.videos[0].codec == "h264"
469
+ assert output.videos[0].pix_fmt == "yuv420p"
470
+
471
+ def test_encode_hevc(self):
472
+ if os.environ.get("GITHUB_ACTIONS") == "true":
473
+ raise SkipTest()
474
+
475
+ out = self.main(["resources/testsrc.mp4"], ["-c:v", "hevc"], "out.mkv")
476
+ output = fileinfo(out)
477
+ assert output.videos[0].codec == "hevc"
478
+ assert output.videos[0].pix_fmt == "yuv420p"
479
+
467
480
  # Issue 280
468
481
  def test_SAR(self):
469
482
  out = self.main(["resources/SAR-2by3.mp4"], [], "2by3_out.mp4")
@@ -646,16 +659,21 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
646
659
 
647
660
  def timed_test(test_func):
648
661
  start_time = perf_counter()
662
+ skipped = False
649
663
  try:
650
664
  test_func()
651
665
  success = True
666
+ except SkipTest:
667
+ skipped = True
652
668
  except Exception as e:
653
669
  success = False
654
670
  exception = e
655
671
  end_time = perf_counter()
656
672
  duration = end_time - start_time
657
673
 
658
- if success:
674
+ if skipped:
675
+ return (SkipTest, duration, None)
676
+ elif success:
659
677
  return (True, duration, None)
660
678
  else:
661
679
  return (False, duration, exception)
@@ -674,17 +692,15 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
674
692
  total_time += dur
675
693
  index += 1
676
694
 
677
- if success:
695
+ msg = f"{name:<26} ({index}/{total}) {round(dur, 2):<5} secs "
696
+ if success == SkipTest:
678
697
  passed += 1
679
- print(
680
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs [\033[1;32mPASSED\033[0m]",
681
- flush=True,
682
- )
698
+ print(f"{msg}[\033[38;2;125;125;125;mSKIPPED\033[0m]", flush=True)
699
+ elif success:
700
+ passed += 1
701
+ print(f"{msg}[\033[1;32mPASSED\033[0m]", flush=True)
683
702
  else:
684
- print(
685
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs \033[1;31m[FAILED]\033[0m",
686
- flush=True,
687
- )
703
+ print(f"{msg}\033[1;31m[FAILED]\033[0m", flush=True)
688
704
  if args.no_fail_fast:
689
705
  print(f"\n{exception}")
690
706
  else: