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/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
- class FFmpeg:
21
- __slots__ = ("debug", "show_cmd", "path", "version")
22
-
23
- def __init__(
24
- self,
25
- ff_location: str | None = None,
26
- my_ffmpeg: bool = False,
27
- show_cmd: bool = False,
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
- _version = get_stdout([self.path, "-version"]).split("\n")[0]
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
- def print(self, message: str) -> None:
61
- if self.debug:
62
- sys.stderr.write(f"FFmpeg: {message}\n")
27
+ program = ae_ffmpeg.get_path()
28
+ except ImportError:
29
+ program = "ffmpeg"
63
30
 
64
- def print_cmd(self, cmd: list[str]) -> None:
65
- if self.show_cmd:
66
- sys.stderr.write(f"{' '.join(cmd)}\n\n")
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.print_cmd(cmd)
73
- subprocess.run(cmd)
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
- cmd = [self.path] + cmd
118
- self.print_cmd(cmd)
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, codec: str | None = None) -> None:
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
- packet = output_audio_stream.encode(frame)
144
- if packet:
145
- output_container.mux(packet)
69
+ output_container.mux(output_audio_stream.encode(frame))
146
70
 
147
- packet = output_audio_stream.encode(None)
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
- The value accepts a natural number and the units: ``, `k`, `K`, and `M`.
152
- The special value `unset` may also be used, and means: Don't pass any value to ffmpeg, let it choose a default bitrate.
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` and the special `unset` value is allowed.
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 FFmpeg, FileInfo
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 Args
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
- # PyAV always uses "stereo" layout, which is what we want.
56
- output_astream = out_container.add_stream("pcm_s16le", rate=sample_rate)
57
- assert isinstance(output_astream, av.audio.stream.AudioStream)
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
- for packet in output_astream.encode(new_frame):
66
- out_container.mux_one(packet)
75
+ out_container.mux(output_astream.encode(new_frame))
67
76
 
68
- for packet in output_astream.encode():
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)
@@ -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(
@@ -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.audio.AudioFrame)
216
- for packet in output_stream.encode(aframe):
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
- for packet in output_stream.encode(None):
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