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.
- {auto_editor-25.3.1 → auto_editor-26.0.0}/PKG-INFO +1 -1
- auto_editor-26.0.0/auto_editor/__init__.py +1 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/__main__.py +1 -11
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/edit.py +156 -44
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/ffwrapper.py +2 -43
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/help.py +4 -3
- auto_editor-26.0.0/auto_editor/output.py +83 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/audio.py +65 -55
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/subtitle.py +69 -10
- auto_editor-26.0.0/auto_editor/render/video.py +333 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/repl.py +12 -3
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/test.py +41 -37
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/container.py +2 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/func.py +1 -1
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/types.py +2 -15
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/PKG-INFO +1 -1
- auto_editor-25.3.1/auto_editor/__init__.py +0 -1
- auto_editor-25.3.1/auto_editor/output.py +0 -244
- auto_editor-25.3.1/auto_editor/render/video.py +0 -347
- {auto_editor-25.3.1 → auto_editor-26.0.0}/LICENSE +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/README.md +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/analyze.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/fcp11.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/fcp7.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/json.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/shotcut.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/formats/utils.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/json.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/libintrospection.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/libmath.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/palet.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lang/stdenv.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/contracts.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/data_structs.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/lib/err.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/make_layers.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/preview.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/render/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/desc.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/info.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/levels.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/palet.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/subcommands/subdump.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/timeline.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/__init__.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/bar.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/chunks.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/cmdkw.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/encoder.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/utils/log.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/vanparse.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor/wavfile.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/SOURCES.txt +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/dependency_links.txt +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/entry_points.txt +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/requires.txt +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/auto_editor.egg-info/top_level.txt +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/docs/build.py +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/pyproject.toml +0 -0
- {auto_editor-25.3.1 → auto_editor-26.0.0}/setup.cfg +0 -0
@@ -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").
|
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,
|
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
|
-
|
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
|
-
|
107
|
+
codec = ctr.default_aud
|
98
108
|
if codec == "mp3float":
|
99
|
-
|
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
|
110
|
-
|
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,
|
284
|
+
def make_media(tl: v3, output_path: str) -> None:
|
274
285
|
assert src is not None
|
275
286
|
|
276
|
-
|
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
|
-
|
290
|
+
sub_paths = make_new_subtitles(tl, log)
|
291
|
+
else:
|
292
|
+
sub_paths = []
|
284
293
|
|
285
294
|
if ctr.default_aud != "none":
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
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
|
-
|
107
|
-
if packet:
|
108
|
-
output_container.mux(packet)
|
69
|
+
output_container.mux(output_audio_stream.encode(frame))
|
109
70
|
|
110
|
-
|
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
|
-
|
152
|
-
|
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`
|
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) ->
|
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
|
-
|
104
|
-
"
|
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
|
-
|
139
|
-
|
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
|
-
|
158
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
|