auto-editor 26.0.1__py3-none-any.whl → 26.1.1__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.0.1"
1
+ __version__ = "26.1.1"
auto_editor/__main__.py CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ import platform as plat
3
4
  import re
4
5
  import sys
5
6
  from os import environ
@@ -7,8 +8,6 @@ from os.path import exists, isdir, isfile, lexists, splitext
7
8
  from subprocess import run
8
9
 
9
10
  import auto_editor
10
- from auto_editor.edit import edit_media
11
- from auto_editor.ffwrapper import FFmpeg
12
11
  from auto_editor.utils.func import get_stdout
13
12
  from auto_editor.utils.log import Log
14
13
  from auto_editor.utils.types import (
@@ -34,13 +33,12 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
34
33
  "-m",
35
34
  type=margin,
36
35
  metavar="LENGTH",
37
- help='Set sections near "loud" as "loud" too if section is less than LENGTH away.',
36
+ help='Set sections near "loud" as "loud" too if section is less than LENGTH away',
38
37
  )
39
38
  parser.add_argument(
40
- "--edit-based-on",
41
39
  "--edit",
42
40
  metavar="METHOD",
43
- help="Decide which method to use when making edits",
41
+ help="Set an expression which determines how to make auto edits",
44
42
  )
45
43
  parser.add_argument(
46
44
  "--silent-speed",
@@ -148,7 +146,7 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
148
146
  "--output",
149
147
  "-o",
150
148
  metavar="FILE",
151
- help="Set the name/path of the new output file.",
149
+ help="Set the name/path of the new output file",
152
150
  )
153
151
  parser.add_argument(
154
152
  "--player", "-p", metavar="CMD", help="Set player to open output media files"
@@ -161,11 +159,6 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
161
159
  metavar="PATH",
162
160
  help="Set where the temporary directory is located",
163
161
  )
164
- parser.add_argument(
165
- "--ffmpeg-location",
166
- metavar="PATH",
167
- help="Set a custom path to the ffmpeg location",
168
- )
169
162
  parser.add_text("Display Options:")
170
163
  parser.add_argument(
171
164
  "--progress",
@@ -241,11 +234,6 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
241
234
  flag=True,
242
235
  help="Disable the inclusion of data streams in the output file",
243
236
  )
244
- parser.add_argument(
245
- "--extras",
246
- metavar="CMD",
247
- help="Add extra options for ffmpeg. Must be in quotes",
248
- )
249
237
  parser.add_argument(
250
238
  "--config", flag=True, help="When set, look for `config.pal` and run it"
251
239
  )
@@ -256,7 +244,7 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
256
244
  return parser
257
245
 
258
246
 
259
- def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
247
+ def download_video(my_input: str, args: Args, log: Log) -> str:
260
248
  log.conwrite("Downloading video...")
261
249
 
262
250
  def get_domain(url: str) -> str:
@@ -272,18 +260,15 @@ def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
272
260
  else:
273
261
  output_format = args.output_format
274
262
 
275
- yt_dlp_path = args.yt_dlp_location
276
-
277
- cmd = ["--ffmpeg-location", ffmpeg.get_path("yt-dlp", log)]
278
-
263
+ cmd = []
279
264
  if download_format is not None:
280
265
  cmd.extend(["-f", download_format])
281
266
 
282
267
  cmd.extend(["-o", output_format, my_input])
283
-
284
268
  if args.yt_dlp_extras is not None:
285
269
  cmd.extend(args.yt_dlp_extras.split(" "))
286
270
 
271
+ yt_dlp_path = args.yt_dlp_location
287
272
  try:
288
273
  location = get_stdout(
289
274
  [yt_dlp_path, "--get-filename", "--no-warnings"] + cmd
@@ -301,7 +286,16 @@ def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
301
286
 
302
287
 
303
288
  def main() -> None:
304
- subcommands = ("test", "info", "levels", "subdump", "desc", "repl", "palet")
289
+ subcommands = (
290
+ "test",
291
+ "info",
292
+ "levels",
293
+ "subdump",
294
+ "desc",
295
+ "repl",
296
+ "palet",
297
+ "cache",
298
+ )
305
299
 
306
300
  if len(sys.argv) > 1 and sys.argv[1] in subcommands:
307
301
  obj = __import__(
@@ -326,6 +320,7 @@ def main() -> None:
326
320
  ({"--export-as-json"}, ["--export", "json"]),
327
321
  ({"--export-as-clip-sequence", "-excs"}, ["--export", "clip-sequence"]),
328
322
  ({"--keep-tracks-seperate"}, ["--keep-tracks-separate"]),
323
+ ({"--edit-based-on"}, ["--edit"]),
329
324
  ],
330
325
  )
331
326
 
@@ -334,15 +329,17 @@ def main() -> None:
334
329
  return
335
330
 
336
331
  if args.debug and not args.input:
337
- import platform as plat
332
+ print(f"OS: {plat.system()} {plat.release()} {plat.machine().lower()}")
333
+ print(f"Python: {plat.python_version()}")
338
334
 
339
- import av
335
+ try:
336
+ import av
340
337
 
341
- license = av._core.library_meta["libavcodec"]["license"]
338
+ license = av._core.library_meta["libavcodec"]["license"]
339
+ print(f"PyAV: {av.__version__} ({license})")
340
+ except (ModuleNotFoundError, ImportError):
341
+ print("PyAV: error")
342
342
 
343
- print(f"OS: {plat.system()} {plat.release()} {plat.machine().lower()}")
344
- print(f"Python: {plat.python_version()}")
345
- print(f"PyAV: {av.__version__} ({license})")
346
343
  print(f"Auto-Editor: {auto_editor.__version__}")
347
344
  return
348
345
 
@@ -352,11 +349,10 @@ def main() -> None:
352
349
  is_machine = args.progress == "machine"
353
350
  log = Log(args.debug, args.quiet, args.temp_dir, is_machine, no_color)
354
351
 
355
- ffmpeg = FFmpeg(args.ffmpeg_location)
356
352
  paths = []
