auto-editor 26.3.3__py3-none-any.whl → 27.1.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.1.0"
auto_editor/__main__.py CHANGED
@@ -75,7 +75,9 @@ class Args:
75
75
 
76
76
  # Audio Rendering
77
77
  audio_codec: str = "auto"
78
+ audio_layout: str | None = None
78
79
  audio_bitrate: str = "auto"
80
+ mix_audio_streams: bool = False
79
81
  keep_tracks_separate: bool = False
80
82
  audio_normalize: str = "#f"
81
83
 
@@ -335,16 +337,26 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
335
337
  metavar="ENCODER",
336
338
  help="Set audio codec for output media",
337
339
  )
340
+ parser.add_argument(
341
+ "--audio-layout",
342
+ "-channel-layout",
343
+ "-layout",
344
+ metavar="LAYOUT",
345
+ help="Set the audio layout for the output media/timeline",
346
+ )
338
347
  parser.add_argument(
339
348
  "--audio-bitrate",
340
349
  "-b:a",
341
350
  metavar="BITRATE",
342
351
  help="Set the number of bits per second for audio",
343
352
  )
353
+ parser.add_argument(
354
+ "--mix-audio-streams", flag=True, help="Mix all audio streams together into one"
355
+ )
344
356
  parser.add_argument(
345
357
  "--keep-tracks-separate",
346
358
  flag=True,
347
- help="Don't mix all audio tracks into one when exporting",
359
+ help="Don't mix all audio streams into one when exporting (default)",
348
360
  )
349
361
  parser.add_argument(
350
362
  "--audio-normalize",
@@ -448,15 +460,15 @@ def main() -> None:
448
460
  if args.debug and not args.input:
449
461
  buf = StringIO()
450
462
  buf.write(f"OS: {plat.system()} {plat.release()} {plat.machine().lower()}\n")
451
- buf.write(f"Python: {plat.python_version()}\nPyAV: ")
463
+ buf.write(f"Python: {plat.python_version()}\nAV: ")
452
464
  try:
453
- import av
465
+ import bv
454
466
  except (ModuleNotFoundError, ImportError):
455
467
  buf.write("not found")
456
468
  else:
457
469
  try:
458
- buf.write(f"{av.__version__} ")
459
- license = av._core.library_meta["libavcodec"]["license"]
470
+ buf.write(f"{bv.__version__} ")
471
+ license = bv._core.library_meta["libavcodec"]["license"]
460
472
  buf.write(f"({license})")
461
473
  except AttributeError:
462
474
  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/desc.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import sys
4
4
  from dataclasses import dataclass, field
5
5
 
6
- from auto_editor.ffwrapper import initFileInfo
6
+ from auto_editor.ffwrapper import FileInfo
7
7
  from auto_editor.utils.log import Log
8
8
  from auto_editor.vanparse import ArgumentParser
9
9
 
@@ -22,7 +22,7 @@ def desc_options(parser: ArgumentParser) -> ArgumentParser:
22
22
  def main(sys_args: list[str] = sys.argv[1:]) -> None:
23
23
  args = desc_options(ArgumentParser("desc")).parse_args(DescArgs, sys_args)
24
24
  for path in args.input:
25
- src = initFileInfo(path, Log())
25
+ src = FileInfo.init(path, Log())
26
26
  if src.description is not None:
27
27
  sys.stdout.write(f"\n{src.description}\n\n")
28
28
  else:
auto_editor/cmds/info.py CHANGED
@@ -5,8 +5,8 @@ import sys
5
5
  from dataclasses import dataclass, field
6
6
  from typing import Any, Literal, TypedDict
7
7
 
8
- from auto_editor.ffwrapper import initFileInfo
9
- from auto_editor.lang.json import dump
8
+ from auto_editor.ffwrapper import FileInfo
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
@@ -102,7 +102,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
102
102
  file_info[file] = {"type": "timeline"}
103
103
  continue
104
104
 
105
- src = initFileInfo(file, log)
105
+ src = FileInfo.init(file, log)
106
106
 
107
107
  if len(src.videos) + len(src.audios) + len(src.subtitles) == 0:
108
108
  file_info[file] = {"type": "unknown"}
@@ -5,11 +5,11 @@ 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 *
12
- from auto_editor.ffwrapper import initFileInfo
12
+ from auto_editor.ffwrapper import FileInfo
13
13
  from auto_editor.lang.palet import env
14
14
  from auto_editor.lib.contracts import is_bool, is_nat, is_nat1, is_str, is_void, orc
15
15
  from auto_editor.utils.bar import initBar
@@ -87,7 +87,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
87
87
  bar = initBar("none")
88
88
  log = Log(quiet=True)
89
89
 
90
- sources = [initFileInfo(path, log) for path in args.input]
90
+ sources = [FileInfo.init(path, log) for path in args.input]
91
91
  if len(sources) < 1:
92
92
  log.error("levels needs at least one input file")
93
93
 
@@ -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 = []
auto_editor/cmds/repl.py CHANGED
@@ -7,7 +7,7 @@ from os import environ
7
7
 
8
8
  import auto_editor
9
9
  from auto_editor.analyze import initLevels
10
- from auto_editor.ffwrapper import initFileInfo
10
+ from auto_editor.ffwrapper import FileInfo
11
11
  from auto_editor.lang.palet import ClosingError, Lexer, Parser, env, interpret
12
12
  from auto_editor.lang.stdenv import make_standard_env
13
13
  from auto_editor.lib.data_structs import print_str
@@ -48,11 +48,6 @@ def repl_options(parser: ArgumentParser) -> ArgumentParser:
48
48
  type=frame_rate,
49
49
  help="Set custom timebase",
50
50
  )
