auto-editor 25.3.0__py3-none-any.whl → 26.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auto_editor/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "25.3.0"
1
+ __version__ = "26.0.0"
auto_editor/__main__.py CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ import re
3
4
  import sys
4
5
  from os import environ
6
+ from os.path import exists, isdir, isfile, lexists, splitext
7
+ from subprocess import run
5
8
 
6
9
  import auto_editor
7
10
  from auto_editor.edit import edit_media
8
- from auto_editor.ffwrapper import FFmpeg
11
+ from auto_editor.ffwrapper import FFmpeg, initFFmpeg
12
+ from auto_editor.utils.func import get_stdout
9
13
  from auto_editor.utils.log import Log
10
14
  from auto_editor.utils.types import (
11
15
  Args,
12
- bitrate,
13
16
  color,
14
17
  frame_rate,
15
18
  margin,
@@ -20,7 +23,6 @@ from auto_editor.utils.types import (
20
23
  speed_range,
21
24
  time_range,
22
25
  )
23
- from auto_editor.validate_input import valid_input
24
26
  from auto_editor.vanparse import ArgumentParser
25
27
 
26
28
 
@@ -202,16 +204,8 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
202
204
  "--video-bitrate",
203
205
  "-b:v",
204
206
  metavar="BITRATE",
205
- type=bitrate,
206
207
  help="Set the number of bits per second for video",
207
208
  )
208
- parser.add_argument(
209
- "--video-quality-scale",
210
- "-qscale:v",
211
- "-q:v",
212
- metavar="SCALE",
213
- help="Set a value to the ffmpeg option -qscale:v",
214
- )
215
209
  parser.add_argument(
216
210
  "--scale",
217
211
  type=number,
@@ -235,7 +229,6 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
235
229
  "--audio-bitrate",
236
230
  "-b:a",
237
231
  metavar="BITRATE",
238
- type=bitrate,
239
232
  help="Set the number of bits per second for audio",
240
233
  )