357
353
  for my_input in args.input:
358
354
  if my_input.startswith("http://") or my_input.startswith("https://"):
359
- paths.append(download_video(my_input, args, ffmpeg, log))
355
+ paths.append(download_video(my_input, args, log))
360
356
  else:
361
357
  if not splitext(my_input)[1]:
362
358
  if isdir(my_input):
@@ -369,8 +365,10 @@ def main() -> None:
369
365
  log.error(f"Option/Input file doesn't exist: {my_input}")
370
366
  paths.append(my_input)
371
367
 
368
+ from auto_editor.edit import edit_media
369
+
372
370
  try:
373
- edit_media(paths, ffmpeg, args, log)
371
+ edit_media(paths, args, log)
374
372
  except KeyboardInterrupt:
375
373
  log.error("Keyboard Interrupt")
376
374
  log.cleanup()
auto_editor/analyze.py CHANGED
@@ -4,6 +4,7 @@ import os
4
4
  import re
5
5
  from dataclasses import dataclass
6
6
  from fractions import Fraction
7
+ from hashlib import sha1
7
8
  from math import ceil
8
9
  from tempfile import gettempdir
9
10
  from typing import TYPE_CHECKING
@@ -27,6 +28,9 @@ if TYPE_CHECKING:
27
28
  from auto_editor.utils.log import Log
28
29
 
29
30
 
31
+ __all__ = ("LevelError", "Levels", "iter_audio", "iter_motion")
32
+
33
+
30
34
  class LevelError(Exception):
31
35
  pass
32
36
 
@@ -69,45 +73,39 @@ def mut_remove_large(
69
73
  active = False
70
74
 
71
75
 
72
- def iter_audio(src, tb: Fraction, stream: int = 0) -> Iterator[np.float32]:
76
+ def iter_audio(audio_stream: av.AudioStream, tb: Fraction) -> Iterator[np.float32]:
73
77
  fifo = AudioFifo()
74
- try:
75
- container = av.open(src.path, "r")
76
- audio_stream = container.streams.audio[stream]
77
- sample_rate = audio_stream.rate
78
+ sr = audio_stream.rate
78
79
 
79
- exact_size = (1 / tb) * sample_rate
80
- accumulated_error = 0
80
+ exact_size = (1 / tb) * sr
81
+ accumulated_error = Fraction(0)
81
82
 
82
- # Resample so that audio data is between [-1, 1]
83
- resampler = av.AudioResampler(
84
- av.AudioFormat("flt"), audio_stream.layout, sample_rate
85
- )
83
+ # Resample so that audio data is between [-1, 1]
84
+ resampler = av.AudioResampler(av.AudioFormat("flt"), audio_stream.layout, sr)
86
85
 
87
- for frame in container.decode(audio=stream):
88
- frame.pts = None # Skip time checks
86
+ container = audio_stream.container
87
+ assert isinstance(container, av.container.InputContainer)
89
88
 
90
- for reframe in resampler.resample(frame):
91
- fifo.write(reframe)
89
+ for frame in container.decode(audio_stream):
90
+ frame.pts = None # Skip time checks
92
91
 
93
- while fifo.samples >= ceil(exact_size):
94
- size_with_error = exact_size + accumulated_error
95
- current_size = round(size_with_error)
96
- accumulated_error = size_with_error - current_size
92
+ for reframe in resampler.resample(frame):
93
+ fifo.write(reframe)
97
94
 
98
- audio_chunk = fifo.read(current_size)
99
- assert audio_chunk is not None
100
- arr = audio_chunk.to_ndarray().flatten()
101
- yield np.max(np.abs(arr))
95
+ while fifo.samples >= ceil(exact_size):
96
+ size_with_error = exact_size + accumulated_error
97
+ current_size = round(size_with_error)
98
+ accumulated_error = size_with_error - current_size
102
99
 
103
- finally:
104
- container.close()
100
+ audio_chunk = fifo.read(current_size)
101
+ assert audio_chunk is not None
102
+ arr = audio_chunk.to_ndarray().flatten()
103
+ yield np.max(np.abs(arr))
105
104
 
106
105
 
107
- def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.float32]:
108
- container = av.open(src.path, "r")
109
-
110
- video = container.streams.video[stream]
106
+ def iter_motion(
107
+ video: av.VideoStream, tb: Fraction, blur: int, width: int
108
+ ) -> Iterator[np.float32]:
111
109
  video.thread_type = "AUTO"
112
110
 
113
111
  prev_frame = None
@@ -125,6 +123,9 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
125
123
  graph.add("buffersink"),
126
124
  ).configure()
127
125
 
126
+ container = video.container
127
+ assert isinstance(container, av.container.InputContainer)
128
+
128
129
  for unframe in container.decode(video):
129
130
  if unframe.pts is None:
130
131
  continue
@@ -151,13 +152,13 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
151
152
  prev_frame = current_frame
152
153
  prev_index = index
153
154
 
154
- container.close()
155
-
156
155
 
157
156
  def obj_tag(path: Path, kind: str, tb: Fraction, obj: Sequence[object]) -> str:
158
157
  mod_time = int(path.stat().st_mtime)
159
- key = f"{path.name}:{mod_time:x}:{kind}:{tb}:"
160
- return key + ",".join(f"{v}" for v in obj)
158
+ key = f"{path}:{mod_time:x}:{tb}:" + ",".join(f"{v}" for v in obj)
159
+ part1 = sha1(key.encode()).hexdigest()[:16]
160
+
161
+ return f"{part1}{kind}"
161
162
 
162
163
 
163
164
  @dataclass(slots=True)
@@ -175,7 +176,11 @@ class Levels:
175
176
  if (arr := self.read_cache("audio", (0,))) is not None:
176
177
  return len(arr)
177
178
 
178
- result = sum(1 for _ in iter_audio(self.src, self.tb, 0))
179
+ with av.open(self.src.path, "r") as container:
180
+ audio_stream = container.streams.audio[0]
181
+ self.log.experimental(audio_stream.codec)
182
+ result = sum(1 for _ in iter_audio(audio_stream, self.tb))
183
+
179
184
  self.log.debug(f"Audio Length: {result}")