51
- parser.add_argument(
52
- "--temp-dir",
53
- metavar="PATH",
54
- help="Set where the temporary directory is located",
55
- )
56
51
  return parser
57
52
 
58
53
 
@@ -60,8 +55,8 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
60
55
  args = repl_options(ArgumentParser(None)).parse_args(REPL_Args, sys_args)
61
56
 
62
57
  if args.input:
63
- log = Log(quiet=True, temp_dir=args.temp_dir)
64
- sources = [initFileInfo(path, log) for path in args.input]
58
+ log = Log(quiet=True)
59
+ sources = [FileInfo.init(path, log) for path in args.input]
65
60
  src = sources[0]
66
61
  tb = src.get_fps() if args.timebase is None else args.timebase
67
62
  env["timebase"] = tb
@@ -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,10 +11,10 @@ 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
- from auto_editor.ffwrapper import FileInfo, initFileInfo
17
+ from auto_editor.ffwrapper import FileInfo
18
18
  from auto_editor.lang.palet import Lexer, Parser, env, interpret
19
19
  from auto_editor.lang.stdenv import make_standard_env
20
20
  from auto_editor.lib.data_structs import Char
@@ -62,7 +62,7 @@ log = Log()
62
62
 
63
63
 
64
64
  def fileinfo(path: str) -> FileInfo:
65
- return initFileInfo(path, log)
65
+ return FileInfo.init(path, log)
66
66
 
67
67
 
68
68
  def calculate_sha256(filename: str) -> str:
@@ -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,58 @@ 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:
186
+ assert container.duration is not None
187
+ assert container.duration > 17300000 and container.duration < 2 << 24
188
+
189
+ assert len(container.streams) == 2
190
+ video = container.streams[0]
191
+ audio = container.streams[1]
192
+ assert isinstance(video, bv.VideoStream)
193
+ assert isinstance(audio, bv.AudioStream)
194
+ assert video.base_rate == 30
195
+ assert video.average_rate is not None
196
+ assert video.average_rate == 30, video.average_rate
197
+ assert (video.width, video.height) == (1280, 720)
198
+ assert video.codec.name == "h264"
199
+ assert video.language == "eng"
200
+ assert audio.codec.name == "aac"
201
+ assert audio.sample_rate == 48000
202
+ assert audio.language == "eng"
203
+ assert audio.layout.name == "stereo"
204
+
205
+ def test_to_mono(self) -> None:
206
+ out = self.main(["example.mp4"], ["-layout", "mono"], output="example_mono.mp4")
207
+ with bv.open(out) as container:
182
208
  assert container.duration is not None
