auto-editor 26.3.2__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/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"]
@@ -153,36 +157,42 @@ class Runner:
153
157
  def desc(self):
154
158
  self.raw(["desc", "example.mp4"])
155
159
 
160
+ def test_movflags(self) -> None:
161
+ file = "resources/testsrc.mp4"
162
+ out = self.main([file], ["--faststart"]) + ".mp4"
163
+ fast = calculate_sha256(out)
164
+ with bv.open(out) as container:
165
+ assert isinstance(container.streams[0], bv.VideoStream)
166
+ assert isinstance(container.streams[1], bv.AudioStream)
167
+
168
+ out = self.main([file], ["--no-faststart"]) + ".mp4"
169
+ nofast = calculate_sha256(out)
170
+ with bv.open(out) as container:
171
+ assert isinstance(container.streams[0], bv.VideoStream)
172
+ assert isinstance(container.streams[1], bv.AudioStream)
173
+
174
+ out = self.main([file], ["--fragmented"]) + ".mp4"
175
+ frag = calculate_sha256(out)
176
+ with bv.open(out) as container:
177
+ assert isinstance(container.streams[0], bv.VideoStream)
178
+ assert isinstance(container.streams[1], bv.AudioStream)
179
+
180
+ assert fast != nofast, "+faststart is not being applied"
181
+ assert frag not in (fast, nofast), "fragmented output should diff."
182
+
156
183
  def test_example(self) -> None:
157
184
  out = self.main(["example.mp4"], [], output="example_ALTERED.mp4")
158
- with av.open(out) as container:
159
- video = container.streams[0]
160
- audio = container.streams[1]
161
-
162
- assert isinstance(video, av.VideoStream)
163
- assert isinstance(audio, av.AudioStream)
164
- assert video.base_rate == 30
165
- assert video.average_rate is not None
166
- assert video.average_rate == 30, video.average_rate
167
- assert (video.width, video.height) == (1280, 720)
168
- assert video.codec.name == "h264"
169
- assert video.language == "eng"
170
- assert audio.codec.name == "aac"
171
- assert audio.sample_rate == 48000
172
- assert audio.language == "eng"
173
-
174
- out1_sha = calculate_sha256(out)
185
+ with bv.open(out) as container:
186
+ assert container.duration is not None
187
+ assert container.duration > 17300000 and container.duration < 2 << 24
175
188
 
176
- out = self.main(["example.mp4"], ["--fragmented"], output="example_ALTERED.mp4")
177
- with av.open(out) as container:
178
189
  video = container.streams[0]
179
190
  audio = container.streams[1]
180
-
181
- assert isinstance(video, av.VideoStream)
182
- assert isinstance(audio, av.AudioStream)
191
+ assert isinstance(video, bv.VideoStream)
192
+ assert isinstance(audio, bv.AudioStream)
183
193
  assert video.base_rate == 30
184
194
  assert video.average_rate is not None
185
- assert round(video.average_rate) == 30, video.average_rate
195
+ assert video.average_rate == 30, video.average_rate
186
196
  assert (video.width, video.height) == (1280, 720)
187
197
  assert video.codec.name == "h264"
188
198
  assert video.language == "eng"
@@ -190,8 +200,6 @@ class Runner:
190
200
  assert audio.sample_rate == 48000
191
201
  assert audio.language == "eng"
192
202
 
193
- assert calculate_sha256(out) != out1_sha, "Fragmented output should be diff."
194
-
195
203
  # PR #260
196
204
  def test_high_speed(self):
197
205
  self.check(["example.mp4", "--video-speed", "99998"], "empty")
@@ -257,19 +265,19 @@ class Runner:
257
265
  self.check([path, "--no-open"], "must have an extension")
258
266
 
259
267
  def test_silent_threshold(self):
260
- with av.open("resources/new-commentary.mp3") as container:
261
- 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
262
270
 
263
271
  out = self.main(
264
272
  ["resources/new-commentary.mp3"], ["--edit", "audio:threshold=0.1"]
265
273
  )
266
274
  out += ".mp3"
267
275
 
268
- with av.open(out) as container:
269
- assert container.duration / av.time_base == 6.552
276
+ with bv.open(out) as container:
277
+ assert container.duration / bv.time_base == 6.552
270
278
 
271
279
  def test_track(self):