180
185
  return result
181
186
 
@@ -204,31 +209,47 @@ class Levels:
204
209
  if self.no_cache:
205
210
  return None
206
211
 
207
- workfile = os.path.join(gettempdir(), f"ae-{__version__}", "cache.npz")
212
+ key = obj_tag(self.src.path, kind, self.tb, obj)
213
+ cache_file = os.path.join(gettempdir(), f"ae-{__version__}", f"{key}.npz")
208
214
 
209
215
  try:
210
- npzfile = np.load(workfile, allow_pickle=False)
216
+ with np.load(cache_file, allow_pickle=False) as npzfile:
217
+ return npzfile["data"]
211
218
  except Exception as e:
212
219
  self.log.debug(e)
213
220
  return None
214
221
 
215
- key = obj_tag(self.src.path, kind, self.tb, obj)
216
- if key not in npzfile.files:
217
- return None
218
-
219
- self.log.debug("Using cache")
220
- return npzfile[key]
221
-
222
222
  def cache(self, arr: np.ndarray, kind: str, obj: Sequence[object]) -> np.ndarray:
223
223
  if self.no_cache:
224
224
  return arr
225
225
 
226
- workdur = os.path.join(gettempdir(), f"ae-{__version__}")
227
- if not os.path.exists(workdur):
228
- os.mkdir(workdur)
226
+ workdir = os.path.join(gettempdir(), f"ae-{__version__}")
227
+ if not os.path.exists(workdir):
228
+ os.mkdir(workdir)
229
229
 
230
230
  key = obj_tag(self.src.path, kind, self.tb, obj)
231
- np.savez(os.path.join(workdur, "cache.npz"), **{key: arr})
231
+ cache_file = os.path.join(workdir, f"{key}.npz")
232
+
233
+ try:
234
+ np.savez(cache_file, data=arr)
235
+ except Exception as e:
236
+ self.log.warning(f"Cache write failed: {e}")
237
+
238
+ cache_entries = []
239
+ with os.scandir(workdir) as entries:
240
+ for entry in entries:
241
+ if entry.name.endswith(".npz"):
242
+ cache_entries.append((entry.path, entry.stat().st_mtime))
243
+
244
+ if len(cache_entries) > 10:
245
+ # Sort by modification time, oldest first
246
+ cache_entries.sort(key=lambda x: x[1])
247
+ # Remove oldest files until we're back to 10
248
+ for filepath, _ in cache_entries[:-10]:
249
+ try:
250
+ os.remove(filepath)
251
+ except OSError:
252
+ pass
232
253
 
233
254
  return arr
234
255
 
@@ -239,30 +260,37 @@ class Levels:
239
260
  if (arr := self.read_cache("audio", (stream,))) is not None:
240
261
  return arr
241
262
 
242
- with av.open(self.src.path, "r") as container:
243
- audio = container.streams.audio[stream]
244
- if audio.duration is not None and audio.time_base is not None:
245
- inaccurate_dur = int(audio.duration * audio.time_base * self.tb)
246
- elif container.duration is not None:
247
- inaccurate_dur = int(container.duration / av.time_base * self.tb)
248
- else:
249
- inaccurate_dur = 1024
263
+ container = av.open(self.src.path, "r")
264
+ audio = container.streams.audio[stream]
265
+
266
+ if audio.codec.experimental:
267
+ self.log.error(f"`{audio.codec.name}` is an experimental codec")
268
+
269
+ if audio.duration is not None and audio.time_base is not None:
270
+ inaccurate_dur = int(audio.duration * audio.time_base * self.tb)
271
+ elif container.duration is not None:
272
+ inaccurate_dur = int(container.duration / av.time_base * self.tb)
273
+ else:
274
+ inaccurate_dur = 1024
250
275
 
251
276
  bar = self.bar
252
277
  bar.start(inaccurate_dur, "Analyzing audio volume")
253
278
 
254
- result = np.zeros((inaccurate_dur), dtype=np.float32)
279
+ result: NDArray[np.float32] = np.zeros(inaccurate_dur, dtype=np.float32)
255
280
  index = 0
256
- for value in iter_audio(self.src, self.tb, stream):
281
+
282
+ for value in iter_audio(audio, self.tb):
257
283
  if index > len(result) - 1:
258
284
  result = np.concatenate(
259
- (result, np.zeros((len(result)), dtype=np.float32))
285
+ (result, np.zeros(len(result), dtype=np.float32))
260
286
  )
287
+
261
288
  result[index] = value
262
289
  bar.tick(index)
263
290
  index += 1
264
291
 
265
292
  bar.end()
293
+ assert len(result) > 0
266
294
  return self.cache(result[:index], "audio", (stream,))
267
295
 
268
296
  def motion(self, stream: int, blur: int, width: int) -> NDArray[np.float32]:
@@ -273,23 +301,28 @@ class Levels:
273
301
  if (arr := self.read_cache("motion", mobj)) is not None:
274
302
  return arr
275
303
 
276
- with av.open(self.src.path, "r") as container:
277
- video = container.streams.video[stream]
278
- inaccurate_dur = (
279
- 1024
280
- if video.duration is None or video.time_base is None
281
- else int(video.duration * video.time_base * self.tb)
282
- )
304
+ container = av.open(self.src.path, "r")
305
+ video = container.streams.video[stream]
306
+
307
+ if video.codec.experimental:
308
+ self.log.experimental(video.codec)
309
+
310
+ inaccurate_dur = (
311
+ 1024
312
+ if video.duration is None or video.time_base is None
313
+ else int(video.duration * video.time_base * self.tb)
314
+ )
283
315
 
284
316
  bar = self.bar
285
317
  bar.start(inaccurate_dur, "Analyzing motion")
286
318
 
287
- result = np.zeros((inaccurate_dur), dtype=np.float32)
319
+ result: NDArray[np.float32] = np.zeros(inaccurate_dur, dtype=np.float32)
288
320
  index = 0