183
209
  assert container.duration > 17300000 and container.duration < 2 << 24
184
210
 
211
+ assert len(container.streams) == 2
185
212
  video = container.streams[0]
186
213
  audio = container.streams[1]
187
- assert isinstance(video, av.VideoStream)
188
- assert isinstance(audio, av.AudioStream)
214
+ assert isinstance(video, bv.VideoStream)
215
+ assert isinstance(audio, bv.AudioStream)
189
216
  assert video.base_rate == 30
190
217
  assert video.average_rate is not None
191
218
  assert video.average_rate == 30, video.average_rate
@@ -195,6 +222,7 @@ class Runner:
195
222
  assert audio.codec.name == "aac"
196
223
  assert audio.sample_rate == 48000
197
224
  assert audio.language == "eng"
225
+ assert audio.layout.name == "mono"
198
226
 
199
227
  # PR #260
200
228
  def test_high_speed(self):
@@ -261,19 +289,19 @@ class Runner:
261
289
  self.check([path, "--no-open"], "must have an extension")
262
290
 
263
291
  def test_silent_threshold(self):
264
- with av.open("resources/new-commentary.mp3") as container:
265
- assert container.duration / av.time_base == 6.732
292
+ with bv.open("resources/new-commentary.mp3") as container:
293
+ assert container.duration / bv.time_base == 6.732
266
294
 
267
295
  out = self.main(
268
296
  ["resources/new-commentary.mp3"], ["--edit", "audio:threshold=0.1"]
269
297
  )
270
298
  out += ".mp3"
271
299
 
272
- with av.open(out) as container:
273
- assert container.duration / av.time_base == 6.552
300
+ with bv.open(out) as container:
301
+ assert container.duration / bv.time_base == 6.552
274
302
 
275
303
  def test_track(self):
276
- out = self.main(["resources/multi-track.mov"], ["--keep_tracks_seperate"], "te")
304
+ out = self.main(["resources/multi-track.mov"], []) + ".mov"
277
305
  assert len(fileinfo(out).audios) == 2
278
306
 
279
307
  def test_export_json(self):
@@ -328,8 +356,18 @@ class Runner:
328
356
  assert cn.videos[0].height == 380
329
357
  assert cn.audios[0].samplerate == 48000
330
358
 
359
+ # def test_premiere_multi(self):
360
+ # p_xml = self.main([f"resources/multi-track.mov"], ["-exp"], "multi.xml")
361
+
362
+ # cn = fileinfo(self.main([p_xml], []))
363
+ # assert len(cn.videos) == 1
364
+ # assert len(cn.audios) == 2
365
+
331
366
  def test_premiere(self):
332
367
  for test_name in all_files:
368
+ if test_name == "multi-track.mov":
369
+ continue
370
+
333
371
  p_xml = self.main([f"resources/{test_name}"], ["-exp"], "out.xml")
334
372
  self.main([p_xml], [])
335
373
 
@@ -357,30 +395,24 @@ class Runner:
357
395
  ["--edit", "audio:stream=1"],
358
396
  "multi-track_ALTERED.mov",
359
397
  )
360
- assert len(fileinfo(out).audios) == 1
398
+ assert len(fileinfo(out).audios) == 2
361
399
 
362
400
  def test_concat(self):
363
401
  out = self.main(["example.mp4"], ["--cut-out", "0,171"], "hmm.mp4")
364
402
  self.main(["example.mp4", out], ["--debug"])
365
403
 
