auto-editor 25.3.1__tar.gz → 26.0.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 (64) hide show
  1. {auto_editor-25.3.1 → auto_editor-26.0.0}/PKG-INFO +1 -1
  2. auto_editor-26.0.0/auto_editor/__init__.py +1 -0
  3. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/__main__.py +1 -11
  4. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/edit.py +156 -44
  5. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/ffwrapper.py +2 -43
  6. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/help.py +4 -3
  7. auto_editor-26.0.0/auto_editor/output.py +83 -0
  8. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/audio.py +65 -55
  9. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/subtitle.py +69 -10
  10. auto_editor-26.0.0/auto_editor/render/video.py +333 -0
  11. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/repl.py +12 -3
  12. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/test.py +41 -37
  13. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/container.py +2 -0
  14. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/func.py +1 -1
  15. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/types.py +2 -15
  16. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/PKG-INFO +1 -1
  17. auto_editor-25.3.1/auto_editor/__init__.py +0 -1
  18. auto_editor-25.3.1/auto_editor/output.py +0 -244
  19. auto_editor-25.3.1/auto_editor/render/video.py +0 -347
  20. {auto_editor-25.3.1 → auto_editor-26.0.0}/LICENSE +0 -0
  21. {auto_editor-25.3.1 → auto_editor-26.0.0}/README.md +0 -0
  22. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/analyze.py +0 -0
  23. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/__init__.py +0 -0
  24. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/fcp11.py +0 -0
  25. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/fcp7.py +0 -0
  26. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/json.py +0 -0
  27. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/shotcut.py +0 -0
  28. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/utils.py +0 -0
  29. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/__init__.py +0 -0
  30. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/json.py +0 -0
  31. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/libintrospection.py +0 -0
  32. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/libmath.py +0 -0
  33. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/palet.py +0 -0
  34. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/stdenv.py +0 -0
  35. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/__init__.py +0 -0
  36. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/contracts.py +0 -0
  37. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/data_structs.py +0 -0
  38. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/err.py +0 -0
  39. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/make_layers.py +0 -0
  40. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/preview.py +0 -0
  41. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/__init__.py +0 -0
  42. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/__init__.py +0 -0
  43. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/desc.py +0 -0
  44. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/info.py +0 -0
  45. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/levels.py +0 -0
  46. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/palet.py +0 -0
  47. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/subdump.py +0 -0
  48. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/timeline.py +0 -0
  49. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/__init__.py +0 -0
  50. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/bar.py +0 -0
  51. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/chunks.py +0 -0
  52. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/cmdkw.py +0 -0
  53. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/encoder.py +0 -0
  54. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/log.py +0 -0
  55. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/vanparse.py +0 -0
  56. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/wavfile.py +0 -0
  57. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/SOURCES.txt +0 -0
  58. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/dependency_links.txt +0 -0
  59. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/entry_points.txt +0 -0
  60. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/requires.txt +0 -0
  61. {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/top_level.txt +0 -0
  62. {auto_editor-25.3.1 → auto_editor-26.0.0}/docs/build.py +0 -0
  63. {auto_editor-25.3.1 → auto_editor-26.0.0}/pyproject.toml +0 -0
  64. {auto_editor-25.3.1 → auto_editor-26.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: auto-editor
3
- Version: 25.3.1
3
+ Version: 26.0.0
4
4
  Summary: Auto-Editor: Effort free video editing!
5
5
  Author-email: WyattBlue <wyattblue@auto-editor.com>
6
6
  License: Unlicense
@@ -0,0 +1 @@
1
+ __version__ = "26.0.0"
@@ -13,7 +13,6 @@ from auto_editor.utils.func import get_stdout
13
13
  from auto_editor.utils.log import Log
14
14
  from auto_editor.utils.types import (
15
15
  Args,
16
- bitrate,
17
16
  color,
18
17
  frame_rate,
19
18
  margin,
@@ -205,16 +204,8 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
205
204
  "--video-bitrate",
206
205
  "-b:v",
207
206
  metavar="BITRATE",
208
- type=bitrate,
209
207
  help="Set the number of bits per second for video",
210
208
  )
211
- parser.add_argument(
212
- "--video-quality-scale",
213
- "-qscale:v",
214
- "-q:v",
215
- metavar="SCALE",
216
- help="Set a value to the ffmpeg option -qscale:v",
217
- )
218
209
  parser.add_argument(
219
210
  "--scale",
220
211
  type=number,
@@ -238,7 +229,6 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
238
229
  "--audio-bitrate",
239
230
  "-b:a",
240
231
  metavar="BITRATE",
241
- type=bitrate,
242
232
  help="Set the number of bits per second for audio",
243
233
  )
244
234
  parser.add_argument(
@@ -281,7 +271,7 @@ def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
281
271
  log.conwrite("Downloading video...")
282
272
 
283
273
  def get_domain(url: str) -> str:
284
- t = __import__("urllib").parse.urlparse(url).netloc
274
+ t = __import__("urllib.parse", fromlist=["parse"]).urlparse(url).netloc
285
275
  return ".".join(t.split(".")[-2:])
286
276
 
287
277
  download_format = args.download_format
@@ -2,13 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import sys
5
+ from fractions import Fraction
5
6
  from subprocess import run
6
7
  from typing import Any
7
8
 
9
+ import av
10
+ from av import AudioResampler
11
+
8
12
  from auto_editor.ffwrapper import FFmpeg, FileInfo, initFileInfo
9
13
  from auto_editor.lib.contracts import is_int, is_str
10
14
  from auto_editor.make_layers import make_timeline
11
- from auto_editor.output import Ensure, mux_quality_media
15
+ from auto_editor.output import Ensure, parse_bitrate
12
16
  from auto_editor.render.audio import make_new_audio
13
17
  from auto_editor.render.subtitle import make_new_subtitles
14
18
  from auto_editor.render.video import render_av
@@ -92,11 +96,19 @@ def set_audio_codec(
92
96
  codec: str, src: FileInfo | None, out_ext: str, ctr: Container, log: Log
93
97
  ) -> str:
94
98
  if codec == "auto":
95
- 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"
96
106
  if codec not in ctr.acodecs and ctr.default_aud != "none":
97
- return ctr.default_aud
107
+ codec = ctr.default_aud
98
108
  if codec == "mp3float":
99
- return "mp3"
109
+ codec = "mp3"
110
+ if codec is None:
111
+ codec = "aac"
100
112
  return codec
101
113
 
102
114
  if codec == "copy":
@@ -106,9 +118,8 @@ def set_audio_codec(
106
118
  log.error("Input file does not have an audio stream to copy codec from.")
107
119
  codec = src.audios[0].codec
108
120
 
109
- if codec != "unset":
110
- if ctr.acodecs is None or codec not in ctr.acodecs:
111
- 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))
112
123
 
113
124
  return codec
114
125
 
@@ -270,49 +281,150 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
270
281
  if args.keep_tracks_separate and ctr.max_audios == 1:
271
282
  log.warning(f"'{out_ext}' container doesn't support multiple audio tracks.")
272
283
 
273
- def make_media(tl: v3, output: str) -> None:
284
+ def make_media(tl: v3, output_path: str) -> None:
274
285
  assert src is not None
275
286
 
276
- visual_output = []
277
- audio_output = []
278
- sub_output = []
279
- apply_later = False
287
+ output = av.open(output_path, "w")
280
288
 
281
- ensure = Ensure(ffmpeg, bar, samplerate, log)
282
289
  if ctr.default_sub != "none" and not args.sn:
283
- sub_output = make_new_subtitles(tl, ensure, log.temp)
290
+ sub_paths = make_new_subtitles(tl, log)
291
+ else:
292
+ sub_paths = []
284
293
 
285
294
  if ctr.default_aud != "none":
286
- audio_output = make_new_audio(tl, ensure, args, ffmpeg, bar, log)
287
-
288
- if ctr.default_vid != "none":
289
- if tl.v:
290
- out_path, apply_later = render_av(ffmpeg, tl, args, bar, ctr, log)
291
- visual_output.append((True, out_path))
292
-
293
- for v, vid in enumerate(src.videos, start=1):
294
- if ctr.allow_image and vid.codec in ("png", "mjpeg", "webp"):
295
- out_path = os.path.join(log.temp, f"{v}.{vid.codec}")
296
- # fmt: off
297
- ffmpeg.run(["-i", f"{src.path}", "-map", "0:v", "-map", "-0:V",
298
- "-c", "copy", out_path])
299
- # fmt: on
300
- visual_output.append((False, out_path))
301
-
302
- log.conwrite("Writing output file")
303
- mux_quality_media(
304
- ffmpeg,
305
- visual_output,
306
- audio_output,
307
- sub_output,
308
- apply_later,
309
- ctr,
310
- output,
311
- tl.tb,
312
- args,
313
- src,
314
- log,
315
- )
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()
316
428
 
317
429
  if export == "clip-sequence":
318
430
  if tl.v1 is None:
@@ -1,11 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- import os.path
4
3
  import sys
5
4
  from dataclasses import dataclass
6
5
  from fractions import Fraction
7
6
  from pathlib import Path
8
- from re import search
9
7
  from shutil import which
10
8
  from subprocess import PIPE, Popen, run
11
9
  from typing import Any
@@ -52,41 +50,6 @@ class FFmpeg:
52
50
  sys.stderr.write(f"{' '.join(cmd)}\n\n")
53
51
  run(cmd)
54
52
 
55
- def run_check_errors(
56
- self, cmd: list[str], show_out: bool = False, path: str | None = None
57
- ) -> None:
58
- process = self.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
59
- _, stderr = process.communicate()
60
-
61
- if process.stdin is not None:
62
- process.stdin.close()
63
- output = stderr.decode("utf-8", "replace")
64
-
65
- error_list = (
66
- r"Unknown encoder '.*'",
67
- r"-q:v qscale not available for encoder\. Use -b:v bitrate instead\.",
68
- r"Specified sample rate .* is not supported",
69
- r'Unable to parse option value ".*"',
70
- r"Error setting option .* to value .*\.",
71
- r"Undefined constant or missing '.*' in '.*'",
72
- r"DLL .* failed to open",
73
- r"Incompatible pixel format '.*' for codec '[A-Za-z0-9_]*'",
74
- r"Unrecognized option '.*'",
75
- r"Permission denied",
76
- )
77
-
78
- if self.debug:
79
- print(f"stderr: {output}")
80
-
81
- for item in error_list:
82
- if check := search(item, output):
83
- self.log.error(check.group())
84
-
85
- if path is not None and not os.path.isfile(path):
86
- self.log.error(f"The file {path} was not created.")
87
- if show_out and not self.debug:
88
- print(f"stderr: {output}")
89
-
90
53
  def Popen(
91
54
  self, cmd: list[str], stdin: Any = None, stdout: Any = PIPE, stderr: Any = None
92
55
  ) -> Popen:
@@ -103,13 +66,9 @@ def mux(input: Path, output: Path, stream: int) -> None:
103
66
  output_audio_stream = output_container.add_stream("pcm_s16le")
104
67
 
105
68
  for frame in input_container.decode(input_audio_stream):
106
- packet = output_audio_stream.encode(frame)
107
- if packet:
108
- output_container.mux(packet)
69
+ output_container.mux(output_audio_stream.encode(frame))
109
70
 
110
- packet = output_audio_stream.encode(None)
111
- if packet:
112
- output_container.mux(packet)
71
+ output_container.mux(output_audio_stream.encode(None))
113
72
 
114
73
  output_container.close()
115
74
  input_container.close()
@@ -148,11 +148,12 @@ Beware that the temp directory can get quite big.
148
148
  "--my-ffmpeg": "This is equivalent to `--ffmpeg-location ffmpeg`.",
149
149
  "--audio-bitrate": """
150
150
  `--audio-bitrate` sets the target bitrate for the audio encoder.
151
- The value accepts a natural number and the units: ``, `k`, `K`, and `M`.
152
- The special value `unset` may also be used, and means: Don't pass any value to ffmpeg, let it choose a default bitrate.
151
+ By default, the value is `auto` (let the encoder decide).
152
+ It can be set to a natural number with units: ``, `k`, `K`, `M`, or `G`.
153
+
153
154
  """.strip(),
154
155
  "--video-bitrate": """
155
- `--video-bitrate` sets the target bitrate for the video encoder. It accepts the same format as `--audio-bitrate` and the special `unset` value is allowed.
156
+ `--video-bitrate` sets the target bitrate for the video encoder. `auto` is set as the default. It accepts the same format as `--audio-bitrate`
156
157
  """.strip(),
157
158
  "--margin": """
158
159
  Default value: 0.2s,0.2s
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import os.path
4
+ from dataclasses import dataclass, field
5
+
6
+ import av
7
+ from av.audio.resampler import AudioResampler
8
+
9
+ from auto_editor.ffwrapper import FileInfo
10
+ from auto_editor.utils.bar import Bar
11
+ from auto_editor.utils.log import Log
12
+ from auto_editor.utils.types import _split_num_str
13
+
14
+
15
+ def parse_bitrate(input_: str, log: Log) -> int:
16
+ val, unit = _split_num_str(input_)
17
+
18
+ if unit.lower() == "k":
19
+ return int(val * 1000)
20
+ if unit == "M":
21
+ return int(val * 1_000_000)
22
+ if unit == "G":
23
+ return int(val * 1_000_000_000)
24
+ if unit == "":
25
+ return int(val)
26
+
27
+ log.error(f"Unknown bitrate: {input_}")
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Ensure:
32
+ _bar: Bar
33
+ _sr: int
34
+ log: Log
35
+ _audios: list[tuple[FileInfo, int]] = field(default_factory=list)
36
+
37
+ def audio(self, src: FileInfo, stream: int) -> str:
38
+ try:
39
+ label = self._audios.index((src, stream))
40
+ first_time = False
41
+ except ValueError:
42
+ self._audios.append((src, stream))
43
+ label = len(self._audios) - 1
44
+ first_time = True
45
+
46
+ out_path = os.path.join(self.log.temp, f"{label:x}.wav")
47
+
48
+ if first_time:
49
+ sample_rate = self._sr
50
+ bar = self._bar
51
+ self.log.debug(f"Making external audio: {out_path}")
52
+
53
+ in_container = av.open(src.path, "r")
54
+ out_container = av.open(
55
+ out_path, "w", format="wav", options={"rf64": "always"}
56
+ )
57
+ astream = in_container.streams.audio[stream]
58
+
59
+ if astream.duration is None or astream.time_base is None:
60
+ dur = 1.0
61
+ else:
62
+ dur = float(astream.duration * astream.time_base)
63
+
64
+ bar.start(dur, "Extracting audio")
65
+
66
+ output_astream = out_container.add_stream(
67
+ "pcm_s16le", layout="stereo", rate=sample_rate
68
+ )
69
+ resampler = AudioResampler(format="s16", layout="stereo", rate=sample_rate)
70
+ for i, frame in enumerate(in_container.decode(astream)):
71
+ if i % 1500 == 0 and frame.time is not None:
72
+ bar.tick(frame.time)
73
+
74
+ for new_frame in resampler.resample(frame):
75
+ out_container.mux(output_astream.encode(new_frame))
76
+
77
+ out_container.mux(output_astream.encode(None))
78
+
79
+ out_container.close()
80
+ in_container.close()
81
+ bar.end()
82
+
83
+ return out_path
@@ -35,8 +35,6 @@ norm_types = {
35
35
  ),
36
36
  }
37
37
 
38
- file_null = "NUL" if system() in ("Windows", "cli") else "/dev/null"
39
-
40
38
 
41
39
  def parse_norm(norm: str, log: Log) -> dict | None:
42
40
  if norm == "#f":
@@ -58,7 +56,7 @@ def parse_norm(norm: str, log: Log) -> dict | None:
58
56
  log.error(e)
59
57
 
60
58
 
61
- def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> list[str]:
59
+ def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> tuple[str, str]:
62
60
  start = end = 0
63
61
  lines = stderr.splitlines()
64
62
 
@@ -78,13 +76,7 @@ def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> list[str]:
78
76
  except MyError:
79
77
  log.error(f"Invalid loudnorm stats.\n{start=},{end=}\n{stderr!r}")
80
78
 
81
- for key in (
82
- "input_i",
83
- "input_tp",
84
- "input_lra",
85
- "input_thresh",
86
- "target_offset",
87
- ):
79
+ for key in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
88
80
  val = float(parsed[key])
89
81
  if val == float("-inf"):
90
82
  parsed[key] = -99
@@ -100,31 +92,12 @@ def parse_ebu_bytes(norm: dict, stderr: bytes, log: Log) -> list[str]:
100
92
  m_thresh = parsed["input_thresh"]
101
93
  target_offset = parsed["target_offset"]
102
94
 
103
- return [
104
- "-af",
105
- f"loudnorm=i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:offset={target_offset}"
95
+ filter = (
96
+ f"i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:offset={target_offset}"
106
97
  f":measured_i={m_i}:measured_lra={m_lra}:measured_tp={m_tp}"
107
- f":measured_thresh={m_thresh}:linear=true:print_format=json",
108
- ]
109
-
110
-
111
- def parse_peak_bytes(t: float, stderr: bytes, log: Log) -> list[str]:
112
- peak_level = None
113
- for line in stderr.splitlines():
114
- if line.startswith(b"[Parsed_astats_0") and b"Peak level dB:" in line:
115
- try:
116
- peak_level = float(line.split(b":")[1])
117
- except Exception:
118
- log.error(f"Invalid `astats` stats.\n{stderr!r}")
119
- break
120
-
121
- if peak_level is None:
122
- log.error(f"Invalid `astats` stats.\n{stderr!r}")
123
-
124
- adjustment = t - peak_level
125
- log.debug(f"current peak level: {peak_level}")
126
- log.print(f"peak adjustment: {adjustment}")
127
- return ["-af", f"volume={adjustment}"]
98
+ f":measured_thresh={m_thresh}:linear=true:print_format=json"
99
+ )
100
+ return "loudnorm", filter
128
101
 
129
102
 
130
103
  def apply_audio_normalization(
@@ -135,13 +108,9 @@ def apply_audio_normalization(
135
108
  f"loudnorm=i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:"
136
109
  f"offset={norm['gain']}:print_format=json"
137
110
  )
138
- else:
139
- first_pass = "astats=measure_overall=Peak_level:measure_perchannel=0"
140
-
141
- log.debug(f"audio norm first pass: {first_pass}")
142
-
143
- stderr = ffmpeg.Popen(
144
- [
111
+ log.debug(f"audio norm first pass: {first_pass}")
112
+ file_null = "NUL" if system() in ("Windows", "cli") else "/dev/null"
113
+ cmd = [
145
114
  "-hide_banner",
146
115
  "-i",
147
116
  f"{pre_master}",
@@ -152,19 +121,57 @@ def apply_audio_normalization(
152
121
  "-f",
153
122
  "null",
154
123
  file_null,
155
- ],
156
- stdin=PIPE,
157
- stdout=PIPE,
158
- stderr=PIPE,
159
- ).communicate()[1]
160
-
161
- if norm["tag"] == "ebu":
162
- cmd = parse_ebu_bytes(norm, stderr, log)
124
+ ]
125
+ process = ffmpeg.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
126
+ stderr = process.communicate()[1]
127
+ name, filter_args = parse_ebu_bytes(norm, stderr, log)
163
128
  else:
164
129
  assert "t" in norm
165
- cmd = parse_peak_bytes(norm["t"], stderr, log)
166
130
 
167
- ffmpeg.run(["-i", f"{pre_master}"] + cmd + [f"{path}"])
131
+ def get_peak_level(frame: av.AudioFrame) -> float:
132
+ # Calculate peak level in dB
133
+ # Should be equivalent to: -af astats=measure_overall=Peak_level:measure_perchannel=0
134
+ max_amplitude = np.abs(frame.to_ndarray()).max()
135
+ if max_amplitude > 0.0:
136
+ return -20.0 * np.log10(max_amplitude)
137
+ return -99.0
138
+
139
+ with av.open(pre_master) as container:
140
+ max_peak_level = -99.0
141
+ assert len(container.streams.video) == 0
142
+ for frame in container.decode(audio=0):
143
+ peak_level = get_peak_level(frame)
144
+ max_peak_level = max(max_peak_level, peak_level)
145
+
146
+ adjustment = norm["t"] - max_peak_level
147
+ log.debug(f"current peak level: {max_peak_level}")
148
+ log.print(f"peak adjustment: {adjustment:.3f}dB")
149
+ name, filter_args = "volume", f"{adjustment}"
150
+
151
+ with av.open(pre_master) as container:
152
+ input_stream = container.streams.audio[0]
153
+
154
+ output_file = av.open(path, mode="w")
155
+ output_stream = output_file.add_stream("pcm_s16le", rate=input_stream.rate)
156
+
157
+ graph = av.filter.Graph()
158
+ graph.link_nodes(
159
+ graph.add_abuffer(template=input_stream),
160
+ graph.add(name, filter_args),
161
+ graph.add("abuffersink"),
162
+ ).configure()
163
+ for frame in container.decode(input_stream):
164
+ graph.push(frame)
165
+ while True:
166
+ try:
167
+ aframe = graph.pull()
168
+ assert isinstance(aframe, av.AudioFrame)
169
+ output_file.mux(output_stream.encode(aframe))
170
+ except (av.BlockingIOError, av.EOFError):
171
+ break
172
+
173
+ output_file.mux(output_stream.encode(None))
174
+ output_file.close()
168
175
 
169
176
 
170
177
  def process_audio_clip(
@@ -212,19 +219,22 @@ def process_audio_clip(
212
219
  try:
213
220
  aframe = graph.pull()
214
221
  assert isinstance(aframe, av.AudioFrame)
215
- for packet in output_stream.encode(aframe):
216
- output_file.mux(packet)
222
+ output_file.mux(output_stream.encode(aframe))
217
223
  except (av.BlockingIOError, av.EOFError):
218
224
  break
219
225
 
220
226
  # Flush the stream
221
- for packet in output_stream.encode(None):
222
- output_file.mux(packet)
227
+ output_file.mux(output_stream.encode(None))
223
228
 
224
229
  input_file.close()
225
230
  output_file.close()
226
231
 
227
232
  output_bytes.seek(0)
233
+ has_filesig = output_bytes.read(4)
234
+ output_bytes.seek(0)
235
+ if not has_filesig: # Can rarely happen when clip is extremely small
236
+ return np.empty((0, 2), dtype=np.int16)
237
+
228
238
  return read(output_bytes)[1]
229
239
 
230
240