auto-editor 28.0.1__tar.gz → 28.1.0__tar.gz

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.
Files changed (65) hide show
  1. {auto_editor-28.0.1 → auto_editor-28.1.0}/PKG-INFO +2 -2
  2. auto_editor-28.1.0/auto_editor/__init__.py +1 -0
  3. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/__main__.py +4 -3
  4. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/analyze.py +13 -13
  5. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/desc.py +2 -2
  6. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/levels.py +20 -12
  7. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/subdump.py +4 -4
  8. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/test.py +31 -27
  9. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/edit.py +64 -48
  10. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/exports/fcp11.py +40 -14
  11. auto_editor-28.1.0/auto_editor/exports/json.py +63 -0
  12. auto_editor-28.1.0/auto_editor/exports/kdenlive.py +322 -0
  13. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/exports/shotcut.py +1 -2
  14. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/ffwrapper.py +20 -9
  15. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/help.py +1 -0
  16. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lang/stdenv.py +0 -5
  17. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/make_layers.py +3 -3
  18. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/preview.py +12 -21
  19. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/render/audio.py +42 -42
  20. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/render/subtitle.py +5 -5
  21. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/render/video.py +28 -33
  22. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/timeline.py +0 -35
  23. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/container.py +2 -3
  24. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/log.py +3 -1
  25. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor.egg-info/PKG-INFO +2 -2
  26. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor.egg-info/SOURCES.txt +1 -0
  27. auto_editor-28.1.0/auto_editor.egg-info/requires.txt +2 -0
  28. {auto_editor-28.0.1 → auto_editor-28.1.0}/pyproject.toml +1 -1
  29. auto_editor-28.0.1/auto_editor/__init__.py +0 -1
  30. auto_editor-28.0.1/auto_editor/exports/json.py +0 -32
  31. auto_editor-28.0.1/auto_editor.egg-info/requires.txt +0 -2
  32. {auto_editor-28.0.1 → auto_editor-28.1.0}/LICENSE +0 -0
  33. {auto_editor-28.0.1 → auto_editor-28.1.0}/README.md +0 -0
  34. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/__init__.py +0 -0
  35. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/cache.py +0 -0
  36. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/info.py +0 -0
  37. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/palet.py +0 -0
  38. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/cmds/repl.py +0 -0
  39. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/exports/__init__.py +0 -0
  40. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/exports/fcp7.py +0 -0
  41. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/imports/__init__.py +0 -0
  42. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/imports/fcp7.py +0 -0
  43. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/imports/json.py +0 -0
  44. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/json.py +0 -0
  45. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lang/__init__.py +0 -0
  46. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lang/libintrospection.py +0 -0
  47. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lang/libmath.py +0 -0
  48. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lang/palet.py +0 -0
  49. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lib/__init__.py +0 -0
  50. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lib/contracts.py +0 -0
  51. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lib/data_structs.py +0 -0
  52. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/lib/err.py +0 -0
  53. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/render/__init__.py +0 -0
  54. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/__init__.py +0 -0
  55. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/bar.py +0 -0
  56. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/chunks.py +0 -0
  57. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/cmdkw.py +0 -0
  58. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/func.py +0 -0
  59. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/utils/types.py +0 -0
  60. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor/vanparse.py +0 -0
  61. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor.egg-info/dependency_links.txt +0 -0
  62. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor.egg-info/entry_points.txt +0 -0
  63. {auto_editor-28.0.1 → auto_editor-28.1.0}/auto_editor.egg-info/top_level.txt +0 -0
  64. {auto_editor-28.0.1 → auto_editor-28.1.0}/docs/build.py +0 -0
  65. {auto_editor-28.0.1 → auto_editor-28.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auto-editor
3
- Version: 28.0.1
3
+ Version: 28.1.0
4
4
  Summary: Auto-Editor: Effort free video editing!
5
5
  Author-email: WyattBlue <wyattblue@auto-editor.com>
6
6
  License-Expression: Unlicense
@@ -12,7 +12,7 @@ Requires-Python: <3.14,>=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: numpy<3.0,>=2
15
- Requires-Dist: basswood-av<16,>=15.2.1
15
+ Requires-Dist: av<16,>=15.0
16
16
  Dynamic: license-file
17
17
 
18
18
  <p align="center"><img src="https://auto-editor.com/img/auto-editor-banner.webp" title="Auto-Editor" width="700"></p>
@@ -0,0 +1 @@
1
+ __version__ = "28.1.0"
@@ -441,6 +441,7 @@ def main() -> None:
441
441
  ({"--export-to-resolve", "-exr"}, ["--export", "resolve"]),
442
442
  ({"--export-to-final-cut-pro", "-exf"}, ["--export", "final-cut-pro"]),
443
443
  ({"--export-to-shotcut", "-exs"}, ["--export", "shotcut"]),
444
+ ({"--export-to-kdenlive", "-exk"}, ["--export", "kdenlive"]),
444
445
  ({"--export-as-clip-sequence", "-excs"}, ["--export", "clip-sequence"]),
445
446
  ({"--edit-based-on"}, ["--edit"]),
446
447
  ],