366
404
  def test_concat_mux_tracks(self):
367
- out = self.main(
368
- ["example.mp4", "resources/multi-track.mov"], [], "concat_mux.mov"
369
- )
405
+ inputs = ["example.mp4", "resources/multi-track.mov"]
406
+ out = self.main(inputs, ["--mix-audio-streams"], "concat_mux.mov")
370
407
  assert len(fileinfo(out).audios) == 1
371
408
 
372
409
  def test_concat_multi_tracks(self):
373
410
  out = self.main(
374
- ["resources/multi-track.mov", "resources/multi-track.mov"],
375
- ["--keep-tracks-separate"],
376
- "out.mov",
411
+ ["resources/multi-track.mov", "resources/multi-track.mov"], [], "out.mov"
377
412
  )
378
413
  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
- )
414
+ inputs = ["example.mp4", "resources/multi-track.mov"]
415
+ out = self.main(inputs, [], "out.mov")
384
416
  assert len(fileinfo(out).audios) == 2
385
417
 
386
418
  def test_frame_rate(self):
@@ -464,6 +496,21 @@ class Runner:
464
496
  out2 = self.main([out], ["-c:v", "prores"], "prores2.mkv")
465
497
  assert fileinfo(out2).videos[0].pix_fmt == "yuv422p10le"
466
498
 
499
+ def test_decode_hevc(self):
500
+ out = self.main(["resources/testsrc-hevc.mp4"], ["-c:v", "h264"]) + ".mp4"
501
+ output = fileinfo(out)
502
+ assert output.videos[0].codec == "h264"
503
+ assert output.videos[0].pix_fmt == "yuv420p"
504
+
505
+ def test_encode_hevc(self):
506
+ if os.environ.get("GITHUB_ACTIONS") == "true":
507
+ raise SkipTest()
508
+
509
+ out = self.main(["resources/testsrc.mp4"], ["-c:v", "hevc"], "out.mkv")
510
+ output = fileinfo(out)
511
+ assert output.videos[0].codec == "hevc"
512
+ assert output.videos[0].pix_fmt == "yuv420p"
513
+
467
514
  # Issue 280
468
515
  def test_SAR(self):
469
516
  out = self.main(["resources/SAR-2by3.mp4"], [], "2by3_out.mp4")
@@ -646,16 +693,21 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
646
693
 
647
694
  def timed_test(test_func):
648
695
  start_time = perf_counter()
696
+ skipped = False
649
697
  try:
650
698
  test_func()
651
699
  success = True
700
+ except SkipTest:
701
+ skipped = True
652
702
  except Exception as e:
653
703
  success = False
654
704
  exception = e
655
705
  end_time = perf_counter()
656
706
  duration = end_time - start_time
657
707
 
658
- if success:
708
+ if skipped:
709
+ return (SkipTest, duration, None)
710
+ elif success:
659
711
  return (True, duration, None)
660
712
  else:
661
713
  return (False, duration, exception)
@@ -674,17 +726,15 @@ def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
674
726
  total_time += dur
675
727
  index += 1
676
728
 
677
- if success:
729
+ msg = f"{name:<26} ({index}/{total}) {round(dur, 2):<5} secs "
730
+ if success == SkipTest:
678
731
  passed += 1
679
- print(
680
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs [\033[1;32mPASSED\033[0m]",
681
- flush=True,
682
- )
732
+ print(f"{msg}[\033[38;2;125;125;125;mSKIPPED\033[0m]", flush=True)
733
+ elif success:
734
+ passed += 1
735
+ print(f"{msg}[\033[1;32mPASSED\033[0m]", flush=True)
683
736
  else:
684
- print(
685
- f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs \033[1;31m[FAILED]\033[0m",
686
- flush=True,
687
- )
737
+ print(f"{msg}\033[1;31m[FAILED]\033[0m", flush=True)
688
738
  if args.no_fail_fast:
689
739
  print(f"\n{exception}")
690
740
  else: