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.
@@ -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
 
@@ -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 make_new_subtitles(tl: v3, ensure: Ensure, temp: str) -> list[str]:
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
- new_path = os.path.join(temp, f"new{s}s.{sub.ext}")
136
- parser = SubtitleParser(tl.tb)
186
+ if sub.codec == "mov_text":
187
+ continue
137
188
 
138
- ext = sub.ext if sub.codec in parser.supported_codecs else "vtt"
139
- file_path = ensure.subtitle(tl.v1.source, s, ext)
140
-
141
- with open(file_path, encoding="utf-8") as file:
142
- parser.parse(file.read(), sub.codec)
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