@@ -454,13 +455,13 @@ def main() -> None:
454
455
  buf.write(f"OS: {plat.system()} {plat.release()} {plat.machine().lower()}\n")
455
456
  buf.write(f"Python: {plat.python_version()}\nAV: ")
456
457
  try:
457
- import bv
458
+ import av
458
459
  except (ModuleNotFoundError, ImportError):
459
460
  buf.write("not found")
460
461
  else:
461
462
  try:
462
- buf.write(f"{bv.__version__} ")
463
- license = bv._core.library_meta["libavcodec"]["license"]
463
+ buf.write(f"{av.__version__} ")
464
+ license = av._core.library_meta["libavcodec"]["license"]
464
465
  buf.write(f"({license})")
465
466
  except AttributeError:
466
467
  buf.write("error")
@@ -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 bv
12
+ import av
13
13
  import numpy as np
14
- from bv.audio.fifo import AudioFifo
15
- from bv.subtitles.subtitle import AssSubtitle
14
+ from av.audio.fifo import AudioFifo
15
+ from av.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: bv.AudioStream, tb: Fraction) -> Iterator[np.float32]:
75
+ def iter_audio(audio_stream: av.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: bv.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 = bv.AudioResampler(bv.AudioFormat("flt"), audio_stream.layout, sr)
83
+ resampler = av.AudioResampler(av.AudioFormat("flt"), audio_stream.layout, sr)
84
84
 
85
85
  container = audio_stream.container
86
- assert isinstance(container, bv.container.InputContainer)
86
+ assert isinstance(container, av.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: bv.AudioStream, tb: Fraction) -> Iterator[np.float3
103
103
 
104
104
 
105
105
  def iter_motion(
106
- video: bv.VideoStream, tb: Fraction, blur: int, width: int
106
+ video: av.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 = bv.filter.Graph()
116
+ graph = av.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, bv.container.InputContainer)
126
+ assert isinstance(container, av.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: bv.container.InputContainer
157
+ container: av.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 / bv.time_base * self.tb)
261
+ inaccurate_dur = int(container.duration / av.time_base * self.tb)
262
262
  else:
263
263
  inaccurate_dur = 1024
264
264
 
@@ -385,8 +385,8 @@ def initLevels(
385
385
  src: FileInfo, tb: Fraction, bar: Bar, no_cache: bool, log: Log
386
386
  ) -> Levels:
387
387
  try:
388
- container = bv.open(src.path)
389
- except bv.FFmpegError as e:
388
+ container = av.open(src.path)
389
+ except av.FFmpegError as e:
390
390
  log.error(e)
391
391
 
392
392
  mod_time = int(src.path.stat().st_mtime)
@@ -1,7 +1,7 @@
1
1
  import sys
2
2
  from dataclasses import dataclass, field
3
3
 
4
- import bv
4
+ import av
5
5
 
6
6
  from auto_editor.vanparse import ArgumentParser
7
7
 
@@ -21,7 +21,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
21
21
  args = desc_options(ArgumentParser("desc")).parse_args(DescArgs, sys_args)
22
22
  for path in args.input:
23
23
  try:
24
- container = bv.open(path)
24
+ container = av.open(path)
25
25
  desc = container.metadata.get("description", None)
26
26
  except Exception:
27
27
  desc = None
@@ -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 bv
8
+ import av
9
9
  import numpy as np
10
10
 
11
11
  from auto_editor.analyze import *
@@ -36,6 +36,7 @@ class LevelArgs:
36
36
  input: list[str] = field(default_factory=list)
37
37
  edit: str = "audio"
38
38
  timebase: Fraction | None = None
39
+ no_cache: bool = False
39
40
  help: bool = False
40
41
 
41
42
 
@@ -53,16 +54,14 @@ def levels_options(parser: ArgumentParser) -> ArgumentParser:
53
54
  type=frame_rate,
54
55
  help="Set custom timebase",
55
56
  )
57
+ parser.add_argument("--no-cache", flag=True)
56
58
  return parser
57
59
 
58
60
 
59
61
  def print_arr(arr: NDArray) -> None:
60
62
  print("")
61
63
  print("@start")
62
- if arr.dtype in {np.float64, np.float32, np.float16}:
63
- for a in arr:
64
- sys.stdout.write(f"{a:.20f}\n")
65
- elif arr.dtype == np.bool_:
64
+ if arr.dtype == np.bool_:
66
65
  for a in arr:
67
66
  sys.stdout.write(f"{1 if a else 0}\n")
68
67
  else:
@@ -76,7 +75,7 @@ def print_arr_gen(arr: Iterator[float | np.float32]) -> None:
76
75
  print("")
77
76
  print("@start")
78
77
  for a in arr:
79
- print(f"{a:.20f}")
78
+ print(f"{a}")
80
79
  print("")
81
80
 
82
81
 
@@ -131,10 +130,14 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
131
130
  levels = initLevels(src, tb, bar, False, log)
132
131
  try:
133
132
  if method == "audio":
134
- if (arr := levels.read_cache("audio", (obj["stream"],))) is not None:
133
+ if (
134
+ not args.no_cache
135
+ and (arr := levels.read_cache("audio", (obj["stream"],)))
136
+ is not None
137
+ ):
135
138
  print_arr(arr)
136
139
  else:
137
- container = bv.open(src.path, "r")
140
+ container = av.open(src.path, "r")
138
141
  audio_stream = container.streams.audio[obj["stream"]]
139
142
 
140
143
  values = []
@@ -148,14 +151,18 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
148
151
  container.close()
149
152
 
150
153
  cache_array = np.array(values, dtype=np.float32)
151
- levels.cache(cache_array, "audio", (obj["stream"],))
154
+ if not args.no_cache:
155
+ levels.cache(cache_array, "audio", (obj["stream"],))
152
156
 
153
157
  elif method == "motion":
154
158
  mobj = (obj["stream"], obj["width"], obj["blur"])
155
- if (arr := levels.read_cache("motion", mobj)) is not None:
159
+ if (
160
+ not args.no_cache
161
+ and (arr := levels.read_cache("motion", mobj)) is not None
162
+ ):
156
163
  print_arr(arr)
157
164
  else:
158
- container = bv.open(src.path, "r")
165
+ container = av.open(src.path, "r")
159
166
  video_stream = container.streams.video[obj["stream"]]
160
167
 
161
168
  values = []
@@ -171,7 +178,8 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
171
178
  container.close()
172
179
 
173
180
  cache_array = np.array(values, dtype=np.float32)
174
- levels.cache(cache_array, "motion", mobj)
181
+ if not args.no_cache:
182
+ levels.cache(cache_array, "motion", mobj)
175
183
 
176
184
  elif method == "subtitle":
177
185
  print_arr(levels.subtitle(**obj))
@@ -1,8 +1,8 @@
1
1
  import sys
2
2
  from dataclasses import dataclass, field
3
3
 
4
- import bv
5
- from bv.subtitles.subtitle import AssSubtitle
4
+ import av
5
+ from av.subtitles.subtitle import AssSubtitle
6
6
 
7
7
  from auto_editor.json import dump
8
8
  from auto_editor.vanparse import ArgumentParser
@@ -24,7 +24,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
24
24
  if args.json:
25
25
  data = {}
26
26
  for input_file in args.input:
27
- container = bv.open(input_file)
27
+ container = av.open(input_file)
28
28
  for s in range(len(container.streams.subtitles)):
29
29
  entry_data = []
30
30
 
@@ -59,7 +59,7 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
59
59
  return
60
60
 
61
61
  for input_file in args.input:
62
- with bv.open(input_file) as container:
62
+ with av.open(input_file) as container:
63
63
  for s in range(len(container.streams.subtitles)):
64
64
  print(f"file: {input_file} ({s}:{container.streams.subtitles[s].name})")
65
65
  for sub2 in container.decode(subtitles=s):
@@ -11,8 +11,9 @@ from hashlib import sha256
11
11
  from tempfile import mkdtemp
12
12
  from time import perf_counter
13
13
 
14
- import bv
14
+ import av
15
15
  import numpy as np
16
+ from av import AudioStream, VideoStream
16
17
 
17
18
  from auto_editor.ffwrapper import FileInfo
18
19
  from auto_editor.lang.palet import Lexer, Parser, env, interpret
@@ -58,7 +59,7 @@ all_files = (
58
59
  "mov_text.mp4",
59
60
  "testsrc.mkv",
60
61
  )
61
- log = Log()
62
+ log = Log(is_debug=True)
62
63
 
63
64
 
64
65
  def fileinfo(path: str) -> FileInfo:
@@ -103,10 +104,11 @@ class Runner:
103
104
 
104
105
  return output
105
106
 
106
- def raw(self, cmd: list[str]) -> None:
107
+ def raw(self, cmd: list[str]) -> str:
107
108
  returncode, stdout, stderr = pipe_to_console(self.program + cmd)
108
109
  if returncode > 0:
109
110
  raise Exception(f"{stdout}\n{stderr}\n")
111
+ return stdout
110
112
 
111
113
  def check(self, cmd: list[str], match=None) -> None:
112
114
  returncode, stdout, stderr = pipe_to_console(self.program + cmd)
@@ -133,11 +135,13 @@ class Runner:
133
135
 
134
136
  def test_version(self):
135
137
  """Test version flags and debug by itself."""
136
- self.raw(["--version"])
137
- self.raw(["-V"])
138
+ v1 = self.raw(["--version"])
139
+ v2 = self.raw(["-V"])
140
+ assert "." in v1 and len(v1) > 4
141
+ assert v1 == v2
138
142
 
139
143
  def test_parser(self):
140
- self.check(["example.mp4", "--video-speed"], "needs argument")
144
+ self.check(["example.mp4", "--margin"], "needs argument")
141
145
 
142
146
  def info(self):
143
147
  self.raw(["info", "example.mp4"])
@@ -161,36 +165,36 @@ class Runner:
161
165
  file = "resources/testsrc.mp4"
162
166
  out = self.main([file], ["--faststart"]) + ".mp4"
163
167
  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)