289
- for value in iter_motion(self.src, self.tb, stream, blur, width):
321
+
322
+ for value in iter_motion(video, self.tb, blur, width):
290
323
  if index > len(result) - 1:
291
324
  result = np.concatenate(
292
- (result, np.zeros((len(result)), dtype=np.float32))
325
+ (result, np.zeros(len(result), dtype=np.float32))
293
326
  )
294
327
  result[index] = value
295
328
  bar.tick(index)
auto_editor/edit.py CHANGED
@@ -10,7 +10,7 @@ from typing import Any
10
10
  import av
11
11
  from av import AudioResampler
12
12
 
13
- from auto_editor.ffwrapper import FFmpeg, FileInfo, initFileInfo
13
+ from auto_editor.ffwrapper import FileInfo, initFileInfo
14
14
  from auto_editor.lib.contracts import is_int, is_str
15
15
  from auto_editor.make_layers import clipify, make_av, make_timeline
16
16
  from auto_editor.output import Ensure, parse_bitrate
@@ -160,7 +160,7 @@ def parse_export(export: str, log: Log) -> dict[str, Any]:
160
160
  log.error(f"'{name}': Export must be [{', '.join([s for s in parsing.keys()])}]")
161
161
 
162
162
 
163
- def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
163
+ def edit_media(paths: list[str], args: Args, log: Log) -> None:
164
164
  bar = initBar(args.progress)
165
165
  tl = None
166
166
 
@@ -294,7 +294,7 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
294
294
 
295
295
  if ctr.default_aud != "none":
296
296
  ensure = Ensure(bar, samplerate, log)
297
- audio_paths = make_new_audio(tl, ctr, ensure, args, ffmpeg, bar, log)
297
+ audio_paths = make_new_audio(tl, ctr, ensure, args, bar, log)
298
298
  else:
299
299
  audio_paths = []
300
300
 
@@ -343,8 +343,8 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
343
343
  for i, sub_path in enumerate(sub_paths):
344
344
  subtitle_input = av.open(sub_path)
345
345
  subtitle_inputs.append(subtitle_input)
