auto-editor 25.3.1__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 +1 -11
- auto_editor/edit.py +156 -44
- auto_editor/ffwrapper.py +2 -43
- auto_editor/help.py +4 -3
- auto_editor/output.py +22 -183
- auto_editor/render/audio.py +65 -55
- auto_editor/render/subtitle.py +69 -10
- auto_editor/render/video.py +166 -180
- auto_editor/subcommands/repl.py +12 -3
- auto_editor/subcommands/test.py +41 -37
- auto_editor/utils/container.py +2 -0
- auto_editor/utils/func.py +1 -1
- auto_editor/utils/types.py +2 -15
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/METADATA +1 -1
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/RECORD +20 -20
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/WHEEL +1 -1
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/LICENSE +0 -0
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/entry_points.txt +0 -0
- {auto_editor-25.3.1.dist-info → auto_editor-26.0.0.dist-info}/top_level.txt +0 -0
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(
|
@@ -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
|
|
auto_editor/render/subtitle.py
CHANGED
@@ -1,18 +1,23 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import io
|
3
4
|
import os
|
4
5
|
import re
|
5
6
|
from dataclasses import dataclass
|
6
7
|
from typing import TYPE_CHECKING
|
7
8
|
|
9
|
+
import av
|
10
|
+
|
8
11
|
from auto_editor.utils.func import to_timecode
|
9
12
|
|
10
13
|
if TYPE_CHECKING:
|
11
14
|
from fractions import Fraction
|
12
15
|
|
13
|
-
from auto_editor.output import Ensure
|
14
16
|
from auto_editor.timeline import v3
|
15
17
|
from auto_editor.utils.chunks import Chunks
|
18
|
+
from auto_editor.utils.log import Log
|
19
|
+
|
20
|
+
Input = av.container.InputContainer
|
16
21
|
|
17
22
|
|
18
23
|
@dataclass(slots=True)
|
@@ -26,7 +31,6 @@ class SerialSub:
|
|
26
31
|
|
27
32
|
class SubtitleParser:
|
28
33
|
def __init__(self, tb: Fraction) -> None:
|
29
|
-
self.supported_codecs = ("ass", "webvtt", "mov_text")
|
30
34
|
self.tb = tb
|
31
35
|
self.contents: list[SerialSub] = []
|
32
36
|
self.header = ""
|
@@ -125,24 +129,79 @@ class SubtitleParser:
|
|
125
129
|
file.write(self.footer)
|
126
130
|
|
127
131
|
|
128
|
-
def
|
132
|
+
def make_srt(input_: Input, stream: int) -> str:
|
133
|
+
output_bytes = io.StringIO()
|
134
|
+
input_stream = input_.streams.subtitles[stream]
|
135
|
+
assert input_stream.time_base is not None
|
136
|
+
s = 1
|
137
|
+
for packet in input_.demux(input_stream):
|
138
|
+
if packet.dts is None or packet.pts is None or packet.duration is None:
|
139
|
+
continue
|
140
|
+
|
141
|
+
start = packet.pts * input_stream.time_base
|
142
|
+
end = start + packet.duration * input_stream.time_base
|
143
|
+
|
144
|
+
for subset in packet.decode():
|
145
|
+
start_time = to_timecode(start, "srt")
|
146
|
+
end_time = to_timecode(end, "srt")
|
147
|
+
|
148
|
+
sub = subset[0]
|
149
|
+
assert len(subset) == 1
|
150
|
+
assert isinstance(sub, av.subtitles.subtitle.AssSubtitle)
|
151
|
+
|
152
|
+
output_bytes.write(f"{s}\n{start_time} --> {end_time}\n")
|
153
|
+
output_bytes.write(sub.dialogue.decode("utf-8", errors="ignore") + "\n\n")
|
154
|
+
s += 1
|
155
|
+
|
156
|
+
output_bytes.seek(0)
|
157
|
+
return output_bytes.getvalue()
|
158
|
+
|
159
|
+
|
160
|
+
def _ensure(input_: Input, format: str, stream: int, log: Log) -> str:
|
161
|
+
output_bytes = io.BytesIO()
|
162
|
+
output = av.open(output_bytes, "w", format=format)
|
163
|
+
|
164
|
+
in_stream = input_.streams.subtitles[stream]
|
165
|
+
out_stream = output.add_stream(template=in_stream)
|
166
|
+
|
167
|
+
for packet in input_.demux(in_stream):
|
168
|
+
if packet.dts is None:
|
169
|
+
continue
|
170
|
+
packet.stream = out_stream
|
171
|
+
output.mux(packet)
|
172
|
+
|
173
|
+
output.close()
|
174
|
+
output_bytes.seek(0)
|
175
|
+
return output_bytes.getvalue().decode("utf-8", errors="ignore")
|
176
|
+
|
177
|
+
|
178
|
+
def make_new_subtitles(tl: v3, log: Log) -> list[str]:
|
129
179
|
if tl.v1 is None:
|
130
180
|
return []
|
131
181
|
|
182
|
+
input_ = av.open(tl.v1.source.path)
|
132
183
|
new_paths = []
|
133
184
|
|
134
185
|
for s, sub in enumerate(tl.v1.source.subtitles):
|
135
|
-
|
136
|
-
|
186
|
+
if sub.codec == "mov_text":
|
187
|
+
continue
|
137
188
|
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
189
|
+
parser = SubtitleParser(tl.tb)
|
190
|
+
if sub.codec in ("webvtt", "ass", "ssa"):
|
191
|
+
format = sub.codec
|
192
|
+
else:
|
193
|
+
log.error(f"Unknown subtitle codec: {sub.codec}")
|
143
194
|
|
195
|
+
if sub.codec == "mov_text":
|
196
|
+
ret = make_srt(input_, s)
|
197
|
+
else:
|
198
|
+
ret = _ensure(input_, format, s, log)
|
199
|
+
parser.parse(ret, sub.codec)
|
144
200
|
parser.edit(tl.v1.chunks)
|
201
|
+
|
202
|
+
new_path = os.path.join(log.temp, f"new{s}s.{sub.ext}")
|
145
203
|
parser.write(new_path)
|
146
204
|
new_paths.append(new_path)
|
147
205
|
|
206
|
+
input_.close()
|
148
207
|
return new_paths
|