168
+ with av.open(out) as container:
169
+ assert isinstance(container.streams[0], VideoStream)
170
+ assert isinstance(container.streams[1], AudioStream)
167
171
 
168
172
  out = self.main([file], ["--no-faststart"]) + ".mp4"
169
173
  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)
174
+ with av.open(out) as container:
175
+ assert isinstance(container.streams[0], VideoStream)
176
+ assert isinstance(container.streams[1], AudioStream)
173
177
 
174
178
  out = self.main([file], ["--fragmented"]) + ".mp4"
175
179
  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)
180
+ with av.open(out) as container:
181
+ assert isinstance(container.streams[0], VideoStream)
182
+ assert isinstance(container.streams[1], AudioStream)
179
183
 
180
184
  assert fast != nofast, "+faststart is not being applied"
181
185
  assert frag not in (fast, nofast), "fragmented output should diff."
182
186
 
183
187
  def test_example(self) -> None:
184
188
  out = self.main(["example.mp4"], [], output="example_ALTERED.mp4")
185
- with bv.open(out) as container:
189
+ with av.open(out) as container:
186
190
  assert container.duration is not None
187
191
  assert container.duration > 17300000 and container.duration < 2 << 24
188
192
 
189
193
  assert len(container.streams) == 2
190
194
  video = container.streams[0]