272
- out = self.main(["resources/multi-track.mov"], ["--keep_tracks_seperate"], "te")
280
+ out = self.main(["resources/multi-track.mov"], []) + ".mov"
273
281
  assert len(fileinfo(out).audios) == 2
274
282
 
275
283
  def test_export_json(self):
@@ -353,30 +361,24 @@ class Runner:
353
361
  ["--edit", "audio:stream=1"],
354
362
  "multi-track_ALTERED.mov",
355
363
  )
356
- assert len(fileinfo(out).audios) == 1
364
+ assert len(fileinfo(out).audios) == 2
357
365
 
358
366
  def test_concat(self):
359
367
  out = self.main(["example.mp4"], ["--cut-out", "0,171"], "hmm.mp4")
360
368
  self.main(["example.mp4", out], ["--debug"])
361
369
 
362
370
  def test_concat_mux_tracks(self):
363
- out = self.main(
364
- ["example.mp4", "resources/multi-track.mov"], [], "concat_mux.mov"
365
- )
371
+ inputs = ["example.mp4", "resources/multi-track.mov"]
372
+ out = self.main(inputs, ["--mix-audio-streams"], "concat_mux.mov")
366
373
  assert len(fileinfo(out).audios) == 1
367
374
 
368
375
  def test_concat_multi_tracks(self):
369
376
  out = self.main(
370
- ["resources/multi-track.mov", "resources/multi-track.mov"],
371
- ["--keep-tracks-separate"],
372
- "out.mov",
377
+ ["resources/multi-track.mov", "resources/multi-track.mov"], [], "out.mov"
373
378
  )
374
379
  assert len(fileinfo(out).audios) == 2
375
- out = self.main(
376
- ["example.mp4", "resources/multi-track.mov"],
377
- ["--keep-tracks-separate"],
378
- "out.mov",
379
- )
380
+ inputs = ["example.mp4", "resources/multi-track.mov"]
381
+ out = self.main(inputs, [], "out.mov")
380
382
  assert len(fileinfo(out).audios) == 2
381
383
 
382
384
  def test_frame_rate(self):
@@ -460,6 +462,21 @@ class Runner:
460
462
  out2 = self.main([out], ["-c:v", "prores"], "prores2.mkv")
461
463
  assert fileinfo(out2).videos[0].pix_fmt == "yuv422p10le"
462
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
+
463
480
  # Issue 280
464
481
  def test_SAR(self):
465
482
  out = self.main(["resources/SAR-2by3.mp4"], [], "2by3_out.mp4")
@@ -642,16 +659,21 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
642
659
 
643
660
  def timed_test(test_func):
644
661
  start_time = perf_counter()
662
+ skipped = False
645
663
  try:
646
664
  test_func()
647
665
  success = True
666
+ except SkipTest:
667
+ skipped = True
648
668
  except Exception as e:
649
669
  success = False
650
670
  exception = e
651
671
  end_time = perf_counter()
652
672
  duration = end_time - start_time
653
673
 
654
- if success:
674
+ if skipped:
675
+ return (SkipTest, duration, None)
676
+ elif success:
655
677
  return (True, duration, None)
656
678
  else:
657
679
  return (False, duration, exception)
@@ -670,17 +692,15 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
670
692
  total_time += dur
671
693
  index += 1
672
694
 
673
- if success:
695
+ msg = f"{name:<26} ({index}/{total}) {round(dur, 2):<5} secs "
696
+ if success == SkipTest:
674
697
  passed += 1