241
234
  parser.add_argument(
@@ -274,6 +267,50 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
274
267
  return parser
275
268
 
276
269
 
270
+ def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
271
+ log.conwrite("Downloading video...")
272
+
273
+ def get_domain(url: str) -> str:
274
+ t = __import__("urllib.parse", fromlist=["parse"]).urlparse(url).netloc
275
+ return ".".join(t.split(".")[-2:])
276
+
277
+ download_format = args.download_format
278
+ if download_format is None and get_domain(my_input) == "youtube.com":
279
+ download_format = "bestvideo[ext=mp4]+bestaudio[ext=m4a]"
280
+
281
+ if args.output_format is None:
282
+ output_format = re.sub(r"\W+", "-", splitext(my_input)[0]) + ".%(ext)s"
283
+ else:
284
+ output_format = args.output_format
285
+
286
+ yt_dlp_path = args.yt_dlp_location
287
+
288
+ cmd = ["--ffmpeg-location", ffmpeg.path]
289
+
290
+ if download_format is not None:
291
+ cmd.extend(["-f", download_format])
292
+
293
+ cmd.extend(["-o", output_format, my_input])
294
+
295
+ if args.yt_dlp_extras is not None:
296
+ cmd.extend(args.yt_dlp_extras.split(" "))
297
+
298
+ try:
299
+ location = get_stdout(
300
+ [yt_dlp_path, "--get-filename", "--no-warnings"] + cmd
301
+ ).strip()
302
+ except FileNotFoundError:
303
+ log.error("Program `yt-dlp` must be installed and on PATH.")
304
+
305
+ if not isfile(location):
306
+ run([yt_dlp_path] + cmd)
307
+
308
+ if not isfile(location):
309
+ log.error(f"Download file wasn't created: {location}")
310
+
311
+ return location
312
+
313
+
277
314
  def main() -> None:
278
315
  subcommands = ("test", "info", "levels", "subdump", "desc", "repl", "palet")
279
316
 
@@ -284,8 +321,7 @@ def main() -> None:
284
321
  obj.main(sys.argv[2:])
285
322
  return
286
323
 
287
- ff_color = "AV_LOG_FORCE_NOCOLOR"
288
- no_color = bool(environ.get("NO_COLOR")) or bool(environ.get(ff_color))
324
+ no_color = bool(environ.get("NO_COLOR") or environ.get("AV_LOG_FORCE_NOCOLOR"))
289
325
  log = Log(no_color=no_color)
290
326
 
291
327
  args = main_options(ArgumentParser("Auto-Editor")).parse_args(
@@ -327,13 +363,28 @@ def main() -> None:
327
363
  is_machine = args.progress == "machine"
328
364
  log = Log(args.debug, args.quiet, args.temp_dir, is_machine, no_color)
329
365
 
330
- ffmpeg = FFmpeg(
366
+ ffmpeg = initFFmpeg(
367
+ log,
331
368
  args.ffmpeg_location,
332
369
  args.my_ffmpeg,
333
370
  args.show_ffmpeg_commands,
334
371
  args.show_ffmpeg_output,
335
372
  )
336
- paths = valid_input(args.input, ffmpeg, args, log)
373
+ paths = []
374
+ for my_input in args.input:
375
+ if my_input.startswith("http://") or my_input.startswith("https://"):
376
+ paths.append(download_video(my_input, args, ffmpeg, log))
377
+ else:
378
+ if not splitext(my_input)[1]:
379
+ if isdir(my_input):
380
+ log.error("Input must be a file or a URL, not a directory.")
381
+ if exists(my_input):
382
+ log.error(f"Input file must have an extension: {my_input}")
383
+ if lexists(my_input):
384
+ log.error(f"Input file is a broken symbolic link: {my_input}")
385
+ if my_input.startswith("-"):
386
+ log.error(f"Option/Input file doesn't exist: {my_input}")
387
+ paths.append(my_input)
337
388
 
338
389
  try:
339
390
  edit_media(paths, ffmpeg, args, log)
auto_editor/analyze.py CHANGED
@@ -14,13 +14,11 @@ from av.audio.fifo import AudioFifo
14
14
  from av.subtitles.subtitle import AssSubtitle
15
15
 
16
16
  from auto_editor import __version__
17
- from auto_editor.utils.subtitle_tools import convert_ass_to_text
18
17
 
19
18
  if TYPE_CHECKING:
20
- from collections.abc import Iterator
19
+ from collections.abc import Iterator, Sequence
21
20
  from fractions import Fraction
22
21
  from pathlib import Path
23
- from typing import Any
24
22
 
25
23
  from numpy.typing import NDArray
26
24
 
@@ -156,10 +154,10 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
156
154
  container.close()
157
155
 
158
156
 
159
- def obj_tag(path: Path, kind: str, tb: Fraction, obj: dict[str, Any]) -> str:
157
+ def obj_tag(path: Path, kind: str, tb: Fraction, obj: Sequence[object]) -> str:
160
158
  mod_time = int(path.stat().st_mtime)
161
159
  key = f"{path.name}:{mod_time:x}:{kind}:{tb}:"
162
- return key + ",".join(f"{v}" for v in obj.values())
160
+ return key + ",".join(f"{v}" for v in obj)
163
161
 
164
162
 
165
163
  @dataclass(slots=True)
@@ -174,7 +172,7 @@ class Levels:
174
172
  @property
175
173
  def media_length(self) -> int:
176
174
  if self.src.audios:
177
- if (arr := self.read_cache("audio", {"stream": 0})) is not None:
175
+ if (arr := self.read_cache("audio", (0,))) is not None:
178
176
  return len(arr)
179
177
 
180
178
  result = sum(1 for _ in iter_audio(self.src, self.tb, 0))
@@ -202,7 +200,7 @@ class Levels:
202
200
  def all(self) -> NDArray[np.bool_]:
203
201
  return np.zeros(self.media_length, dtype=np.bool_)
204
202
 
205
- def read_cache(self, kind: str, obj: dict[str, Any]) -> None | np.ndarray:
203
+ def read_cache(self, kind: str, obj: Sequence[object]) -> None | np.ndarray:
206
204
  if self.no_cache:
207
205
  return None
208
206
 
@@ -221,7 +219,7 @@ class Levels:
221
219
  self.log.debug("Using cache")
222
220
  return npzfile[key]
223
221
 
224
- def cache(self, arr: np.ndarray, kind: str, obj: dict[str, Any]) -> np.ndarray:
222
+ def cache(self, arr: np.ndarray, kind: str, obj: Sequence[object]) -> np.ndarray:
225
223
  if self.no_cache:
226
224
  return arr
227
225
 
@@ -238,7 +236,7 @@ class Levels:
238
236
  if stream >= len(self.src.audios):
239
237
  raise LevelError(f"audio: audio stream '{stream}' does not exist.")
240
238
 
241
- if (arr := self.read_cache("audio", {"stream": stream})) is not None:
239
+ if (arr := self.read_cache("audio", (stream,))) is not None:
242
240
  return arr
243
241
 
244
242
  with av.open(self.src.path, "r") as container:
@@ -265,13 +263,13 @@ class Levels:
265
263
  index += 1
266
264
 
267
265
  bar.end()
268
- return self.cache(result[:index], "audio", {"stream": stream})
266
+ return self.cache(result[:index], "audio", (stream,))
269
267
 
270
268
  def motion(self, stream: int, blur: int, width: int) -> NDArray[np.float32]:
271
269
  if stream >= len(self.src.videos):
272
270
  raise LevelError(f"motion: video stream '{stream}' does not exist.")
273
271
 
274
- mobj = {"stream": stream, "width": width, "blur": blur}
272
+ mobj = (stream, width, blur)
275
273
  if (arr := self.read_cache("motion", mobj)) is not None:
276
274
  return arr
277
275
 
@@ -360,11 +358,10 @@ class Levels:
360
358
  san_end = round((start + dur) * self.tb)
361
359
 
362
360
  for sub in subset:
363
- if isinstance(sub, AssSubtitle):
364
- line = convert_ass_to_text(sub.ass.decode(errors="ignore"))
365
- else:
361
+ if not isinstance(sub, AssSubtitle):
366
362
  continue
367
363
 
364
+ line = sub.dialogue.decode(errors="ignore")
368
365
  if line and re.search(re_pattern, line):
369
366
  result[san_start:san_end] = 1
370
367
  count += 1
auto_editor/edit.py CHANGED
@@ -1,12 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import sys
5
+ from fractions import Fraction
6
+ from subprocess import run
4
7
  from typing import Any
5
8
 
9
+ import av
10
+ from av import AudioResampler
11
+
6
12
  from auto_editor.ffwrapper import FFmpeg, FileInfo, initFileInfo
7
13
  from auto_editor.lib.contracts import is_int, is_str
8
14
  from auto_editor.make_layers import make_timeline
9
- from auto_editor.output import Ensure, mux_quality_media
15
+ from auto_editor.output import Ensure, parse_bitrate
10
16
  from auto_editor.render.audio import make_new_audio
11
17
  from auto_editor.render.subtitle import make_new_subtitles
12
18
  from auto_editor.render.video import render_av
@@ -15,7 +21,6 @@ from auto_editor.utils.bar import initBar
15
21
  from auto_editor.utils.chunks import Chunk, Chunks
16
22
  from auto_editor.utils.cmdkw import ParserError, parse_with_palet, pAttr, pAttrs
17
23
  from auto_editor.utils.container import Container, container_constructor
18
- from auto_editor.utils.func import open_with_system_default
19
24
  from auto_editor.utils.log import Log
20
25
  from auto_editor.utils.types import Args
21
26
 
@@ -91,11 +96,19 @@ def set_audio_codec(
91
96
  codec: str, src: FileInfo | None, out_ext: str, ctr: Container, log: Log
92
97
  ) -> str:
93
98
  if codec == "auto":
94
- codec = "aac" if (src is None or not src.audios) else src.audios[0].codec
99
+ if src is None or not src.audios:
100
+ codec = "aac"
101
+ else:
102
+ codec = src.audios[0].codec
103
+ ctx = av.Codec(codec)
104
+ if ctx.audio_formats is None:
105
+ codec = "aac"
95
106
  if codec not in ctr.acodecs and ctr.default_aud != "none":
96
- return ctr.default_aud
107
+ codec = ctr.default_aud
97
108
  if codec == "mp3float":
98
- return "mp3"
109
+ codec = "mp3"
110
+ if codec is None:
111
+ codec = "aac"
99
112
  return codec
100
113
 
101
114
  if codec == "copy":
@@ -105,9 +118,8 @@ def set_audio_codec(
105
118
  log.error("Input file does not have an audio stream to copy codec from.")
106
119
  codec = src.audios[0].codec
107
120
 
108
- if codec != "unset":
109
- if ctr.acodecs is None or codec not in ctr.acodecs:
110
- log.error(codec_error.format(codec, out_ext))
121
+ if ctr.acodecs is None or codec not in ctr.acodecs:
122
+ log.error(codec_error.format(codec, out_ext))
111
123
 
112
124
  return codec
113
125
 
@@ -269,49 +281,150 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
269
281
  if args.keep_tracks_separate and ctr.max_audios == 1:
270
282
  log.warning(f"'{out_ext}' container doesn't support multiple audio tracks.")
271
283
 
272
- def make_media(tl: v3, output: str) -> None:
284
+ def make_media(tl: v3, output_path: str) -> None:
273
285
  assert src is not None
274
286
 
275
- visual_output = []
276
- audio_output = []
277
- sub_output = []
278
- apply_later = False
287
+ output = av.open(output_path, "w")
279
288
 
280
- ensure = Ensure(ffmpeg, bar, samplerate, log)
281
289
  if ctr.default_sub != "none" and not args.sn:
282
- sub_output = make_new_subtitles(tl, ensure, log.temp)
290
+ sub_paths = make_new_subtitles(tl, log)
291
+ else:
292
+ sub_paths = []
283
293
 
284
294
  if ctr.default_aud != "none":
285
- audio_output = make_new_audio(tl, ensure, args, ffmpeg, bar, log)
286
-
287
- if ctr.default_vid != "none":
288
- if tl.v:
289
- out_path, apply_later = render_av(ffmpeg, tl, args, bar, ctr, log)
290
- visual_output.append((True, out_path))
291
-
292
- for v, vid in enumerate(src.videos, start=1):
293
- if ctr.allow_image and vid.codec in ("png", "mjpeg", "webp"):
294
- out_path = os.path.join(log.temp, f"{v}.{vid.codec}")
295
- # fmt: off
296
- ffmpeg.run(["-i", f"{src.path}", "-map", "0:v", "-map", "-0:V",
297
- "-c", "copy", out_path])
298
- # fmt: on
299
- visual_output.append((False, out_path))
300
-
301
- log.conwrite("Writing output file")
302
- mux_quality_media(
303
- ffmpeg,
304
- visual_output,
305
- audio_output,
306
- sub_output,
307
- apply_later,
308
- ctr,
309
- output,
310
- tl.tb,
311
- args,
312
- src,
313
- log,
314
- )
295
+ ensure = Ensure(bar, samplerate, log)
296
+ audio_paths = make_new_audio(tl, ensure, args, ffmpeg, bar, log)
297
+ if (
298
+ not (args.keep_tracks_separate and ctr.max_audios is None)
299
+ and len(audio_paths) > 1
300
+ ):
301
+ # Merge all the audio a_tracks into one.
302
+ new_a_file = os.path.join(log.temp, "new_audio.wav")
303
+ new_cmd = []
304
+ for path in audio_paths:
305
+ new_cmd.extend(["-i", path])
306
+ new_cmd.extend(
307
+ [
308
+ "-filter_complex",
309
+ f"amix=inputs={len(audio_paths)}:duration=longest",
310
+ "-ac",
311
+ "2",
312
+ new_a_file,
313
+ ]
314
+ )
315
+ ffmpeg.run(new_cmd)
316
+ audio_paths = [new_a_file]
317
+ else:
318
+ audio_paths = []
319
+
320
+ # Setup audio
321
+ if audio_paths:
322
+ try:
323
+ audio_encoder = av.Codec(args.audio_codec)
324
+ except av.FFmpegError as e:
325
+ log.error(e)
326
+ if audio_encoder.audio_formats is None:
327
+ log.error(f"{args.audio_codec}: No known audio formats avail.")
328
+ audio_format = audio_encoder.audio_formats[0]
329
+ resampler = AudioResampler(format=audio_format, layout="stereo", rate=tl.sr)
330
+
331
+ audio_streams: list[av.AudioStream] = []
332
+ audio_inputs = []
333
+ audio_gen_frames = []
334
+ for i, audio_path in enumerate(audio_paths):
335
+ audio_stream = output.add_stream(
336
+ args.audio_codec,
337
+ format=audio_format,
338
+ rate=tl.sr,
339
+ time_base=Fraction(1, tl.sr),
340
+ )
341
+ if not isinstance(audio_stream, av.AudioStream):
342
+ log.error(f"Not a known audio codec: {args.audio_codec}")
343
+
344
+ if args.audio_bitrate != "auto":
345
+ audio_stream.bit_rate = parse_bitrate(args.audio_bitrate, log)
346
+ log.debug(f"audio bitrate: {audio_stream.bit_rate}")
347
+ else:
348
+ log.debug(f"[auto] audio bitrate: {audio_stream.bit_rate}")
349
+ if i < len(src.audios) and src.audios[i].lang is not None:
350
+ audio_stream.metadata["language"] = src.audios[i].lang # type: ignore
351
+
352
+ audio_streams.append(audio_stream)
353
+ audio_input = av.open(audio_path)
354
+ audio_inputs.append(audio_input)
355
+ audio_gen_frames.append(audio_input.decode(audio=0))
356
+
357
+ # Setup subtitles
358
+ subtitle_streams = []
359
+ subtitle_inputs = []
360
+ sub_gen_frames = []
361
+
362
+ for i, sub_path in enumerate(sub_paths):
363
+ subtitle_input = av.open(sub_path)
364
+ subtitle_inputs.append(subtitle_input)
365
+ subtitle_stream = output.add_stream(
366
+ template=subtitle_input.streams.subtitles[0]
367
+ )
368
+ if i < len(src.subtitles) and src.subtitles[i].lang is not None:
369
+ subtitle_stream.metadata["language"] = src.subtitles[i].lang # type: ignore
370
+
371
+ subtitle_streams.append(subtitle_stream)
372
+ sub_gen_frames.append(subtitle_input.demux(subtitles=0))
373
+
374
+ # Setup video
375
+ if ctr.default_vid != "none" and tl.v:
376
+ vframes = render_av(output, tl, args, bar, log)
377
+ output_stream = next(vframes)
378
+ else:
379
+ output_stream, vframes = None, iter([])
380
+
381
+ # Process frames
382
+ while True:
383
+ audio_frames = [next(frames, None) for frames in audio_gen_frames]
384
+ video_frame = next(vframes, None)
385
+ subtitle_frames = [next(packet, None) for packet in sub_gen_frames]
386
+
387
+ if (
388
+ all(frame is None for frame in audio_frames)
389
+ and video_frame is None
390
+ and all(packet is None for packet in subtitle_frames)
391
+ ):
392
+ break
393
+
394
+ for audio_stream, audio_frame in zip(audio_streams, audio_frames):
395
+ if audio_frame:
396
+ for reframe in resampler.resample(audio_frame):
397
+ output.mux(audio_stream.encode(reframe))
398
+
399
+ for subtitle_stream, packet in zip(subtitle_streams, subtitle_frames):
400
+ if not packet or packet.dts is None:
401
+ continue
402
+ packet.stream = subtitle_stream
403
+ output.mux(packet)
404
+
405
+ if video_frame:
406
+ try:
407
+ output.mux(output_stream.encode(video_frame))
408
+ except av.error.ExternalError:
409
+ log.error(
410
+ f"Generic error for encoder: {output_stream.name}\n"
411
+ "Perhaps video quality settings are too low?"
412
+ )
413
+ except av.FFmpegError as e:
414
+ log.error(e)
415
+
416
+ # Flush streams
417
+ if output_stream is not None:
418
+ output.mux(output_stream.encode(None))
419
+ for audio_stream in audio_streams:
420
+ output.mux(audio_stream.encode(None))
421
+
422
+ # Close resources
423
+ for audio_input in audio_inputs:
424
+ audio_input.close()
425
+ for subtitle_input in subtitle_inputs:
426
+ subtitle_input.close()
427
+ output.close()
315
428
 
316
429
  if export == "clip-sequence":
317
430
  if tl.v1 is None:
@@ -328,7 +441,7 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
328
441
  total_frames = tl.v1.chunks[-1][1] - 1
329
442
  clip_num = 0
330
443
  for chunk in tl.v1.chunks:
331
- if chunk[2] == 99999:
444
+ if chunk[2] == 0 or chunk[2] >= 99999:
332
445
  continue
333
446
 
334
447
  padded_chunks = pad_chunk(chunk, total_frames)
@@ -354,11 +467,23 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
354
467
 
355
468
  log.stop_timer()
356
469
 
357
- if not args.no_open and export in ("default", "audio", "clip-sequence"):
470
+ if not args.no_open and export in ("default", "audio"):
358
471
  if args.player is None:
359
- open_with_system_default(output, log)
472
+ if sys.platform == "win32":
473
+ try:
474
+ os.startfile(output)
475
+ except OSError:
476
+ log.warning(f"Could not find application to open file: {output}")
477
+ else:
478
+ try: # MacOS case
479
+ run(["open", output])
480
+ except Exception:
481
+ try: # WSL2 case
482
+ run(["cmd.exe", "/C", "start", output])
483
+ except Exception:
484
+ try: # Linux case
485
+ run(["xdg-open", output])
486
+ except Exception:
487
+ log.warning(f"Could not open output file: {output}")
360
488
  else:
361
- import subprocess
362
- from shlex import split
363
-
364
- subprocess.run(split(args.player) + [output])
489
+ run(__import__("shlex").split(args.player) + [output])