191
195
  audio = container.streams[1]
192
- assert isinstance(video, bv.VideoStream)
193
- assert isinstance(audio, bv.AudioStream)
196
+ assert isinstance(video, VideoStream)
197
+ assert isinstance(audio, AudioStream)
194
198
  assert video.base_rate == 30
195
199
  assert video.average_rate is not None
196
200
  assert video.average_rate == 30, video.average_rate
@@ -204,28 +208,28 @@ class Runner:
204
208
 
205
209
  def test_video_to_mp3(self) -> None:
206
210
  out = self.main(["example.mp4"], [], output="example_ALTERED.mp3")
207
- with bv.open(out) as container:
211
+ with av.open(out) as container:
208
212
  assert container.duration is not None
209
213
  assert container.duration > 17300000 and container.duration < 2 << 24
210
214
 
211
215
  assert len(container.streams) == 1
212
216
  audio = container.streams[0]
213
- assert isinstance(audio, bv.AudioStream)
217
+ assert isinstance(audio, AudioStream)
214
218
  assert audio.codec.name in ("mp3", "mp3float")
215
219
  assert audio.sample_rate == 48000
216
220
  assert audio.layout.name == "stereo"
217
221
 
218
222
  def test_to_mono(self) -> None:
219
223
  out = self.main(["example.mp4"], ["-layout", "mono"], output="example_mono.mp4")