675
- print(
676
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs [\033[1;32mPASSED\033[0m]",
677
- flush=True,
678
- )
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)
679
702
  else:
680
- print(
681
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs \033[1;31m[FAILED]\033[0m",
682
- flush=True,
683
- )
703
+ print(f"{msg}\033[1;31m[FAILED]\033[0m", flush=True)
684
704
  if args.no_fail_fast:
685
705
  print(f"\n{exception}")
686
706
  else:
auto_editor/edit.py CHANGED
@@ -6,10 +6,10 @@ from fractions import Fraction
6
6
  from heapq import heappop, heappush
7
7
  from os.path import splitext
8
8
  from subprocess import run
9
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any
10
10
 
11
- import av
12
- from av import AudioResampler, Codec
11
+ import bv
12
+ from bv import AudioResampler, Codec
13
13
 
14
14
  from auto_editor.ffwrapper import FileInfo, initFileInfo
15
15
  from auto_editor.lib.contracts import is_int, is_str
@@ -24,7 +24,9 @@ from auto_editor.utils.chunks import Chunk, Chunks
24
24
  from auto_editor.utils.cmdkw import ParserError, parse_with_palet, pAttr, pAttrs
25
25
  from auto_editor.utils.container import Container, container_constructor
26
26
  from auto_editor.utils.log import Log
27
- from auto_editor.utils.types import Args
27
+
28
+ if TYPE_CHECKING:
29
+ from auto_editor.__main__ import Args
28
30
 
29
31
 
30
32
  def set_output(
@@ -81,18 +83,10 @@ def set_video_codec(
81
83
  return ctr.default_vid
82
84
  return codec
83
85
 
84
- if codec == "copy":
85
- log.deprecated("The `copy` codec is deprecated. auto-editor always re-encodes")
86
- if src is None:
87
- log.error("No input to copy its codec from.")
88
- if not src.videos:
89
- log.error("Input file does not have a video stream to copy codec from.")
90
- codec = src.videos[0].codec
91
-
92
86
  if ctr.vcodecs is not None and codec not in ctr.vcodecs:
93
87
  try:
94
88
  cobj = Codec(codec, "w")
95
- except av.codec.codec.UnknownCodecError:
89
+ except bv.codec.codec.UnknownCodecError:
96
90
  log.error(f"Unknown encoder: {codec}")
97
91
  # Normalize encoder names
98
92
  if cobj.id not in (Codec(x, "w").id for x in ctr.vcodecs):
@@ -109,7 +103,7 @@ def set_audio_codec(
109
103
  codec = "aac"
110
104
  else:
111
105
  codec = src.audios[0].codec
112
- if av.Codec(codec, "w").audio_formats is None:
106
+ if bv.Codec(codec, "w").audio_formats is None:
113
107
  codec = "aac"
114
108
  if codec not in ctr.acodecs and ctr.default_aud != "none":
115
109
  codec = ctr.default_aud
@@ -117,18 +111,10 @@ def set_audio_codec(
117
111
  codec = "aac"
118
112
  return codec
119
113
 
120
- if codec == "copy":
121
- log.deprecated("The `copy` codec is deprecated. auto-editor always re-encodes")
122
- if src is None:
123
- log.error("No input to copy its codec from.")
124
- if not src.audios:
125
- log.error("Input file does not have an audio stream to copy codec from.")
126
- codec = src.audios[0].codec
127
-
128
114
  if ctr.acodecs is None or codec not in ctr.acodecs:
129
115
  try:
130
116
  cobj = Codec(codec, "w")
131
- except av.codec.codec.UnknownCodecError:
117
+ except bv.codec.codec.UnknownCodecError:
132
118
  log.error(f"Unknown encoder: {codec}")
133
119
  # Normalize encoder names
134
120
  if cobj.id not in (Codec(x, "w").id for x in ctr.acodecs):
@@ -176,6 +162,10 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
176
162
  bar = initBar(args.progress)
177
163
  tl = None
178
164
 
165
+ if args.keep_tracks_separate:
166
+ log.deprecated("--keep-tracks-separate is deprecated.")
167
+ args.keep_tracks_separate = False
168
+
179
169
  if paths:
180
170
  path_ext = splitext(paths[0])[1].lower()
181
171
  if path_ext == ".xml":
@@ -206,7 +196,7 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
206
196
 
207
197
  del paths
208
198
 
209
- output, export_ops = set_output(args.output_file, args.export, src, log)
199
+ output, export_ops = set_output(args.output, args.export, src, log)
210
200
  assert "export" in export_ops
211
201
  export = export_ops["export"]
212
202
 
@@ -219,10 +209,6 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
219
209
  if os.path.isdir(output):
220
210
  log.error("Output path already has an existing directory!")
221
211
 
222
- if os.path.isfile(output) and src is not None and src.path != output: # type: ignore
223
- log.debug(f"Removing already existing file: {output}")
224
- os.remove(output)
225
-
226
212
  if args.sample_rate is None:
227
213
  if tl is None:
228
214
  samplerate = 48000 if src is None else src.get_sr()
@@ -291,52 +277,53 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
291
277
  args.video_codec = set_video_codec(args.video_codec, src, out_ext, ctr, log)
292
278
  args.audio_codec = set_audio_codec(args.audio_codec, src, out_ext, ctr, log)
293
279
 
294
- if args.keep_tracks_separate and ctr.max_audios == 1:
295
- log.warning(f"'{out_ext}' container doesn't support multiple audio tracks.")
296
-
297
280
  def make_media(tl: v3, output_path: str) -> None:
298
281
  assert src is not None
299
282
 
283
+ options = {}
284
+ mov_flags = []
300
285
  if args.fragmented and not args.no_fragmented:
301
- log.debug("Enabling fragmented mp4/mov")
302
- options = {
303
- "movflags": "+default_base_moof+faststart+frag_keyframe+separate_moof",
304
- "frag_duration": "0.2",
305
- }
306
- else:
307
- options = {"movflags": "faststart"}
308
- output = av.open(output_path, "w", options=options)
286
+ mov_flags.extend(["default_base_moof", "frag_keyframe", "separate_moof"])
287
+ options["frag_duration"] = "0.2"
288
+ if args.faststart:
289
+ log.warning("Fragmented is enabled, will not apply faststart.")
290
+ elif not args.no_faststart:
291
+ mov_flags.append("faststart")
292
+ if mov_flags:
293
+ options["movflags"] = "+".join(mov_flags)
309
294
 
310
- if ctr.default_sub != "none" and not args.sn:
311
- sub_paths = make_new_subtitles(tl, log)
295
+ output = bv.open(output_path, "w", container_options=options)
296
+
297
+ # Setup video
298
+ if ctr.default_vid != "none" and tl.v:
299
+ vframes = render_av(output, tl, args, log)
300
+ output_stream: bv.VideoStream | None
301
+ output_stream = next(vframes) # type: ignore
312
302
  else:
313
- sub_paths = []
303
+ output_stream, vframes = None, iter([])
314
304
 
305
+ # Setup audio
315
306
  if ctr.default_aud != "none":
316
307
  ensure = Ensure(bar, samplerate, log)
317
308
  audio_paths = make_new_audio(tl, ctr, ensure, args, bar, log)
318
309
  else:
319
310
  audio_paths = []
320
311
 
321
- # Setup video
322
- if ctr.default_vid != "none" and tl.v:
323
- vframes = render_av(output, tl, args, log)
324
- output_stream = next(vframes)
325
- else:
326
- output_stream, vframes = None, iter([])
312
+ if len(audio_paths) > 1 and ctr.max_audios == 1:
313
+ log.warning("Dropping extra audio streams (container only allows one)")
314
+ audio_paths = audio_paths[0:1]
327
315
 
328
- # Setup audio
329
316
  if audio_paths:
330
317
  try:
331
- audio_encoder = av.Codec(args.audio_codec, "w")
332
- except av.FFmpegError as e:
318
+ audio_encoder = bv.Codec(args.audio_codec, "w")
319
+ except bv.FFmpegError as e:
333
320
  log.error(e)
334
321
  if audio_encoder.audio_formats is None:
335
322
  log.error(f"{args.audio_codec}: No known audio formats avail.")
336
323
  audio_format = audio_encoder.audio_formats[0]
337
324
  resampler = AudioResampler(format=audio_format, layout="stereo", rate=tl.sr)
338
325
 
339
- audio_streams: list[av.AudioStream] = []
326
+ audio_streams: list[bv.AudioStream] = []
340
327
  audio_inputs = []
341
328
  audio_gen_frames = []
342
329
  for i, audio_path in enumerate(audio_paths):
@@ -346,7 +333,7 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
346
333
  rate=tl.sr,
347
334
  time_base=Fraction(1, tl.sr),
348
335
  )
349
- if not isinstance(audio_stream, av.AudioStream):
336
+ if not isinstance(audio_stream, bv.AudioStream):
350
337
  log.error(f"Not a known audio codec: {args.audio_codec}")
351
338
 
352
339
  if args.audio_bitrate != "auto":
@@ -358,17 +345,22 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
358
345
  audio_stream.metadata["language"] = src.audios[i].lang # type: ignore
359
346
 
360
347
  audio_streams.append(audio_stream)
361
- audio_input = av.open(audio_path)
348
+ audio_input = bv.open(audio_path)
362
349
  audio_inputs.append(audio_input)
363
350
  audio_gen_frames.append(audio_input.decode(audio=0))
364
351
 
365
352
  # Setup subtitles
353
+ if ctr.default_sub != "none" and not args.sn:
354
+ sub_paths = make_new_subtitles(tl, log)
355
+ else:
356
+ sub_paths = []
357
+
366
358
  subtitle_streams = []
367
359
  subtitle_inputs = []
368
360
  sub_gen_frames = []
369
361
 
370
362
  for i, sub_path in enumerate(sub_paths):
371
- subtitle_input = av.open(sub_path)
363
+ subtitle_input = bv.open(sub_path)
372
364
  subtitle_inputs.append(subtitle_input)
373
365
  subtitle_stream = output.add_stream_from_template(
374
366
  subtitle_input.streams.subtitles[0]
@@ -497,14 +489,14 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
497
489
  output.mux(item.stream.encode(item.frame))
498
490
  elif frame_type == "subtitle":
499
491
  output.mux(item.frame)
500
- except av.error.ExternalError:
492
+ except bv.error.ExternalError:
501
493
  log.error(
502
494
  f"Generic error for encoder: {item.stream.name}\n"
503
495
  f"at {item.index} time_base\nPerhaps video quality settings are too low?"
504
496
  )
505
- except av.FileNotFoundError:
497
+ except bv.FileNotFoundError:
506
498
  log.error(f"File not found: {output_path}")
507
- except av.FFmpegError as e:
499
+ except bv.FFmpegError as e:
508
500
  log.error(e)
509
501
 
510
502
  if bar_index:
auto_editor/ffwrapper.py CHANGED
@@ -4,14 +4,14 @@ from dataclasses import dataclass
4
4
  from fractions import Fraction
5
5
  from pathlib import Path
6
6
 
7
- import av
7
+ import bv
8
8
 
9
9
  from auto_editor.utils.log import Log
10
10
 
11
11
 
12
12
  def mux(input: Path, output: Path, stream: int) -> None:
13
- input_container = av.open(input, "r")
14
- output_container = av.open(output, "w")
13
+ input_container = bv.open(input, "r")
14
+ output_container = bv.open(output, "w")
15
15
 
16
16
  input_audio_stream = input_container.streams.audio[stream]
17
17
  output_audio_stream = output_container.add_stream("pcm_s16le")
@@ -92,12 +92,12 @@ class FileInfo:
92
92
 
93
93
  def initFileInfo(path: str, log: Log) -> FileInfo:
94
94
  try:
95
- cont = av.open(path, "r")
96
- except av.error.FileNotFoundError:
95
+ cont = bv.open(path, "r")
96
+ except bv.error.FileNotFoundError:
97
97
  log.error(f"Input file doesn't exist: {path}")
98
- except av.error.IsADirectoryError:
98
+ except bv.error.IsADirectoryError:
99
99
  log.error(f"Expected a media file, but got a directory: {path}")
100
- except av.error.InvalidDataError:
100
+ except bv.error.InvalidDataError:
101
101
  log.error(f"Invalid data when processing: {path}")
102
102
 
103
103
  videos: tuple[VideoStream, ...] = ()
@@ -126,7 +126,7 @@ def initFileInfo(path: str, log: Log) -> FileInfo:
126
126
  VideoStream(
127
127
  v.width,
128
128
  v.height,
129
- v.name,
129
+ v.codec.canonical_name,
130
130
  fps,
131
131
  vdur,
132
132
  sar,
@@ -167,7 +167,7 @@ def initFileInfo(path: str, log: Log) -> FileInfo:
167
167
 
168
168
  desc = cont.metadata.get("description", None)
169
169
  bitrate = 0 if cont.bit_rate is None else cont.bit_rate
170
- dur = 0 if cont.duration is None else cont.duration / av.time_base
170
+ dur = 0 if cont.duration is None else cont.duration / bv.time_base
171
171
 
172
172
  cont.close()
173
173
 
@@ -7,7 +7,7 @@ from fractions import Fraction
7
7
  from typing import Any
8
8
 
9
9
  from auto_editor.ffwrapper import FileInfo, initFileInfo
10
- from auto_editor.lang.json import Lexer, Parser, dump
10
+ from auto_editor.json import dump, load
11
11
  from auto_editor.lib.err import MyError
12
12
  from auto_editor.timeline import (
13
13
  ASpace,
@@ -221,7 +221,7 @@ def read_v1(tl: Any, log: Log) -> v3:
221
221
  def read_json(path: str, log: Log) -> v3:
222
222
  with open(path, encoding="utf-8", errors="ignore") as f:
223
223
  try:
224
- tl = Parser(Lexer(path, f)).expr()
224
+ tl = load(path, f)
225
225
  except MyError as e:
226
226
  log.error(e)
227
227