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 +1 -1
- auto_editor/__main__.py +67 -16
- auto_editor/analyze.py +11 -14
- auto_editor/edit.py +177 -52
- auto_editor/ffwrapper.py +36 -114
- auto_editor/help.py +4 -3
- auto_editor/output.py +22 -183
- auto_editor/render/audio.py +66 -57
- auto_editor/render/subtitle.py +74 -13
- auto_editor/render/video.py +166 -180
- auto_editor/subcommands/repl.py +12 -3
- auto_editor/subcommands/test.py +47 -36
- auto_editor/utils/container.py +2 -0
- auto_editor/utils/func.py +1 -27
- auto_editor/utils/types.py +2 -15
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/METADATA +2 -2
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/RECORD +22 -24
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/WHEEL +1 -1
- docs/build.py +1 -0
- auto_editor/utils/subtitle_tools.py +0 -29
- auto_editor/validate_input.py +0 -88
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/LICENSE +0 -0
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/entry_points.txt +0 -0
- {auto_editor-25.3.0.dist-info → auto_editor-26.0.0.dist-info}/top_level.txt +0 -0
auto_editor/ffwrapper.py
CHANGED
@@ -1,152 +1,74 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import os.path
|
4
|
-
import subprocess
|
5
3
|
import sys
|
6
4
|
from dataclasses import dataclass
|
7
5
|
from fractions import Fraction
|
8
6
|
from pathlib import Path
|
9
|
-
from re import search
|
10
7
|
from shutil import which
|
11
|
-
from subprocess import PIPE, Popen
|
8
|
+
from subprocess import PIPE, Popen, run
|
12
9
|
from typing import Any
|
13
10
|
|
14
11
|
import av
|
15
12
|
|
16
|
-
from auto_editor.utils.func import get_stdout
|
17
13
|
from auto_editor.utils.log import Log
|
18
14
|
|
19
15
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
debug: bool = False,
|
29
|
-
):
|
30
|
-
def _set_ff_path(ff_location: str | None, my_ffmpeg: bool) -> str:
|
31
|
-
if ff_location is not None:
|
32
|
-
return ff_location
|
33
|
-
if my_ffmpeg:
|
34
|
-
return "ffmpeg"
|
35
|
-
|
36
|
-
try:
|
37
|
-
import ae_ffmpeg
|
38
|
-
|
39
|
-
return ae_ffmpeg.get_path()
|
40
|
-
except ImportError:
|
41
|
-
return "ffmpeg"
|
42
|
-
|
43
|
-
self.debug = debug
|
44
|
-
self.show_cmd = show_cmd
|
45
|
-
_path: str | None = _set_ff_path(ff_location, my_ffmpeg)
|
46
|
-
|
47
|
-
if _path == "ffmpeg":
|
48
|
-
_path = which("ffmpeg")
|
49
|
-
|
50
|
-
if _path is None:
|
51
|
-
Log().error("Did not find ffmpeg on PATH.")
|
52
|
-
self.path = _path
|
53
|
-
|
16
|
+
def initFFmpeg(
|
17
|
+
log: Log, ff_location: str | None, my_ffmpeg: bool, show_cmd: bool, debug: bool
|
18
|
+
) -> FFmpeg:
|
19
|
+
if ff_location is not None:
|
20
|
+
program = ff_location
|
21
|
+
elif my_ffmpeg:
|
22
|
+
program = "ffmpeg"
|
23
|
+
else:
|
54
24
|
try:
|
55
|
-
|
56
|
-
self.version = _version.replace("ffmpeg version", "").strip().split(" ")[0]
|
57
|
-
except FileNotFoundError:
|
58
|
-
Log().error("ffmpeg must be installed and on PATH.")
|
25
|
+
import ae_ffmpeg
|
59
26
|
|
60
|
-
|
61
|
-
|
62
|
-
|
27
|
+
program = ae_ffmpeg.get_path()
|
28
|
+
except ImportError:
|
29
|
+
program = "ffmpeg"
|
63
30
|
|
64
|
-
|
65
|
-
|
66
|
-
|
31
|
+
path: str | None = which(program)
|
32
|
+
if path is None:
|
33
|
+
log.error("Did not find ffmpeg on PATH.")
|
34
|
+
|
35
|
+
return FFmpeg(log, path, show_cmd, debug)
|
36
|
+
|
37
|
+
|
38
|
+
@dataclass(slots=True)
|
39
|
+
class FFmpeg:
|
40
|
+
log: Log
|
41
|
+
path: str
|
42
|
+
show_cmd: bool
|
43
|
+
debug: bool
|
67
44
|
|
68
45
|
def run(self, cmd: list[str]) -> None:
|
69
46
|
cmd = [self.path, "-hide_banner", "-y"] + cmd
|
70
47
|
if not self.debug:
|
71
48
|
cmd.extend(["-nostats", "-loglevel", "error"])
|
72
|
-
self.
|
73
|
-
|
74
|
-
|
75
|
-
def run_check_errors(
|
76
|
-
self,
|
77
|
-
cmd: list[str],
|
78
|
-
log: Log,
|
79
|
-
show_out: bool = False,
|
80
|
-
path: str | None = None,
|
81
|
-
) -> None:
|
82
|
-
process = self.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
83
|
-
_, stderr = process.communicate()
|
84
|
-
|
85
|
-
if process.stdin is not None:
|
86
|
-
process.stdin.close()
|
87
|
-
output = stderr.decode("utf-8", "replace")
|
88
|
-
|
89
|
-
error_list = (
|
90
|
-
r"Unknown encoder '.*'",
|
91
|
-
r"-q:v qscale not available for encoder\. Use -b:v bitrate instead\.",
|
92
|
-
r"Specified sample rate .* is not supported",
|
93
|
-
r'Unable to parse option value ".*"',
|
94
|
-
r"Error setting option .* to value .*\.",
|
95
|
-
r"Undefined constant or missing '.*' in '.*'",
|
96
|
-
r"DLL .* failed to open",
|
97
|
-
r"Incompatible pixel format '.*' for codec '[A-Za-z0-9_]*'",
|
98
|
-
r"Unrecognized option '.*'",
|
99
|
-
r"Permission denied",
|
100
|
-
)
|
101
|
-
|
102
|
-
if self.debug:
|
103
|
-
print(f"stderr: {output}")
|
104
|
-
|
105
|
-
for item in error_list:
|
106
|
-
if check := search(item, output):
|
107
|
-
log.error(check.group())
|
108
|
-
|
109
|
-
if path is not None and not os.path.isfile(path):
|
110
|
-
log.error(f"The file {path} was not created.")
|
111
|
-
elif show_out and not self.debug:
|
112
|
-
print(f"stderr: {output}")
|
49
|
+
if self.show_cmd:
|
50
|
+
sys.stderr.write(f"{' '.join(cmd)}\n\n")
|
51
|
+
run(cmd)
|
113
52
|
|
114
53
|
def Popen(
|
115
54
|
self, cmd: list[str], stdin: Any = None, stdout: Any = PIPE, stderr: Any = None
|
116
55
|
) -> Popen:
|
117
|
-
|
118
|
-
|
119
|
-
return Popen(cmd, stdin=stdin, stdout=stdout, stderr=stderr)
|
120
|
-
|
121
|
-
def pipe(self, cmd: list[str]) -> str:
|
122
|
-
cmd = [self.path, "-y"] + cmd
|
123
|
-
|
124
|
-
self.print_cmd(cmd)
|
125
|
-
output = get_stdout(cmd)
|
126
|
-
self.print(output)
|
127
|
-
return output
|
56
|
+
if self.show_cmd:
|
57
|
+
sys.stderr.write(f"{self.path} {' '.join(cmd)}\n\n")
|
58
|
+
return Popen([self.path] + cmd, stdin=stdin, stdout=stdout, stderr=stderr)
|
128
59
|
|
129
60
|
|
130
|
-
def mux(input: Path, output: Path, stream: int
|
61
|
+
def mux(input: Path, output: Path, stream: int) -> None:
|
131
62
|
input_container = av.open(input, "r")
|
132
63
|
output_container = av.open(output, "w")
|
133
64
|
|
134
65
|
input_audio_stream = input_container.streams.audio[stream]
|
135
|
-
|
136
|
-
if codec is None:
|
137
|
-
codec = "pcm_s16le"
|
138
|
-
|
139
|
-
output_audio_stream = output_container.add_stream(codec)
|
140
|
-
assert isinstance(output_audio_stream, av.audio.AudioStream)
|
66
|
+
output_audio_stream = output_container.add_stream("pcm_s16le")
|
141
67
|
|
142
68
|
for frame in input_container.decode(input_audio_stream):
|
143
|
-
|
144
|
-
if packet:
|
145
|
-
output_container.mux(packet)
|
69
|
+
output_container.mux(output_audio_stream.encode(frame))
|
146
70
|
|
147
|
-
|
148
|
-
if packet:
|
149
|
-
output_container.mux(packet)
|
71
|
+
output_container.mux(output_audio_stream.encode(None))
|
150
72
|
|
151
73
|
output_container.close()
|
152
74
|
input_container.close()
|
auto_editor/help.py
CHANGED
@@ -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
|
auto_editor/output.py
CHANGED
@@ -2,26 +2,37 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import os.path
|
4
4
|
from dataclasses import dataclass, field
|
5
|
-
from fractions import Fraction
|
6
5
|
|
7
6
|
import av
|
8
7
|
from av.audio.resampler import AudioResampler
|
9
8
|
|
10
|
-
from auto_editor.ffwrapper import
|
9
|
+
from auto_editor.ffwrapper import FileInfo
|
11
10
|
from auto_editor.utils.bar import Bar
|
12
|
-
from auto_editor.utils.container import Container
|
13
11
|
from auto_editor.utils.log import Log
|
14
|
-
from auto_editor.utils.types import
|
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_}")
|
15
28
|
|
16
29
|
|
17
30
|
@dataclass(slots=True)
|
18
31
|
class Ensure:
|
19
|
-
_ffmpeg: FFmpeg
|
20
32
|
_bar: Bar
|
21
33
|
_sr: int
|
22
34
|
log: Log
|
23
35
|
_audios: list[tuple[FileInfo, int]] = field(default_factory=list)
|
24
|
-
_subtitles: list[tuple[FileInfo, int, str]] = field(default_factory=list)
|
25
36
|
|
26
37
|
def audio(self, src: FileInfo, stream: int) -> str:
|
27
38
|
try:
|
@@ -52,193 +63,21 @@ class Ensure:
|
|
52
63
|
|
53
64
|
bar.start(dur, "Extracting audio")
|
54
65
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
66
|
+
output_astream = out_container.add_stream(
|
67
|
+
"pcm_s16le", layout="stereo", rate=sample_rate
|
68
|
+
)
|
59
69
|
resampler = AudioResampler(format="s16", layout="stereo", rate=sample_rate)
|
60
70
|
for i, frame in enumerate(in_container.decode(astream)):
|
61
71
|
if i % 1500 == 0 and frame.time is not None:
|
62
72
|
bar.tick(frame.time)
|
63
73
|
|
64
74
|
for new_frame in resampler.resample(frame):
|
65
|
-
|
66
|
-
out_container.mux_one(packet)
|
75
|
+
out_container.mux(output_astream.encode(new_frame))
|
67
76
|
|
68
|
-
|
69
|
-
out_container.mux_one(packet)
|
77
|
+
out_container.mux(output_astream.encode(None))
|
70
78
|
|
71
79
|
out_container.close()
|
72
80
|
in_container.close()
|
73
81
|
bar.end()
|
74
82
|
|
75
83
|
return out_path
|
76
|
-
|
77
|
-
def subtitle(self, src: FileInfo, stream: int, ext: str) -> str:
|
78
|
-
try:
|
79
|
-
self._subtitles.index((src, stream, ext))
|
80
|
-
first_time = False
|
81
|
-
except ValueError:
|
82
|
-
self._subtitles.append((src, stream, ext))
|
83
|
-
first_time = True
|
84
|
-
|
85
|
-
out_path = os.path.join(self.log.temp, f"{stream}s.{ext}")
|
86
|
-
|
87
|
-
if first_time:
|
88
|
-
self.log.debug(f"Making external subtitle: {out_path}")
|
89
|
-
self.log.conwrite("Extracting subtitle")
|
90
|
-
self._ffmpeg.run(["-i", f"{src.path}", "-map", f"0:s:{stream}", out_path])
|
91
|
-
|
92
|
-
return out_path
|
93
|
-
|
94
|
-
|
95
|
-
def _ffset(option: str, value: str | None) -> list[str]:
|
96
|
-
if value is None or value == "unset" or value == "reserved":
|
97
|
-
return []
|
98
|
-
return [option] + [value]
|
99
|
-
|
100
|
-
|
101
|
-
def video_quality(args: Args) -> list[str]:
|
102
|
-
return (
|
103
|
-
_ffset("-b:v", args.video_bitrate)
|
104
|
-
+ ["-c:v", args.video_codec]
|
105
|
-
+ _ffset("-qscale:v", args.video_quality_scale)
|
106
|
-
+ ["-movflags", "faststart"]
|
107
|
-
)
|
108
|
-
|
109
|
-
|
110
|
-
def mux_quality_media(
|
111
|
-
ffmpeg: FFmpeg,
|
112
|
-
visual_output: list[tuple[bool, str]],
|
113
|
-
audio_output: list[str],
|
114
|
-
sub_output: list[str],
|
115
|
-
apply_v: bool,
|
116
|
-
ctr: Container,
|
117
|
-
output_path: str,
|
118
|
-
tb: Fraction,
|
119
|
-
args: Args,
|
120
|
-
src: FileInfo,
|
121
|
-
log: Log,
|
122
|
-
) -> None:
|
123
|
-
v_tracks = len(visual_output)
|
124
|
-
a_tracks = len(audio_output)
|
125
|
-
s_tracks = 0 if args.sn else len(sub_output)
|
126
|
-
|
127
|
-
cmd = ["-hide_banner", "-y", "-i", f"{src.path}"]
|
128
|
-
|
129
|
-
same_container = src.path.suffix == os.path.splitext(output_path)[1]
|
130
|
-
|
131
|
-
for is_video, path in visual_output:
|
132
|
-
if is_video or ctr.allow_image:
|
133
|
-
cmd.extend(["-i", path])
|
134
|
-
else:
|
135
|
-
v_tracks -= 1
|
136
|
-
|
137
|
-
if a_tracks > 0:
|
138
|
-
if args.keep_tracks_separate and ctr.max_audios is None:
|
139
|
-
for path in audio_output:
|
140
|
-
cmd.extend(["-i", path])
|
141
|
-
else:
|
142
|
-
# Merge all the audio a_tracks into one.
|
143
|
-
new_a_file = os.path.join(log.temp, "new_audio.wav")
|
144
|
-
if a_tracks > 1:
|
145
|
-
new_cmd = []
|
146
|
-
for path in audio_output:
|
147
|
-
new_cmd.extend(["-i", path])
|
148
|
-
new_cmd.extend(
|
149
|
-
[
|
150
|
-
"-filter_complex",
|
151
|
-
f"amix=inputs={a_tracks}:duration=longest",
|
152
|
-
"-ac",
|
153
|
-
"2",
|
154
|
-
new_a_file,
|
155
|
-
]
|
156
|
-
)
|
157
|
-
ffmpeg.run(new_cmd)
|
158
|
-
a_tracks = 1
|
159
|
-
else:
|
160
|
-
new_a_file = audio_output[0]
|
161
|
-
cmd.extend(["-i", new_a_file])
|
162
|
-
|
163
|
-
for subfile in sub_output:
|
164
|
-
cmd.extend(["-i", subfile])
|
165
|
-
|
166
|
-
for i in range(v_tracks + s_tracks + a_tracks):
|
167
|
-
cmd.extend(["-map", f"{i+1}:0"])
|
168
|
-
|
169
|
-
cmd.extend(["-map_metadata", "0"])
|
170
|
-
|
171
|
-
track = 0
|
172
|
-
for is_video, path in visual_output:
|
173
|
-
if is_video:
|
174
|
-
if apply_v:
|
175
|
-
cmd += video_quality(args)
|
176
|
-
else:
|
177
|
-
# Real video is only allowed on track 0
|
178
|
-
cmd += ["-c:v:0", "copy"]
|
179
|
-
|
180
|
-
if float(tb).is_integer():
|
181
|
-
cmd += ["-video_track_timescale", f"{tb}"]
|
182
|
-
|
183
|
-
elif ctr.allow_image:
|
184
|
-
ext = os.path.splitext(path)[1][1:]
|
185
|
-
cmd += [f"-c:v:{track}", ext, f"-disposition:v:{track}", "attached_pic"]
|
186
|
-
|
187
|
-
track += 1
|
188
|
-
del track
|
189
|
-
|
190
|
-
for i, vstream in enumerate(src.videos):
|
191
|
-
if i > v_tracks:
|
192
|
-
break
|
193
|
-
if vstream.lang is not None:
|
194
|
-
cmd.extend([f"-metadata:s:v:{i}", f"language={vstream.lang}"])
|
195
|
-
for i, astream in enumerate(src.audios):
|
196
|
-
if i > a_tracks:
|
197
|
-
break
|
198
|
-
if astream.lang is not None:
|
199
|
-
cmd.extend([f"-metadata:s:a:{i}", f"language={astream.lang}"])
|
200
|
-
for i, sstream in enumerate(src.subtitles):
|
201
|
-
if i > s_tracks:
|
202
|
-
break
|
203
|
-
if sstream.lang is not None:
|
204
|
-
cmd.extend([f"-metadata:s:s:{i}", f"language={sstream.lang}"])
|
205
|
-
|
206
|
-
if s_tracks > 0:
|
207
|
-
scodec = src.subtitles[0].codec
|
208
|
-
if same_container:
|
209
|
-
cmd.extend(["-c:s", scodec])
|
210
|
-
elif ctr.scodecs is not None:
|
211
|
-
if scodec not in ctr.scodecs:
|
212
|
-
scodec = ctr.default_sub
|
213
|
-
cmd.extend(["-c:s", scodec])
|
214
|
-
|
215
|
-
if a_tracks > 0:
|
216
|
-
cmd += _ffset("-c:a", args.audio_codec) + _ffset("-b:a", args.audio_bitrate)
|
217
|
-
|
218
|
-
if same_container and v_tracks > 0:
|
219
|
-
color_range = src.videos[0].color_range
|
220
|
-
colorspace = src.videos[0].color_space
|
221
|
-
color_prim = src.videos[0].color_primaries
|
222
|
-
color_trc = src.videos[0].color_transfer
|
223
|
-
|
224
|
-
if color_range == 1 or color_range == 2:
|
225
|
-
cmd.extend(["-color_range", f"{color_range}"])
|
226
|
-
if colorspace in (0, 1) or (colorspace >= 3 and colorspace < 16):
|
227
|
-
cmd.extend(["-colorspace", f"{colorspace}"])
|
228
|
-
if color_prim == 1 or (color_prim >= 4 and color_prim < 17):
|
229
|
-
cmd.extend(["-color_primaries", f"{color_prim}"])
|
230
|
-
if color_trc == 1 or (color_trc >= 4 and color_trc < 22):
|
231
|
-
cmd.extend(["-color_trc", f"{color_trc}"])
|
232
|
-
|
233
|
-
if args.extras is not None:
|
234
|
-
cmd.extend(args.extras.split(" "))
|
235
|
-
cmd.extend(["-strict", "-2"]) # Allow experimental codecs.
|
236
|
-
|
237
|
-
if s_tracks > 0:
|
238
|
-
cmd.extend(["-map", "0:t?"]) # Add input attachments to output.
|
239
|
-
|
240
|
-
if not args.dn:
|
241
|
-
cmd.extend(["-map", "0:d?"])
|
242
|
-
|
243
|
-
cmd.append(output_path)
|
244
|
-
ffmpeg.run_check_errors(cmd, log, path=output_path)
|
auto_editor/render/audio.py
CHANGED
@@ -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(
|
@@ -180,7 +187,6 @@ def process_audio_clip(
|
|
180
187
|
output_bytes = io.BytesIO()
|
181
188
|
output_file = av.open(output_bytes, mode="w", format="wav")
|
182
189
|
output_stream = output_file.add_stream("pcm_s16le", rate=sr)
|
183
|
-
assert isinstance(output_stream, av.audio.AudioStream)
|
184
190
|
|
185
191
|
graph = av.filter.Graph()
|
186
192
|
args = [graph.add_abuffer(template=input_stream)]
|
@@ -212,20 +218,23 @@ def process_audio_clip(
|
|
212
218
|
while True:
|
213
219
|
try:
|
214
220
|
aframe = graph.pull()
|
215
|
-
assert isinstance(aframe, av.
|
216
|
-
|
217
|
-
output_file.mux(packet)
|
221
|
+
assert isinstance(aframe, av.AudioFrame)
|
222
|
+
output_file.mux(output_stream.encode(aframe))
|
218
223
|
except (av.BlockingIOError, av.EOFError):
|
219
224
|
break
|
220
225
|
|
221
226
|
# Flush the stream
|
222
|
-
|
223
|
-
output_file.mux(packet)
|
227
|
+
output_file.mux(output_stream.encode(None))
|
224
228
|
|
225
229
|
input_file.close()
|
226
230
|
output_file.close()
|
227
231
|
|
228
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
|
+
|
229
238
|
return read(output_bytes)[1]
|
230
239
|
|
231
240
|
|