220
- with bv.open(out) as container:
224
+ with av.open(out) as container:
221
225
  assert container.duration is not None
222
226
  assert container.duration > 17300000 and container.duration < 2 << 24
223
227
 
224
228
  assert len(container.streams) == 2
225
229
  video = container.streams[0]
226
230
  audio = container.streams[1]
227
- assert isinstance(video, bv.VideoStream)
228
- assert isinstance(audio, bv.AudioStream)
231
+ assert isinstance(video, VideoStream)
232
+ assert isinstance(audio, AudioStream)
229
233
  assert video.base_rate == 30
230
234
  assert video.average_rate is not None
231
235
  assert video.average_rate == 30, video.average_rate
@@ -302,18 +306,18 @@ class Runner:
302
306
  self.check([path, "--no-open"], "must have an extension")
303
307
 
304
308
  def test_silent_threshold(self):
305
- with bv.open("resources/new-commentary.mp3") as container:
309
+ with av.open("resources/new-commentary.mp3") as container:
306
310
  assert container.duration is not None
307
- assert container.duration / bv.time_base == 6.732
311
+ assert container.duration / av.time_base == 6.732
308
312
 
309
313
  out = self.main(
310
314
  ["resources/new-commentary.mp3"], ["--edit", "audio:threshold=0.1"]
311
315
  )
312
316
  out += ".mp3"
313
317
 
314
- with bv.open(out) as container:
318
+ with av.open(out) as container:
315
319
  assert container.duration is not None
316
- assert container.duration / bv.time_base == 6.552
320
+ assert container.duration / av.time_base == 6.552
317
321
 
318
322
  def test_track(self):
319
323
  out = self.main(["resources/multi-track.mov"], []) + ".mov"
@@ -9,7 +9,8 @@ from pathlib import Path
9
9
  from subprocess import run
10
10
  from typing import TYPE_CHECKING, Any
11
11
 
12
- import bv
12
+ import av
13
+ from av import Codec
13
14
 
14
15
  from auto_editor.ffwrapper import FileInfo
15
16
  from auto_editor.lib.contracts import is_int, is_str
@@ -29,8 +30,8 @@ if TYPE_CHECKING:
29
30
 
30
31
 
31
32
  def set_output(
32
- out: str | None, _export: str | None, path: Path | None, log: Log
33
- ) -> tuple[str, dict[str, Any]]:
33
+ out: str | None, export: str | None, path: Path | None, log: Log
34
+ ) -> tuple[str, str]:
34
35
  if out is None or out == "-":
35
36
  if path is None:
36
37
  log.error("`--output` must be set.") # When a timeline file is the input.