346
- subtitle_stream = output.add_stream(
347
- template=subtitle_input.streams.subtitles[0]
346
+ subtitle_stream = output.add_stream_from_template(
347
+ subtitle_input.streams.subtitles[0]
348
348
  )
349
349
  if i < len(src.subtitles) and src.subtitles[i].lang is not None:
350
350
  subtitle_stream.metadata["language"] = src.subtitles[i].lang # type: ignore
auto_editor/ffwrapper.py CHANGED
@@ -3,40 +3,12 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from fractions import Fraction
5
5
  from pathlib import Path
6
- from shutil import which
7
- from subprocess import PIPE, Popen
8
6
 
9
7
  import av
10
8
 
11
9
  from auto_editor.utils.log import Log
12
10
 
13
11
 
14
- def _get_ffmpeg(reason: str, ffloc: str | None, log: Log) -> str:
15
- program = "ffmpeg" if ffloc is None else ffloc
16
- if (path := which(program)) is None:
17
- log.error(f"{reason} needs ffmpeg cli but couldn't find ffmpeg on PATH.")
18
- return path
19
-
20
-
21
- @dataclass(slots=True)
22
- class FFmpeg:
23
- ffmpeg_location: str | None
24
- path: str | None = None
25
-
26
- def get_path(self, reason: str, log: Log) -> str:
27
- if self.path is not None:
28
- return self.path
29
-
30
- self.path = _get_ffmpeg(reason, self.ffmpeg_location, log)
31
- return self.path
32
-
33
- def Popen(self, reason: str, cmd: list[str], log: Log) -> Popen:
34
- if self.path is None:
35
- self.path = _get_ffmpeg(reason, self.ffmpeg_location, log)
36
-
37
- return Popen([self.path] + cmd, stdout=PIPE, stderr=PIPE)
38
-
39
-
40
12
  def mux(input: Path, output: Path, stream: int) -> None:
41
13
  input_container = av.open(input, "r")
42
14
  output_container = av.open(output, "w")
auto_editor/help.py CHANGED
@@ -24,10 +24,23 @@ example:
24
24
  will set the speed from 400 ticks to 800 ticks to 2.5x
25
25
  If timebase is 30, 400 ticks to 800 means 13.33 to 26.66 seconds
26
26
  """.strip(),
27
- "--edit-based-on": """
27
+ "--edit": """
28
28
  Evaluates a palet expression that returns a bool-array?. The array is then used for
29
29
  editing.
30
30
 
31
+ Examples:
32
+ --edit audio
33
+ --edit audio:0.03 ; Change the threshold. Can be a value between 0-1.
34
+ --edit audio:3% ; You can also use the `%` macro.
35
+ --edit audio:0.03,stream=0 ; Only consider the first stream for editing.
36
+ --edit audio:stream=1,threshold=0.05 ; Here's how you use keyword arguments.
37
+ --edit (or audio:0.04,stream=0 audio:0.08,stream=1) ; Consider both streams for editing (merge with logical or), but with different thresholds.
38
+ --edit motion
39
+ --edit motion:0.02,blur=3
40
+ --edit (or audio:0.04 motion:0.02,blur=3)
41
+ --edit none
42
+ --edit all/e
43
+
31
44
  Editing Methods:
32
45
  - audio ; Audio silence/loudness detection
33
46
  - threshold threshold? : 4%
@@ -52,19 +65,6 @@ Editing Methods:
52
65
 
53
66
  - none ; Do not modify the media in anyway; mark all sections as "loud" (1).
54
67
  - all/e ; Cut out everything out; mark all sections as "silent" (0).
55
-
56
-
57
- Command-line Examples:
58
- --edit audio
59
- --edit audio:threshold=4%
60
- --edit audio:threshold=0.03
61
- --edit audio:stream=1
62
- --edit (or audio:4%,stream=0 audio:8%,stream=1) ; `threshold` is first
63
- --edit motion
64
- --edit motion:threshold=2%,blur=3
65
- --edit (or audio:4% motion:2%,blur=3)
66
- --edit none
67
- --edit all/e
68
68
  """.strip(),
69
69
  "--export": """
70
70
  This option controls how timelines are exported.
@@ -144,8 +144,6 @@ If not set, tempdir will be set with Python's tempfile module
144
144
  The directory doesn't have to exist beforehand, however, the root path must be valid.
145
145
  Beware that the temp directory can get quite big.
146
146
  """.strip(),
147
- "--ffmpeg-location": "This takes precedence over `--my-ffmpeg`.",
148
- "--my-ffmpeg": "This is equivalent to `--ffmpeg-location ffmpeg`.",
149
147
  "--audio-bitrate": """
150
148
  `--audio-bitrate` sets the target bitrate for the audio encoder.
151
149
  By default, the value is `auto` (let the encoder decide).
@@ -139,7 +139,7 @@ def make_timeline(
139
139
 
140
140
  for i, src in enumerate(sources):
141
141
  try:
142
- parser = Parser(Lexer("`--edit`", args.edit_based_on))
142
+ parser = Parser(Lexer("`--edit`", args.edit))
143
143
  if log.is_debug:
144
144
  log.debug(f"edit: {parser}")
145
145
 
@@ -169,6 +169,8 @@ def make_timeline(
169
169
  has_loud = concat((has_loud, result))
170
170
  src_index = concat((src_index, np.full(len(result), i, dtype=np.int32)))
171
171
 
172
+ assert len(has_loud) > 0
173
+
172
174
  # Setup for handling custom speeds
173
175
  speed_index = has_loud.astype(np.uint)
174
176
  speed_map = [args.silent_speed, args.video_speed]
@@ -2,12 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import io
4
4
  from pathlib import Path
5
- from platform import system
6
5
 
7
6
  import av
8
7
  import numpy as np
8
+ from av.filter.loudnorm import stats
9
9
 
10
- from auto_editor.ffwrapper import FFmpeg, FileInfo
10
+ from auto_editor.ffwrapper import FileInfo
11
11
  from auto_editor.lang.json import Lexer, Parser
12
12
  from auto_editor.lang.palet import env
13
13
  from auto_editor.lib.contracts import andc, between_c, is_int_or_float
@@ -56,25 +56,11 @@ def parse_norm(norm: str, log: Log) -> dict | None:
56
56
  log.error(e)
57
57
 
58
58
 
59
- def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> tuple[str, str]:
60
- start = end = 0
61
- lines = stderr.splitlines()
62
-
63
- for index, line in enumerate(lines):
64
- if line.startswith(b"[Parsed_loudnorm"):
65
- start = index + 1
66
- continue
67
- if start != 0 and line.startswith(b"}"):
68
- end = index + 1
69
- break
70
-
71
- if start == 0 or end == 0:
72
- log.error(f"Invalid loudnorm stats.\n{stderr!r}")
73
-
59
+ def parse_ebu_bytes(norm: dict, stat: bytes, log: Log) -> tuple[str, str]:
74
60
  try:
75
- parsed = Parser(Lexer("loudnorm", b"\n".join(lines[start:end]))).expr()
61
+ parsed = Parser(Lexer("loudnorm", stat)).expr()
76
62
  except MyError:
77
- log.error(f"Invalid loudnorm stats.\n{start=},{end=}\n{stderr!r}")
63
+ log.error(f"Invalid loudnorm stats.\n{stat!r}")
78
64
 
79
65
  for key in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
80
66
  val = float(parsed[key])
@@ -101,29 +87,17 @@ def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> tuple[str, str]:
101
87
 
102
88
 
103
89
  def apply_audio_normalization(
104
- ffmpeg: FFmpeg, norm: dict, pre_master: Path, path: Path, log: Log
90
+ norm: dict, pre_master: Path, path: Path, log: Log
105
91
  ) -> None:
106
92
  if norm["tag"] == "ebu":
107
93
  first_pass = (
108
- f"loudnorm=i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:"
109
- f"offset={norm['gain']}:print_format=json"
94
+ f"i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:" f"offset={norm['gain']}"
110
95
  )
111
96
  log.debug(f"audio norm first pass: {first_pass}")
112
- file_null = "NUL" if system() in ("Windows", "cli") else "/dev/null"
113
- cmd = [
114
- "-hide_banner",
115
- "-i",
116
- f"{pre_master}",
117
- "-af",
118
- first_pass,
119
- "-vn",
120
- "-sn",
121
- "-f",
122
- "null",
123
- file_null,
124
- ]
125
- stderr = ffmpeg.Popen("EBU", cmd, log).communicate()[1]
126
- name, filter_args = parse_ebu_bytes(norm, stderr, log)
97
+ with av.open(f"{pre_master}") as container:
98
+ stats_ = stats(first_pass, container.streams.audio[0])
99
+
100
+ name, filter_args = parse_ebu_bytes(norm, stats_, log)
127
101
  else:
128
102
  assert "t" in norm
129
103
 
@@ -310,13 +284,7 @@ def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
310
284
 
311
285
 
312
286
  def make_new_audio(
313
- tl: v3,
314
- ctr: Container,
315
- ensure: Ensure,
316
- args: Args,
317
- ffmpeg: FFmpeg,
318
- bar: Bar,
319
- log: Log,
287
+ tl: v3, ctr: Container, ensure: Ensure, args: Args, bar: Bar, log: Log
320
288
  ) -> list[str]:
321
289
  sr = tl.sr
322
290
  tb = tl.tb
@@ -390,7 +358,7 @@ def make_new_audio(
390
358
  with open(pre_master, "wb") as fid:
391
359
  write(fid, sr, arr)
392
360
 
393
- apply_audio_normalization(ffmpeg, norm, pre_master, path, log)
361
+ apply_audio_normalization(norm, pre_master, path, log)
394
362
 
395
363
  bar.end()
396
364
 
@@ -162,7 +162,7 @@ def _ensure(input_: Input, format: str, stream: int) -> str:
162
162
  output = av.open(output_bytes, "w", format=format)
163
163
 
164
164
  in_stream = input_.streams.subtitles[stream]
165
- out_stream = output.add_stream(template=in_stream)
165
+ out_stream = output.add_stream_from_template(in_stream)
166
166
 
167
167
  for packet in input_.demux(in_stream):
168
168
  if packet.dts is None:
@@ -0,0 +1,69 @@
1
+ import glob
2
+ import os
3
+ import sys
4
+ from shutil import rmtree
5
+ from tempfile import gettempdir
6
+
7
+ import numpy as np
8
+
9
+ from auto_editor import __version__
10
+
11
+
12
+ def main(sys_args: list[str] = sys.argv[1:]) -> None:
13
+ cache_dir = os.path.join(gettempdir(), f"ae-{__version__}")
14
+
15
+ if sys_args and sys_args[0] in ("clean", "clear"):
16
+ rmtree(cache_dir, ignore_errors=True)
17
+ return
18
+
19
+ if not os.path.exists(cache_dir):
20
+ print("Empty cache")
21
+ return
22
+
23
+ cache_files = glob.glob(os.path.join(cache_dir, "*.npz"))
24
+ if not cache_files:
25
+ print("Empty cache")
26
+ return
27
+
28
+ def format_bytes(size: float) -> str:
29
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
30
+ if size < 1024:
31
+ return f"{size:.2f} {unit}"
32
+ size /= 1024
33
+ return f"{size:.2f} PiB"
34
+
35
+ GRAY = "\033[90m"
36
+ GREEN = "\033[32m"
37
+ BLUE = "\033[34m"
38
+ YELLOW = "\033[33m"
39
+ RESET = "\033[0m"
40
+
41
+ total_size = 0
42
+ for cache_file in cache_files:
43
+ try:
44
+ with np.load(cache_file, allow_pickle=False) as npzfile:
45
+ array = npzfile["data"]
46
+ key = os.path.basename(cache_file)[:-4] # Remove .npz extension
47
+
48
+ hash_part = key[:16]
49
+ rest_part = key[16:]
50
+
51
+ size = array.nbytes
52
+ total_size += size
53
+ size_str = format_bytes(size)
54
+ size_num, size_unit = size_str.rsplit(" ", 1)
55
+
56
+ print(
57
+ f"{YELLOW}entry: {GRAY}{hash_part}{RESET}{rest_part} "
58
+ f"{YELLOW}size: {GREEN}{size_num} {BLUE}{size_unit}{RESET}"
59
+ )
60
+ except Exception as e:
61
+ print(f"Error reading {cache_file}: {e}")
62
+
63
+ total_str = format_bytes(total_size)
64
+ total_num, total_unit = total_str.rsplit(" ", 1)
65
+ print(f"\n{YELLOW}total cache size: {GREEN}{total_num} {BLUE}{total_unit}{RESET}")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
@@ -163,6 +163,8 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
163
163
  file_info[file]["subtitle"].append(sub)
164
164
 
165
165
  if args.json:
166
+ if sys.platform == "win32":
167
+ sys.stdout.reconfigure(encoding="utf-8")
166
168
  dump(file_info, sys.stdout, indent=4)
167
169
  return
168
170
 
@@ -5,9 +5,10 @@ from dataclasses import dataclass, field
5
5
  from fractions import Fraction
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ import av
8
9
  import numpy as np
9
10
 
10
- from auto_editor.analyze import LevelError, Levels, iter_audio, iter_motion
11
+ from auto_editor.analyze import *
11
12
  from auto_editor.ffwrapper import initFileInfo
12
13
  from auto_editor.lang.palet import env
13
14
  from auto_editor.lib.contracts import is_bool, is_nat, is_nat1, is_str, is_void, orc
@@ -130,9 +131,19 @@ def main(sys_args: list[str] = sys.argv[1:]) -> None:
130
131
  levels = Levels(src, tb, bar, False, log, strict=True)
131
132
  try:
132
133
  if method == "audio":
133
- print_arr_gen(iter_audio(src, tb, **obj))
134
+ container = av.open(src.path, "r")
135
+ audio_stream = container.streams.audio[obj["stream"]]
136
+ log.experimental(audio_stream.codec)
137
+ print_arr_gen(iter_audio(audio_stream, tb))
138
+ container.close()
139
+
134
140
  elif method == "motion":
135
- print_arr_gen(iter_motion(src, tb, **obj))
141
+ container = av.open(src.path, "r")
142
+ video_stream = container.streams.video[obj["stream"]]
143
+ log.experimental(video_stream.codec)
144
+ print_arr_gen(iter_motion(video_stream, tb, obj["blur"], obj["width"]))
145
+ container.close()
146
+
136
147
  elif method == "subtitle":
137
148
  print_arr(levels.subtitle(**obj))
138
149
  elif method == "none":
auto_editor/utils/log.py CHANGED
@@ -5,7 +5,10 @@ from datetime import timedelta
5
5
  from shutil import get_terminal_size, rmtree
6
6
  from tempfile import mkdtemp
7
7
  from time import perf_counter, sleep
8
- from typing import NoReturn
8
+ from typing import TYPE_CHECKING, NoReturn
9
+
10
+ if TYPE_CHECKING:
11
+ import av
9
12
 
10
13
 
11
14
  class Log:
@@ -97,6 +100,10 @@ class Log:
97
100
 
98
101
  sys.stdout.write(f"Finished. took {second_len} seconds ({minute_len})\n")
99
102
 
103
+ def experimental(self, codec: av.Codec) -> None:
104
+ if codec.experimental:
105
+ self.error(f"`{codec.name}` is an experimental codec")
106
+
100
107
  def error(self, message: str | Exception) -> NoReturn:
101
108
  if self.is_debug and isinstance(message, Exception):
102
109
  self.cleanup()
@@ -210,14 +210,13 @@ class Args:
210
210
  sample_rate: int | None = None
211
211
  resolution: tuple[int, int] | None = None
212
212
  background: str = "#000000"
213
- edit_based_on: str = "audio"
213
+ edit: str = "audio"
214
214
  keep_tracks_separate: bool = False
215
215
  audio_normalize: str = "#f"
216
216
  export: str | None = None
217
217
  player: str | None = None
218
218
  no_open: bool = False
219
219
  temp_dir: str | None = None
220
- ffmpeg_location: str | None = None
221
220
  progress: str = "modern"
222
221
  version: bool = False
223
222
  debug: bool = False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: auto-editor
3
- Version: 26.0.1
3
+ Version: 26.1.1
4
4
  Summary: Auto-Editor: Effort free video editing!
5
5
  Author-email: WyattBlue <wyattblue@auto-editor.com>
6
6
  License: Unlicense
@@ -11,8 +11,8 @@ Keywords: video,audio,media,editor,editing,processing,nonlinear,automatic,silenc
11
11
  Requires-Python: <3.14,>=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: numpy <3.0,>=1.23.0
15
- Requires-Dist: pyav ==13.1.*
14
+ Requires-Dist: numpy<3.0,>=1.24
15
+ Requires-Dist: pyav==14.*
16
16
 
17
17
  <p align="center"><img src="https://auto-editor.com/img/auto-editor-banner.webp" title="Auto-Editor" width="700"></p>
18
18
 
@@ -1,10 +1,10 @@
1
- auto_editor/__init__.py,sha256=IVlNYVWUewREksm_90yLqKeN61i9bI8Lx_lWZz4SObk,23
2
- auto_editor/__main__.py,sha256=vSvw0fWi4GOIsak_RuOBnMB_CrtNNp8COh7i8LV4acE,11541
3
- auto_editor/analyze.py,sha256=uCi21659BB-lbPwZ6yxNLekS6Q3yoB2ypLNXPhmhTfg,11688
4
- auto_editor/edit.py,sha256=_fjvs2UzK84Wl6DVZwyRBbqbxh16z4VPNTHUlvjU9iQ,15977
5
- auto_editor/ffwrapper.py,sha256=Sx1-9OmAStR73I-RR2XPwWPTmGywM7ssqT9-U0sffA4,5615
6
- auto_editor/help.py,sha256=62s3L0rlhA7nkkOjtXItRUl779EJ__A7_6E-VFH3J_E,7924
7
- auto_editor/make_layers.py,sha256=8uFy5SvMArAP-5slYJrxa_iGAEwimQBFeM-T01VORVw,8995
1
+ auto_editor/__init__.py,sha256=2Ltcef2BVJgJx2W5ZkX7r21sdnzR3Zvtu1PYKRHEjLk,23
2
+ auto_editor/__main__.py,sha256=tc0M1MIPYjU5wCEU3EqmleOzaUgksU60qVHO0vRuC10,11310
3
+ auto_editor/analyze.py,sha256=Fv8NA99T1dZzrqlweJNlK7haKjgK13neR9CMw4t6rlY,12716
4
+ auto_editor/edit.py,sha256=eEMRaQbn0jylfJ6D_egnUXjoMCbdQVsAu7MDrn-xlGo,15950
5
+ auto_editor/ffwrapper.py,sha256=Tct_Q-uy5F51h8M7UFam50UzRFpgkBvUamJP1AoKVvc,4749
6
+ auto_editor/help.py,sha256=CzfDTsL4GuGu596ySHKj_wKnxGR9h8B0KUdkZpo33oE,8044
7
+ auto_editor/make_layers.py,sha256=vEeJt0PnE1vc9-cQZ_AlXVDjvWhObRCWJSCQGraoMvU,9016
8
8
  auto_editor/output.py,sha256=ho8Lpqz4Sv_Gw0Vj2OvG39s83xHpyZlvtRNryTPbXqc,2563
9
9
  auto_editor/preview.py,sha256=HUsjmV9Fx73rZ26BXrpz9z-z_e4oiui3u9e7qbbGoBY,3037
10
10
  auto_editor/timeline.py,sha256=XfaH9cH-RB-MObOpMr5IfLcqJcjmabO1XwkUkT3_FQM,8186
@@ -27,13 +27,14 @@ auto_editor/lib/contracts.py,sha256=lExGQymcQUmwG5lC1lO4qm4GY8W0q_yzK_miTaAoPA4,
27
27
  auto_editor/lib/data_structs.py,sha256=dcsXgsLLzbmFDUZucoirzewPALsKzoxz7z5L22_QJM8,7091
28
28
  auto_editor/lib/err.py,sha256=UlszQJdzMZwkbT8x3sY4GkCV_5x9yrd6uVVUzvA8iiI,35
29
29
  auto_editor/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
- auto_editor/render/audio.py,sha256=KvhAJf-5_HFkRoaYaKOYraT2uQr65gdayuicWYOpjgk,13376
31
- auto_editor/render/subtitle.py,sha256=qyP_AZHwGToVBeH8qMSa9LUenMaNmsnJN8w0Y7SXQ3o,6235
30
+ auto_editor/render/audio.py,sha256=1iOQCeRXfRz28cqnHp2XeK-f3_UnPf80AKQAfifGvdE,12584
31
+ auto_editor/render/subtitle.py,sha256=lf2l1QWJgFiqlpQWWBwSlKJnSgW8Lkfi59WrJMbIDqM,6240
32
32
  auto_editor/render/video.py,sha256=dje0RNW2dKILfTzt0VAF0WR6REfGOsc6l17pP1Z4ooA,12215
33
33
  auto_editor/subcommands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ auto_editor/subcommands/cache.py,sha256=YW_5qH0q5TVzmfOLEO117uqcY7dF6DS619ltVTPIzHQ,1959
34
35
  auto_editor/subcommands/desc.py,sha256=GDrKJYiHMaeTrplZAceXl1JwoqD78XsV2_5lc0Xd7po,869
35
- auto_editor/subcommands/info.py,sha256=t5n43HLt9hpMFSIfGV777X4zIPBAFugOKlpCfRjiKxY,6921
36
- auto_editor/subcommands/levels.py,sha256=ChJMDTd34-jgxewqHRmmd3VNhFdy964w0DcQG0ls-hY,4079
36
+ auto_editor/subcommands/info.py,sha256=UDdoxd6_fqSoRPwthkWXqnpxHp7dJQ0Dn96lYX_ubWc,7010
37
+ auto_editor/subcommands/levels.py,sha256=psSSIsGfzr9j0HGKp2yvK6nMlrkLwxkwsyI0uF2xb_c,4496
37
38
  auto_editor/subcommands/palet.py,sha256=ONzTqemaQq9YEfIOsDRNnwzfqnEMUMSXIQrETxyroRU,749
38
39
  auto_editor/subcommands/repl.py,sha256=TF_I7zsFY7-KdgidrqjafTz7o_eluVbLvgTcOBG-UWQ,3449
39
40
  auto_editor/subcommands/subdump.py,sha256=af_XBf7kaevqHn1A71z8C-7x8pS5WKD9FE_ugkCw6rk,665
@@ -44,12 +45,12 @@ auto_editor/utils/chunks.py,sha256=J-eGKtEz68gFtRrj1kOSgH4Tj_Yz6prNQ7Xr-d9NQJw,5
44
45
  auto_editor/utils/cmdkw.py,sha256=aUGBvBel2Ko1o6Rwmr4rEL-BMc5hEnzYLbyZ1GeJdcY,5729
45
46
  auto_editor/utils/container.py,sha256=Wf1ZL0tvXWl6m1B9mK_SkgVl89ilV_LpwlQq0TVroCc,2704
46
47
  auto_editor/utils/func.py,sha256=kB-pNDn20M6YT7sljyd_auve5teK-E2G4TgwVOAIuJw,2754
47
- auto_editor/utils/log.py,sha256=M2QKeQHMRNLm3HMVUKedZPRprT2u5dipOStiO4miPBk,3613
48
- auto_editor/utils/types.py,sha256=ecjTQmTlKoT9Wbwb_N4p6wC7s3bxiKPmq8sF15WfyVs,10772
49
- docs/build.py,sha256=CM-ZWgQk8wSNjivx_-6wGIaG7cstrNKsX2d4TzFVivE,1642
50
- auto_editor-26.0.1.dist-info/LICENSE,sha256=yiq99pWITHfqS0pbZMp7cy2dnbreTuvBwudsU-njvIM,1210
51
- auto_editor-26.0.1.dist-info/METADATA,sha256=e_JZKirHFmWBAXaCDqXHIOB4J-IIpbuoK72fzhlXMtw,6115
52
- auto_editor-26.0.1.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
53
- auto_editor-26.0.1.dist-info/entry_points.txt,sha256=-H7zdTw4MqnAcwrN5xTNkGIhzZtJMxS9r6lTMeR9-aA,240
54
- auto_editor-26.0.1.dist-info/top_level.txt,sha256=jBV5zlbWRbKOa-xaWPvTD45QL7lGExx2BDzv-Ji4dTw,17
55
- auto_editor-26.0.1.dist-info/RECORD,,
48
+ auto_editor/utils/log.py,sha256=n5dlJ2CdK_54eiYE02SPgkBdBWABV7tE2p8ONj_F6TM,3813
49
+ auto_editor/utils/types.py,sha256=7BF7R7DA5eKmtI6f5ia7bOYNL0u_2sviiPsE1VmP0lc,10724
50
+ docs/build.py,sha256=POy8X8QOBYe_8A8HI_yiVI_Qg9E5mLpn1z7AHQr0_vQ,1888
51
+ auto_editor-26.1.1.dist-info/LICENSE,sha256=yiq99pWITHfqS0pbZMp7cy2dnbreTuvBwudsU-njvIM,1210
52
+ auto_editor-26.1.1.dist-info/METADATA,sha256=Ovf6CjY_x-lyih-4c1xBZEkL_X0gvifVFthPcLSMOtk,6109
53
+ auto_editor-26.1.1.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
54
+ auto_editor-26.1.1.dist-info/entry_points.txt,sha256=-H7zdTw4MqnAcwrN5xTNkGIhzZtJMxS9r6lTMeR9-aA,240
55
+ auto_editor-26.1.1.dist-info/top_level.txt,sha256=jBV5zlbWRbKOa-xaWPvTD45QL7lGExx2BDzv-Ji4dTw,17
56
+ auto_editor-26.1.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.3.0)
2
+ Generator: setuptools (75.7.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
docs/build.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
  import sys
5
+ from html import escape
5
6
 
6
7
  # Put 'auto_editor' in Python path
7
8
  sys.path.append(os.path.dirname(os.path.dirname(__file__)))
@@ -19,7 +20,7 @@ def main():
19
20
 
20
21
  with open("src/ref/options.html", "w") as file:
21
22
  file.write(
22
- '{{ header-desc "Options" "These are the options and flags that auto-editor uses." }}\n'
23
+ '{{ headerdesc "Options" "These are the options and flags that auto-editor uses." }}\n'
23
24
  "<body>\n"
24
25
  "{{ nav }}\n"
25
26
  '<section class="section">\n'
@@ -27,12 +28,18 @@ def main():
27
28
  )
28
29
  for op in parser.args:
29
30
  if isinstance(op, OptionText):
30
- file.write(f"<h2>{op.text}</h2>\n")
31
+ file.write(f"<h2>{escape(op.text)}</h2>\n")
31
32
  else:
32
- file.write(f"<h3><code>{op.names[0]}</code></h3>\n")
33
+ if op.metavar is None:
34
+ file.write(f"<h3><code>{op.names[0]}</code></h3>\n")
35
+ else:
36
+ file.write(
37
+ f"<h3><code>{op.names[0]} {escape(op.metavar)}</code></h3>\n"
38
+ )
39
+
33
40
  if len(op.names) > 1:
34
41
  file.write(
35
- "<h4><code>"
42
+ "<h4>Aliases: <code>"
36
43
  + "</code> <code>".join(op.names[1:])
37
44
  + "</code></h4>\n"
38
45
  )