auto-editor 26.3.3__py3-none-any.whl → 27.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 +9 -5
- auto_editor/analyze.py +30 -36
- auto_editor/cmds/info.py +1 -1
- auto_editor/cmds/levels.py +3 -3
- auto_editor/cmds/subdump.py +62 -8
- auto_editor/cmds/test.py +56 -40
- auto_editor/edit.py +34 -44
- auto_editor/ffwrapper.py +9 -9
- auto_editor/formats/json.py +2 -2
- auto_editor/{lang/json.py → json.py} +39 -43
- auto_editor/lang/palet.py +2 -2
- auto_editor/lang/stdenv.py +12 -0
- auto_editor/output.py +4 -4
- auto_editor/render/audio.py +26 -24
- auto_editor/render/subtitle.py +10 -14
- auto_editor/render/video.py +40 -44
- auto_editor/utils/container.py +3 -3
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info}/METADATA +7 -6
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info}/RECORD +25 -25
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info}/WHEEL +1 -1
- docs/build.py +16 -7
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info}/entry_points.txt +0 -0
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info/licenses}/LICENSE +0 -0
- {auto_editor-26.3.3.dist-info → auto_editor-27.0.0.dist-info}/top_level.txt +0 -0
auto_editor/edit.py
CHANGED
@@ -8,8 +8,8 @@ from os.path import splitext
|
|
8
8
|
from subprocess import run
|
9
9
|
from typing import TYPE_CHECKING, Any
|
10
10
|
|
11
|
-
import
|
12
|
-
from
|
11
|
+
import bv
|
12
|
+
from bv import AudioResampler, Codec
|
13
13
|
|
14
14
|
from auto_editor.ffwrapper import FileInfo, initFileInfo
|
15
15
|
from auto_editor.lib.contracts import is_int, is_str
|
@@ -83,18 +83,10 @@ def set_video_codec(
|
|
83
83
|
return ctr.default_vid
|
84
84
|
return codec
|
85
85
|
|
86
|
-
if codec == "copy":
|
87
|
-
log.deprecated("The `copy` codec is deprecated. auto-editor always re-encodes")
|
88
|
-
if src is None:
|
89
|
-
log.error("No input to copy its codec from.")
|
90
|
-
if not src.videos:
|
91
|
-
log.error("Input file does not have a video stream to copy codec from.")
|
92
|
-
codec = src.videos[0].codec
|
93
|
-
|
94
86
|
if ctr.vcodecs is not None and codec not in ctr.vcodecs:
|
95
87
|
try:
|
96
88
|
cobj = Codec(codec, "w")
|
97
|
-
except
|
89
|
+
except bv.codec.codec.UnknownCodecError:
|
98
90
|
log.error(f"Unknown encoder: {codec}")
|
99
91
|
# Normalize encoder names
|
100
92
|
if cobj.id not in (Codec(x, "w").id for x in ctr.vcodecs):
|
@@ -111,7 +103,7 @@ def set_audio_codec(
|
|
111
103
|
codec = "aac"
|
112
104
|
else:
|
113
105
|
codec = src.audios[0].codec
|
114
|
-
if
|
106
|
+
if bv.Codec(codec, "w").audio_formats is None:
|
115
107
|
codec = "aac"
|
116
108
|
if codec not in ctr.acodecs and ctr.default_aud != "none":
|
117
109
|
codec = ctr.default_aud
|
@@ -119,18 +111,10 @@ def set_audio_codec(
|
|
119
111
|
codec = "aac"
|
120
112
|
return codec
|
121
113
|
|
122
|
-
if codec == "copy":
|
123
|
-
log.deprecated("The `copy` codec is deprecated. auto-editor always re-encodes")
|
124
|
-
if src is None:
|
125
|
-
log.error("No input to copy its codec from.")
|
126
|
-
if not src.audios:
|
127
|
-
log.error("Input file does not have an audio stream to copy codec from.")
|
128
|
-
codec = src.audios[0].codec
|
129
|
-
|
130
114
|
if ctr.acodecs is None or codec not in ctr.acodecs:
|
131
115
|
try:
|
132
116
|
cobj = Codec(codec, "w")
|
133
|
-
except
|
117
|
+
except bv.codec.codec.UnknownCodecError:
|
134
118
|
log.error(f"Unknown encoder: {codec}")
|
135
119
|
# Normalize encoder names
|
136
120
|
if cobj.id not in (Codec(x, "w").id for x in ctr.acodecs):
|
@@ -178,6 +162,10 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
178
162
|
bar = initBar(args.progress)
|
179
163
|
tl = None
|
180
164
|
|
165
|
+
if args.keep_tracks_separate:
|
166
|
+
log.deprecated("--keep-tracks-separate is deprecated.")
|
167
|
+
args.keep_tracks_separate = False
|
168
|
+
|
181
169
|
if paths:
|
182
170
|
path_ext = splitext(paths[0])[1].lower()
|
183
171
|
if path_ext == ".xml":
|
@@ -289,9 +277,6 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
289
277
|
args.video_codec = set_video_codec(args.video_codec, src, out_ext, ctr, log)
|
290
278
|
args.audio_codec = set_audio_codec(args.audio_codec, src, out_ext, ctr, log)
|
291
279
|
|
292
|
-
if args.keep_tracks_separate and ctr.max_audios == 1:
|
293
|
-
log.warning(f"'{out_ext}' container doesn't support multiple audio tracks.")
|
294
|
-
|
295
280
|
def make_media(tl: v3, output_path: str) -> None:
|
296
281
|
assert src is not None
|
297
282
|
|
@@ -307,38 +292,38 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
307
292
|
if mov_flags:
|
308
293
|
options["movflags"] = "+".join(mov_flags)
|
309
294
|
|
310
|
-
output =
|
295
|
+
output = bv.open(output_path, "w", container_options=options)
|
311
296
|
|
312
|
-
|
313
|
-
|
297
|
+
# Setup video
|
298
|
+
if ctr.default_vid != "none" and tl.v:
|
299
|
+
vframes = render_av(output, tl, args, log)
|
300
|
+
output_stream: bv.VideoStream | None
|
301
|
+
output_stream = next(vframes) # type: ignore
|
314
302
|
else:
|
315
|
-
|
303
|
+
output_stream, vframes = None, iter([])
|
316
304
|
|
305
|
+
# Setup audio
|
317
306
|
if ctr.default_aud != "none":
|
318
307
|
ensure = Ensure(bar, samplerate, log)
|
319
308
|
audio_paths = make_new_audio(tl, ctr, ensure, args, bar, log)
|
320
309
|
else:
|
321
310
|
audio_paths = []
|
322
311
|
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
output_stream = next(vframes)
|
327
|
-
else:
|
328
|
-
output_stream, vframes = None, iter([])
|
312
|
+
if len(audio_paths) > 1 and ctr.max_audios == 1:
|
313
|
+
log.warning("Dropping extra audio streams (container only allows one)")
|
314
|
+
audio_paths = audio_paths[0:1]
|
329
315
|
|
330
|
-
# Setup audio
|
331
316
|
if audio_paths:
|
332
317
|
try:
|
333
|
-
audio_encoder =
|
334
|
-
except
|
318
|
+
audio_encoder = bv.Codec(args.audio_codec, "w")
|
319
|
+
except bv.FFmpegError as e:
|
335
320
|
log.error(e)
|
336
321
|
if audio_encoder.audio_formats is None:
|
337
322
|
log.error(f"{args.audio_codec}: No known audio formats avail.")
|
338
323
|
audio_format = audio_encoder.audio_formats[0]
|
339
324
|
resampler = AudioResampler(format=audio_format, layout="stereo", rate=tl.sr)
|
340
325
|
|
341
|
-
audio_streams: list[
|
326
|
+
audio_streams: list[bv.AudioStream] = []
|
342
327
|
audio_inputs = []
|
343
328
|
audio_gen_frames = []
|
344
329
|
for i, audio_path in enumerate(audio_paths):
|
@@ -348,7 +333,7 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
348
333
|
rate=tl.sr,
|
349
334
|
time_base=Fraction(1, tl.sr),
|
350
335
|
)
|
351
|
-
if not isinstance(audio_stream,
|
336
|
+
if not isinstance(audio_stream, bv.AudioStream):
|
352
337
|
log.error(f"Not a known audio codec: {args.audio_codec}")
|
353
338
|
|
354
339
|
if args.audio_bitrate != "auto":
|
@@ -360,17 +345,22 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
360
345
|
audio_stream.metadata["language"] = src.audios[i].lang # type: ignore
|
361
346
|
|
362
347
|
audio_streams.append(audio_stream)
|
363
|
-
audio_input =
|
348
|
+
audio_input = bv.open(audio_path)
|
364
349
|
audio_inputs.append(audio_input)
|
365
350
|
audio_gen_frames.append(audio_input.decode(audio=0))
|
366
351
|
|
367
352
|
# Setup subtitles
|
353
|
+
if ctr.default_sub != "none" and not args.sn:
|
354
|
+
sub_paths = make_new_subtitles(tl, log)
|
355
|
+
else:
|
356
|
+
sub_paths = []
|
357
|
+
|
368
358
|
subtitle_streams = []
|
369
359
|
subtitle_inputs = []
|
370
360
|
sub_gen_frames = []
|
371
361
|
|
372
362
|
for i, sub_path in enumerate(sub_paths):
|
373
|
-
subtitle_input =
|
363
|
+
subtitle_input = bv.open(sub_path)
|
374
364
|
subtitle_inputs.append(subtitle_input)
|
375
365
|
subtitle_stream = output.add_stream_from_template(
|
376
366
|
subtitle_input.streams.subtitles[0]
|
@@ -499,14 +489,14 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
|
|
499
489
|
output.mux(item.stream.encode(item.frame))
|
500
490
|
elif frame_type == "subtitle":
|
501
491
|
output.mux(item.frame)
|
502
|
-
except
|
492
|
+
except bv.error.ExternalError:
|
503
493
|
log.error(
|
504
494
|
f"Generic error for encoder: {item.stream.name}\n"
|
505
495
|
f"at {item.index} time_base\nPerhaps video quality settings are too low?"
|
506
496
|
)
|
507
|
-
except
|
497
|
+
except bv.FileNotFoundError:
|
508
498
|
log.error(f"File not found: {output_path}")
|
509
|
-
except
|
499
|
+
except bv.FFmpegError as e:
|
510
500
|
log.error(e)
|
511
501
|
|
512
502
|
if bar_index:
|
auto_editor/ffwrapper.py
CHANGED
@@ -4,14 +4,14 @@ from dataclasses import dataclass
|
|
4
4
|
from fractions import Fraction
|
5
5
|
from pathlib import Path
|
6
6
|
|
7
|
-
import
|
7
|
+
import bv
|
8
8
|
|
9
9
|
from auto_editor.utils.log import Log
|
10
10
|
|
11
11
|
|
12
12
|
def mux(input: Path, output: Path, stream: int) -> None:
|
13
|
-
input_container =
|
14
|
-
output_container =
|
13
|
+
input_container = bv.open(input, "r")
|
14
|
+
output_container = bv.open(output, "w")
|
15
15
|
|
16
16
|
input_audio_stream = input_container.streams.audio[stream]
|
17
17
|
output_audio_stream = output_container.add_stream("pcm_s16le")
|
@@ -92,12 +92,12 @@ class FileInfo:
|
|
92
92
|
|
93
93
|
def initFileInfo(path: str, log: Log) -> FileInfo:
|
94
94
|
try:
|
95
|
-
cont =
|
96
|
-
except
|
95
|
+
cont = bv.open(path, "r")
|
96
|
+
except bv.error.FileNotFoundError:
|
97
97
|
log.error(f"Input file doesn't exist: {path}")
|
98
|
-
except
|
98
|
+
except bv.error.IsADirectoryError:
|
99
99
|
log.error(f"Expected a media file, but got a directory: {path}")
|
100
|
-
except
|
100
|
+
except bv.error.InvalidDataError:
|
101
101
|
log.error(f"Invalid data when processing: {path}")
|
102
102
|
|
103
103
|
videos: tuple[VideoStream, ...] = ()
|
@@ -126,7 +126,7 @@ def initFileInfo(path: str, log: Log) -> FileInfo:
|
|
126
126
|
VideoStream(
|
127
127
|
v.width,
|
128
128
|
v.height,
|
129
|
-
v.
|
129
|
+
v.codec.canonical_name,
|
130
130
|
fps,
|
131
131
|
vdur,
|
132
132
|
sar,
|
@@ -167,7 +167,7 @@ def initFileInfo(path: str, log: Log) -> FileInfo:
|
|
167
167
|
|
168
168
|
desc = cont.metadata.get("description", None)
|
169
169
|
bitrate = 0 if cont.bit_rate is None else cont.bit_rate
|
170
|
-
dur = 0 if cont.duration is None else cont.duration /
|
170
|
+
dur = 0 if cont.duration is None else cont.duration / bv.time_base
|
171
171
|
|
172
172
|
cont.close()
|
173
173
|
|
auto_editor/formats/json.py
CHANGED
@@ -7,7 +7,7 @@ from fractions import Fraction
|
|
7
7
|
from typing import Any
|
8
8
|
|
9
9
|
from auto_editor.ffwrapper import FileInfo, initFileInfo
|
10
|
-
from auto_editor.
|
10
|
+
from auto_editor.json import dump, load
|
11
11
|
from auto_editor.lib.err import MyError
|
12
12
|
from auto_editor.timeline import (
|
13
13
|
ASpace,
|
@@ -221,7 +221,7 @@ def read_v1(tl: Any, log: Log) -> v3:
|
|
221
221
|
def read_json(path: str, log: Log) -> v3:
|
222
222
|
with open(path, encoding="utf-8", errors="ignore") as f:
|
223
223
|
try:
|
224
|
-
tl =
|
224
|
+
tl = load(path, f)
|
225
225
|
except MyError as e:
|
226
226
|
log.error(e)
|
227
227
|
|
@@ -11,29 +11,10 @@ if TYPE_CHECKING:
|
|
11
11
|
|
12
12
|
from _typeshed import SupportsWrite
|
13
13
|
|
14
|
-
|
15
|
-
class Token:
|
16
|
-
__slots__ = ("type", "value")
|
17
|
-
|
18
|
-
def __init__(self, type: int, value: object):
|
19
|
-
self.type = type
|
20
|
-
self.value = value
|
21
|
-
|
22
|
-
def __str__(self) -> str:
|
23
|
-
return f"{self.type=} {self.value=}"
|
24
|
-
|
25
|
-
__repr__ = __str__
|
14
|
+
Token = tuple[int, object]
|
26
15
|
|
27
16
|
|
28
17
|
EOF, LCUR, RCUR, LBRAC, RBRAC, COL, COMMA, STR, VAL = range(9)
|
29
|
-
table = {
|
30
|
-
"{": LCUR,
|
31
|
-
"}": RCUR,
|
32
|
-
"[": LBRAC,
|
33
|
-
"]": RBRAC,
|
34
|
-
":": COL,
|
35
|
-
",": COMMA,
|
36
|
-
}
|
37
18
|
str_escape = {
|
38
19
|
"\\": "\\",
|
39
20
|
"/": "/",
|
@@ -88,6 +69,12 @@ class Lexer:
|
|
88
69
|
self.char = self.text[self.pos]
|
89
70
|
self.column += 1
|
90
71
|
|
72
|
+
def rewind(self) -> None:
|
73
|
+
self.pos = 0
|
74
|
+
self.lineno = 1
|
75
|
+
self.column = 1
|
76
|
+
self.char = self.text[self.pos] if self.text else None
|
77
|
+
|
91
78
|
def peek(self) -> str | None:
|
92
79
|
peek_pos = self.pos + 1
|
93
80
|
return None if peek_pos > len(self.text) - 1 else self.text[peek_pos]
|
@@ -142,7 +129,7 @@ class Lexer:
|
|
142
129
|
result = buf.getvalue()
|
143
130
|
|
144
131
|
try:
|
145
|
-
return
|
132
|
+
return (VAL, float(result) if has_dot else int(result))
|
146
133
|
except ValueError:
|
147
134
|
self.error(f"`{result}` is not a valid JSON Number")
|
148
135
|
|
@@ -158,7 +145,7 @@ class Lexer:
|
|
158
145
|
|
159
146
|
if self.char == '"':
|
160
147
|
self.advance()
|
161
|
-
return
|
148
|
+
return (STR, self.string())
|
162
149
|
|
163
150
|
if self.char == "-":
|
164
151
|
_peek = self.peek()
|
@@ -168,10 +155,11 @@ class Lexer:
|
|
168
155
|
if self.char in "0123456789.":
|
169
156
|
return self.number()
|
170
157
|
|
158
|
+
table = {"{": LCUR, "}": RCUR, "[": LBRAC, "]": RBRAC, ":": COL, ",": COMMA}
|
171
159
|
if self.char in table:
|
172
160
|
key = table[self.char]
|
173
161
|
self.advance()
|
174
|
-
return
|
162
|
+
return (key, None)
|
175
163
|
|
176
164
|
keyword = ""
|
177
165
|
for i in range(5): # Longest valid keyword length
|
@@ -181,14 +169,14 @@ class Lexer:
|
|
181
169
|
self.advance()
|
182
170
|
|
183
171
|
if keyword == "true":
|
184
|
-
return
|
172
|
+
return (VAL, True)
|
185
173
|
if keyword == "false":
|
186
|
-
return
|
174
|
+
return (VAL, False)
|
187
175
|
if keyword == "null":
|
188
|
-
return
|
176
|
+
return (VAL, None)
|
189
177
|
|
190
178
|
self.error(f"Invalid keyword: `{keyword}`")
|
191
|
-
return
|
179
|
+
return (EOF, None)
|
192
180
|
|
193
181
|
|
194
182
|
class Parser:
|
@@ -204,49 +192,49 @@ class Parser:
|
|
204
192
|
def expr(self) -> Any:
|
205
193
|
self.current_token
|
206
194
|
|
207
|
-
if self.current_token
|
208
|
-
val = self.current_token
|
195
|
+
if self.current_token[0] in {STR, VAL}:
|
196
|
+
val = self.current_token[1]
|
209
197
|
self.eat()
|
210
198
|
return val
|
211
199
|
|
212
|
-
if self.current_token
|
200
|
+
if self.current_token[0] == LCUR:
|
213
201
|
self.eat()
|
214
202
|
|
215
203
|
my_dic = {}
|
216
|
-
while self.current_token
|
217
|
-
if self.current_token
|
218
|
-
if self.current_token
|
204
|
+
while self.current_token[0] != RCUR:
|
205
|
+
if self.current_token[0] != STR:
|
206
|
+
if self.current_token[0] in {LBRAC, VAL}:
|
219
207
|
self.lexer.error("JSON Objects only allow strings as keys")
|
220
208
|
self.lexer.error("Expected closing `}`")
|
221
|
-
key = self.current_token
|
209
|
+
key = self.current_token[1]
|
222
210
|
if key in my_dic:
|
223
211
|
self.lexer.error(f"Object has repeated key `{key}`")
|
224
212
|
self.eat()
|
225
|
-
if self.current_token
|
213
|
+
if self.current_token[0] != COL:
|
226
214
|
self.lexer.error("Expected `:`")
|
227
215
|
self.eat()
|
228
216
|
|
229
217
|
my_dic[key] = self.expr()
|
230
|
-
if self.current_token
|
231
|
-
if self.current_token
|
218
|
+
if self.current_token[0] != RCUR:
|
219
|
+
if self.current_token[0] != COMMA:
|
232
220
|
self.lexer.error("Expected `,` between Object entries")
|
233
221
|
self.eat()
|
234
|
-
if self.current_token
|
222
|
+
if self.current_token[0] == RCUR:
|
235
223
|
self.lexer.error("Trailing `,` in Object")
|
236
224
|
|
237
225
|
self.eat()
|
238
226
|
return my_dic
|
239
227
|
|
240
|
-
if self.current_token
|
228
|
+
if self.current_token[0] == LBRAC:
|
241
229
|
self.eat()
|
242
230
|
my_arr = []
|
243
|
-
while self.current_token
|
231
|
+
while self.current_token[0] != RBRAC:
|
244
232
|
my_arr.append(self.expr())
|
245
|
-
if self.current_token
|
246
|
-
if self.current_token
|
233
|
+
if self.current_token[0] != RBRAC:
|
234
|
+
if self.current_token[0] != COMMA:
|
247
235
|
self.lexer.error("Expected `,` between array entries")
|
248
236
|
self.eat()
|
249
|
-
if self.current_token
|
237
|
+
if self.current_token[0] == RBRAC:
|
250
238
|
self.lexer.error("Trailing `,` in array")
|
251
239
|
self.eat()
|
252
240
|
return my_arr
|
@@ -254,6 +242,14 @@ class Parser:
|
|
254
242
|
raise MyError(f"Unknown token: {self.current_token}")
|
255
243
|
|
256
244
|
|
245
|
+
def load(path: str, f: str | bytes | TextIOWrapper) -> dict[str, object]:
|
246
|
+
lexer = Lexer(path, f)
|
247
|
+
if lexer.get_next_token()[0] != LCUR:
|
248
|
+
raise MyError("Expected JSON Object")
|
249
|
+
lexer.rewind()
|
250
|
+
return Parser(lexer).expr()
|
251
|
+
|
252
|
+
|
257
253
|
def dump(
|
258
254
|
data: object, file: SupportsWrite[str], indent: int | None = None, level: int = 0
|
259
255
|
) -> None:
|
auto_editor/lang/palet.py
CHANGED
@@ -646,8 +646,8 @@ stack_trace_manager = StackTraceManager()
|
|
646
646
|
|
647
647
|
|
648
648
|
def my_eval(env: Env, node: object) -> Any:
|
649
|
-
def make_trace(sym:
|
650
|
-
return f" at {sym.val} ({sym.lineno}:{sym.column})"
|
649
|
+
def make_trace(sym: object) -> str:
|
650
|
+
return f" at {sym.val} ({sym.lineno}:{sym.column})" if type(sym) is Sym else ""
|
651
651
|
|
652
652
|
if type(node) is Sym:
|
653
653
|
val = env.get(node.val)
|
auto_editor/lang/stdenv.py
CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from typing import TYPE_CHECKING
|
4
4
|
|
5
|
+
import bv
|
6
|
+
|
5
7
|
from auto_editor.analyze import mut_remove_large, mut_remove_small
|
6
8
|
from auto_editor.lib.contracts import *
|
7
9
|
from auto_editor.lib.data_structs import *
|
@@ -749,6 +751,13 @@ def make_standard_env() -> dict[str, Any]:
|
|
749
751
|
raise MyError("@r: attribute must be an identifier")
|
750
752
|
|
751
753
|
base = my_eval(env, node[1])
|
754
|
+
|
755
|
+
if hasattr(base, "__pyx_vtable__"):
|
756
|
+
try:
|
757
|
+
return getattr(base, node[2].val)
|
758
|
+
except AttributeError as e:
|
759
|
+
raise MyError(e)
|
760
|
+
|
752
761
|
if type(base) is PaletClass:
|
753
762
|
if type(name := node[2]) is not Sym:
|
754
763
|
raise MyError("@r: class attribute must be an identifier")
|
@@ -1171,6 +1180,9 @@ def make_standard_env() -> dict[str, Any]:
|
|
1171
1180
|
"string->vector", lambda s: [Char(c) for c in s], (1, 1), is_str
|
1172
1181
|
),
|
1173
1182
|
"range->vector": Proc("range->vector", list, (1, 1), is_range),
|
1183
|
+
# av
|
1184
|
+
"encoder": Proc("encoder", lambda x: bv.Codec(x, "w"), (1, 1), is_str),
|
1185
|
+
"decoder": Proc("decoder", lambda x: bv.Codec(x), (1, 1), is_str),
|
1174
1186
|
# reflexion
|
1175
1187
|
"var-exists?": Proc("var-exists?", lambda sym: sym.val in env, (1, 1), is_symbol),
|
1176
1188
|
"rename": Syntax(syn_rename),
|
auto_editor/output.py
CHANGED
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|
3
3
|
import os.path
|
4
4
|
from dataclasses import dataclass, field
|
5
5
|
|
6
|
-
import
|
7
|
-
from
|
6
|
+
import bv
|
7
|
+
from bv.audio.resampler import AudioResampler
|
8
8
|
|
9
9
|
from auto_editor.ffwrapper import FileInfo
|
10
10
|
from auto_editor.utils.bar import Bar
|
@@ -53,8 +53,8 @@ class Ensure:
|
|
53
53
|
bar = self._bar
|
54
54
|
self.log.debug(f"Making external audio: {out_path}")
|
55
55
|
|
56
|
-
in_container =
|
57
|
-
out_container =
|
56
|
+
in_container = bv.open(src.path, "r")
|
57
|
+
out_container = bv.open(
|
58
58
|
out_path, "w", format="wav", options={"rf64": "always"}
|
59
59
|
)
|
60
60
|
astream = in_container.streams.audio[stream]
|
auto_editor/render/audio.py
CHANGED
@@ -4,12 +4,12 @@ import io
|
|
4
4
|
from pathlib import Path
|
5
5
|
from typing import TYPE_CHECKING
|
6
6
|
|
7
|
-
import
|
7
|
+
import bv
|
8
8
|
import numpy as np
|
9
|
-
from
|
9
|
+
from bv.filter.loudnorm import stats
|
10
10
|
|
11
11
|
from auto_editor.ffwrapper import FileInfo
|
12
|
-
from auto_editor.
|
12
|
+
from auto_editor.json import load
|
13
13
|
from auto_editor.lang.palet import env
|
14
14
|
from auto_editor.lib.contracts import andc, between_c, is_int_or_float
|
15
15
|
from auto_editor.lib.err import MyError
|
@@ -61,12 +61,14 @@ def parse_norm(norm: str, log: Log) -> dict | None:
|
|
61
61
|
|
62
62
|
def parse_ebu_bytes(norm: dict, stat: bytes, log: Log) -> tuple[str, str]:
|
63
63
|
try:
|
64
|
-
parsed =
|
64
|
+
parsed = load("loudnorm", stat)
|
65
65
|
except MyError:
|
66
66
|
log.error(f"Invalid loudnorm stats.\n{stat!r}")
|
67
67
|
|
68
68
|
for key in {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"}:
|
69
|
-
|
69
|
+
val_ = parsed[key]
|
70
|
+
assert isinstance(val_, int | float | str | bytes)
|
71
|
+
val = float(val_)
|
70
72
|
if val == float("-inf"):
|
71
73
|
parsed[key] = -99
|
72
74
|
elif val == float("inf"):
|
@@ -97,14 +99,14 @@ def apply_audio_normalization(
|
|
97
99
|
f"i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:offset={norm['gain']}"
|
98
100
|
)
|
99
101
|
log.debug(f"audio norm first pass: {first_pass}")
|
100
|
-
with
|
102
|
+
with bv.open(f"{pre_master}") as container:
|
101
103
|
stats_ = stats(first_pass, container.streams.audio[0])
|
102
104
|
|
103
105
|
name, filter_args = parse_ebu_bytes(norm, stats_, log)
|
104
106
|
else:
|
105
107
|
assert "t" in norm
|
106
108
|
|
107
|
-
def get_peak_level(frame:
|
109
|
+
def get_peak_level(frame: bv.AudioFrame) -> float:
|
108
110
|
# Calculate peak level in dB
|
109
111
|
# Should be equivalent to: -af astats=measure_overall=Peak_level:measure_perchannel=0
|
110
112
|
max_amplitude = np.abs(frame.to_ndarray()).max()
|
@@ -112,7 +114,7 @@ def apply_audio_normalization(
|
|
112
114
|
return -20.0 * np.log10(max_amplitude)
|
113
115
|
return -99.0
|
114
116
|
|
115
|
-
with
|
117
|
+
with bv.open(pre_master) as container:
|
116
118
|
max_peak_level = -99.0
|
117
119
|
assert len(container.streams.video) == 0
|
118
120
|
for frame in container.decode(audio=0):
|
@@ -124,13 +126,13 @@ def apply_audio_normalization(
|
|
124
126
|
log.print(f"peak adjustment: {adjustment:.3f}dB")
|
125
127
|
name, filter_args = "volume", f"{adjustment}"
|
126
128
|
|
127
|
-
with
|
129
|
+
with bv.open(pre_master) as container:
|
128
130
|
input_stream = container.streams.audio[0]
|
129
131
|
|
130
|
-
output_file =
|
132
|
+
output_file = bv.open(path, mode="w")
|
131
133
|
output_stream = output_file.add_stream("pcm_s16le", rate=input_stream.rate)
|
132
134
|
|
133
|
-
graph =
|
135
|
+
graph = bv.filter.Graph()
|
134
136
|
graph.link_nodes(
|
135
137
|
graph.add_abuffer(template=input_stream),
|
136
138
|
graph.add(name, filter_args),
|
@@ -141,9 +143,9 @@ def apply_audio_normalization(
|
|
141
143
|
while True:
|
142
144
|
try:
|
143
145
|
aframe = graph.pull()
|
144
|
-
assert isinstance(aframe,
|
146
|
+
assert isinstance(aframe, bv.AudioFrame)
|
145
147
|
output_file.mux(output_stream.encode(aframe))
|
146
|
-
except (
|
148
|
+
except (bv.BlockingIOError, bv.EOFError):
|
147
149
|
break
|
148
150
|
|
149
151
|
output_file.mux(output_stream.encode(None))
|
@@ -157,14 +159,14 @@ def process_audio_clip(
|
|
157
159
|
write(input_buffer, sr, samp_list[samp_start:samp_end])
|
158
160
|
input_buffer.seek(0)
|
159
161
|
|
160
|
-
input_file =
|
162
|
+
input_file = bv.open(input_buffer, "r")
|
161
163
|
input_stream = input_file.streams.audio[0]
|
162
164
|
|
163
165
|
output_bytes = io.BytesIO()
|
164
|
-
output_file =
|
166
|
+
output_file = bv.open(output_bytes, mode="w", format="wav")
|
165
167
|
output_stream = output_file.add_stream("pcm_s16le", rate=sr)
|
166
168
|
|
167
|
-
graph =
|
169
|
+
graph = bv.filter.Graph()
|
168
170
|
args = [graph.add_abuffer(template=input_stream)]
|
169
171
|
|
170
172
|
if clip.speed != 1:
|
@@ -194,9 +196,9 @@ def process_audio_clip(
|
|
194
196
|
while True:
|
195
197
|
try:
|
196
198
|
aframe = graph.pull()
|
197
|
-
assert isinstance(aframe,
|
199
|
+
assert isinstance(aframe, bv.AudioFrame)
|
198
200
|
output_file.mux(output_stream.encode(aframe))
|
199
|
-
except (
|
201
|
+
except (bv.BlockingIOError, bv.EOFError):
|
200
202
|
break
|
201
203
|
|
202
204
|
# Flush the stream
|
@@ -220,7 +222,7 @@ def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
|
|
220
222
|
|
221
223
|
# First pass: determine the maximum length
|
222
224
|
for path in audio_paths:
|
223
|
-
container =
|
225
|
+
container = bv.open(path)
|
224
226
|
stream = container.streams.audio[0]
|
225
227
|
|
226
228
|
# Calculate duration in samples
|
@@ -232,10 +234,10 @@ def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
|
|
232
234
|
|
233
235
|
# Second pass: read and mix audio
|
234
236
|
for path in audio_paths:
|
235
|
-
container =
|
237
|
+
container = bv.open(path)
|
236
238
|
stream = container.streams.audio[0]
|
237
239
|
|
238
|
-
resampler =
|
240
|
+
resampler = bv.audio.resampler.AudioResampler(
|
239
241
|
format="s16", layout="mono", rate=sr
|
240
242
|
)
|
241
243
|
|
@@ -268,7 +270,7 @@ def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
|
|
268
270
|
mixed_audio = mixed_audio * (32767 / max_val)
|
269
271
|
mixed_audio = mixed_audio.astype(np.int16) # type: ignore
|
270
272
|
|
271
|
-
output_container =
|
273
|
+
output_container = bv.open(output_path, mode="w")
|
272
274
|
output_stream = output_container.add_stream("pcm_s16le", rate=sr)
|
273
275
|
|
274
276
|
chunk_size = sr # Process 1 second at a time
|
@@ -276,7 +278,7 @@ def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
|
|
276
278
|
# Shape becomes (1, samples) for mono
|
277
279
|
chunk = np.array([mixed_audio[i : i + chunk_size]])
|
278
280
|
|
279
|
-
frame =
|
281
|
+
frame = bv.AudioFrame.from_ndarray(chunk, format="s16", layout="mono")
|
280
282
|
frame.rate = sr
|
281
283
|
frame.pts = i # Set presentation timestamp
|
282
284
|
|
@@ -370,7 +372,7 @@ def make_new_audio(
|
|
370
372
|
except PermissionError:
|
371
373
|
pass
|
372
374
|
|
373
|
-
if
|
375
|
+
if args.mix_audio_streams and len(output) > 1:
|
374
376
|
new_a_file = f"{Path(temp, 'new_audio.wav')}"
|
375
377
|
mix_audio_files(sr, output, new_a_file)
|
376
378
|
return [new_a_file]
|