@@ -42,33 +43,37 @@ def set_output(
42
43
  # Use `mp4` as the default, because it is most compatible.
43
44
  ext = ".mp4" if path is None else path.suffix
44
45
 
45
- if _export is None:
46
+ if export is None:
46
47
  match ext:
47
48
  case ".xml":
48
- export: dict[str, Any] = {"export": "premiere"}
49
+ export = "premiere"
49
50
  case ".fcpxml":
50
- export = {"export": "final-cut-pro"}
51
+ export = "final-cut-pro"
51
52
  case ".mlt":
52
- export = {"export": "shotcut"}
53
+ export = "shotcut"
54
+ case ".kdenlive":
55
+ export = "kdenlive"
53
56
  case ".json" | ".v1":
54
- export = {"export": "v1"}
57
+ export = "v1"
55
58
  case ".v3":
56
- export = {"export": "v3"}
59
+ export = "v3"
57
60
  case _:
58
- export = {"export": "default"}
59
- else:
60
- export = parse_export(_export, log)
61
-
62
- ext_map = {
63
- "premiere": ".xml",
64
- "resolve-fcp7": ".xml",
65
- "final-cut-pro": ".fcpxml",
66
- "resolve": ".fcpxml",
67
- "shotcut": ".mlt",
68
- "json": ".json",
69
- }
70
- if export["export"] in ext_map:
71
- ext = ext_map[export["export"]]
61
+ export = "default"
62
+
63
+ match export:
64
+ case "premiere" | "resolve-fcp7":
65
+ ext = ".xml"
66
+ case "final-cut-pro" | "resolve":
67
+ ext = ".fcpxml"
68
+ case "shotcut":
69
+ ext = ".mlt"
70
+ case "kdenlive":
71
+ ext = ".kdenlive"
72
+ case "v1":
73
+ if ext != ".json":
74
+ ext = ".v1"
75
+ case "v3":
76
+ ext = ".v3"
72
77
 
73
78
  if out == "-":
74
79
  return "-", export
@@ -79,9 +84,6 @@ def set_output(
79
84
  return f"{root}{ext}", export
80
85
 
81
86
 
82
- codec_error = "'{}' codec is not supported in '{}' container."
83
-
84
-
85
87
  def set_video_codec(
86
88
  codec: str, src: FileInfo | None, out_ext: str, ctr: Container, log: Log
87
89
  ) -> str:
@@ -93,12 +95,14 @@ def set_video_codec(
93
95
 
94
96
  if ctr.vcodecs is not None and codec not in ctr.vcodecs:
95
97
  try:
96
- cobj = bv.Codec(codec, "w")
97
- except bv.codec.codec.UnknownCodecError:
98
+ cobj = Codec(codec, "w")
99
+ except av.codec.codec.UnknownCodecError:
98
100
  log.error(f"Unknown encoder: {codec}")
99
101
  # Normalize encoder names
100
- if cobj.id not in (bv.Codec(x, "w").id for x in ctr.vcodecs):
101
- log.error(codec_error.format(codec, out_ext))
102
+ if cobj.id not in (Codec(x, "w").id for x in ctr.vcodecs):
103
+ log.error(
104
+ f"'{codec}' video encoder is not supported in the '{out_ext}' container"
105
+ )
102
106
 
103
107
  return codec
104
108
 
@@ -111,7 +115,7 @@ def set_audio_codec(
111
115
  codec = "aac"
112
116
  else:
113
117
  codec = src.audios[0].codec
114
- if bv.Codec(codec, "w").audio_formats is None:
118
+ if Codec(codec, "w").audio_formats is None:
115
119
  codec = "aac"
116
120
  if codec not in ctr.acodecs and ctr.default_aud != "none":
117
121
  codec = ctr.default_aud
@@ -121,13 +125,14 @@ def set_audio_codec(
121
125
 
122
126
  if ctr.acodecs is None or codec not in ctr.acodecs:
123
127
  try:
124
- cobj = bv.Codec(codec, "w")
125
- except bv.codec.codec.UnknownCodecError:
128
+ cobj = Codec(codec, "w")
129
+ except av.codec.codec.UnknownCodecError:
126
130
  log.error(f"Unknown encoder: {codec}")
127
131
  # Normalize encoder names
128
- if cobj.id not in (bv.Codec(x, "w").id for x in ctr.acodecs):
129
- log.error(codec_error.format(codec, out_ext))
130
-
132
+ if cobj.id not in (Codec(x, "w").id for x in ctr.acodecs):
133
+ log.error(
134
+ f"'{codec}' audio encoder is not supported in the '{out_ext}' container"
135
+ )
131
136
  return codec
132
137
 
133
138
 
@@ -148,6 +153,7 @@ def parse_export(export: str, log: Log) -> dict[str, Any]:
148
153
  "resolve": pAttrs("resolve", name_attr),
149
154
  "resolve-fcp7": pAttrs("resolve-fcp7", name_attr),
150
155
  "shotcut": pAttrs("shotcut"),
156
+ "kdenlive": pAttrs("kdenlive"),
151
157
  "v1": pAttrs("v1"),
152
158
  "v3": pAttrs("v3"),
153
159
  "clip-sequence": pAttrs("clip-sequence"),
@@ -184,9 +190,13 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
184
190
  src = sources[0]
185
191
  use_path = src.path
186
192
 
187
- output, export_ops = set_output(args.output, args.export, use_path, log)
188
- assert "export" in export_ops
189
- export = export_ops["export"]
193
+ if args.export is None:
194
+ output, export = set_output(args.output, args.export, use_path, log)
195
+ export_ops: dict[str, Any] = {"export": export}
196
+ else:
197
+ export_ops = parse_export(args.export, log)
198
+ export = export_ops["export"]
199
+ output, _ = set_output(args.output, export, use_path, log)
190
200
 
191
201
  if output == "-":
192
202
  # When printing to stdout, silence all logs.
@@ -257,6 +267,12 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
257
267
  shotcut_write_mlt(output, tl)
258
268
  return
259
269
 
270
+ if export == "kdenlive":
271
+ from auto_editor.exports.kdenlive import kdenlive_write
272
+
273
+ kdenlive_write(output, tl)
274
+ return
275
+
260
276
  if output == "-":
261
277
  log.error("Exporting media files to stdout is not supported.")
262
278
  out_ext = splitext(output)[1].replace(".", "")
@@ -283,26 +299,26 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
283
299
  if mov_flags:
284
300
  options["movflags"] = "+".join(mov_flags)
285
301
 
286
- output = bv.open(output_path, "w", container_options=options)
302
+ output = av.open(output_path, "w", container_options=options)
287
303
 
288
304
  # Setup video
289
305
  if ctr.default_vid not in ("none", "png") and tl.v:
290
306
  vframes = render_av(output, tl, args, log)
291
- output_stream: bv.VideoStream | None
307
+ output_stream: av.VideoStream | None
292
308
  output_stream = next(vframes) # type: ignore
293
309
  else:
294
310
  output_stream, vframes = None, iter([])
295
311
 
296
312
  # Setup audio
297
313
  try:
298
- audio_encoder = bv.Codec(args.audio_codec, "w")
299
- except bv.FFmpegError as e:
314
+ audio_encoder = Codec(args.audio_codec, "w")
315
+ except av.FFmpegError as e:
300
316
  log.error(e)
301
317
  if audio_encoder.audio_formats is None:
302
318
  log.error(f"{args.audio_codec}: No known audio formats avail.")
303
319
  fmt = audio_encoder.audio_formats[0]
304
320
 
305
- audio_streams: list[bv.AudioStream] = []
321
+ audio_streams: list[av.AudioStream] = []
306
322
 
307
323
  if ctr.default_aud == "none":
308
324
  while len(tl.a) > 0:
@@ -329,7 +345,7 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
329
345
  sub_gen_frames = []
330
346
 
331
347
  for i, sub_path in enumerate(sub_paths):
332
- subtitle_input = bv.open(sub_path)
348
+ subtitle_input = av.open(sub_path)
333
349
  subtitle_inputs.append(subtitle_input)
334
350
  subtitle_stream = output.add_stream_from_template(
335
351
  subtitle_input.streams.subtitles[0]
@@ -456,14 +472,14 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
456
472
  output.mux(item.stream.encode(item.frame))
457
473
  elif frame_type == "subtitle":
458
474
  output.mux(item.frame)
459
- except bv.error.ExternalError:
475
+ except av.error.ExternalError:
460
476
  log.error(
461
477
  f"Generic error for encoder: {item.stream.name}\n"
462
478
  f"at {item.index} time_base\nPerhaps video quality settings are too low?"
463
479
  )
464
- except bv.FileNotFoundError:
480
+ except av.FileNotFoundError:
465
481
  log.error(f"File not found: {output_path}")
466
- except bv.FFmpegError as e:
482
+ except av.FFmpegError as e:
467
483
  log.error(e)
468
484
 
469
485
  if bar_index: