auto-editor 24.27.1__py3-none-any.whl → 24.30.1__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 CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "24.27.1"
2
- version = "24w27a"
1
+ __version__ = "24.30.1"
2
+ version = "24w30a"
auto_editor/analyze.py CHANGED
@@ -4,76 +4,32 @@ import os
4
4
  import re
5
5
  from dataclasses import dataclass
6
6
  from fractions import Fraction
7
+ from math import ceil
7
8
  from typing import TYPE_CHECKING
8
9
 
10
+ import av
9
11
  import numpy as np
12
+ from av.audio.fifo import AudioFifo
13
+ from av.subtitles.subtitle import AssSubtitle
10
14
 
11
15
  from auto_editor import version
12
- from auto_editor.lang.json import Lexer, Parser, dump
13
- from auto_editor.lib.contracts import (
14
- is_bool,
15
- is_nat,
16
- is_nat1,
17
- is_str,
18
- is_threshold,
19
- is_void,
20
- orc,
21
- )
22
- from auto_editor.lib.data_structs import Sym
23
- from auto_editor.utils.cmdkw import (
24
- Required,
25
- pAttr,
26
- pAttrs,
27
- )
28
16
  from auto_editor.utils.subtitle_tools import convert_ass_to_text
29
- from auto_editor.wavfile import read
30
17
 
31
18
  if TYPE_CHECKING:
19
+ from collections.abc import Iterator
32
20
  from fractions import Fraction
33
21
  from typing import Any
34
22
 
35
- from av.filter import FilterContext
36
23
  from numpy.typing import NDArray
37
24
 
38
25
  from auto_editor.ffwrapper import FileInfo
39
- from auto_editor.output import Ensure
40
26
  from auto_editor.utils.bar import Bar
41
27
  from auto_editor.utils.log import Log
42
28
 
43
29
 
44
- audio_builder = pAttrs(
45
- "audio",
46
- pAttr("threshold", 0.04, is_threshold),
47
- pAttr("stream", 0, orc(is_nat, Sym("all"), "all")),
48
- pAttr("mincut", 6, is_nat),
49
- pAttr("minclip", 3, is_nat),
50
- )
51
- motion_builder = pAttrs(
52
- "motion",
53
- pAttr("threshold", 0.02, is_threshold),
54
- pAttr("stream", 0, is_nat),
55
- pAttr("blur", 9, is_nat),
56
- pAttr("width", 400, is_nat1),
57
- )
58
- subtitle_builder = pAttrs(
59
- "subtitle",
60
- pAttr("pattern", Required, is_str),
61
- pAttr("stream", 0, is_nat),
62
- pAttr("ignore-case", False, is_bool),
63
- pAttr("max-count", None, orc(is_nat, is_void)),
64
- )
65
-
66
- builder_map = {
67
- "audio": audio_builder,
68
- "motion": motion_builder,
69
- "subtitle": subtitle_builder,
70
- }
71
-
72
-
73
30
  @dataclass(slots=True)
74
31
  class FileSetup:
75
32
  src: FileInfo
76
- ensure: Ensure
77
33
  strict: bool
78
34
  tb: Fraction
79
35
  bar: Bar
@@ -85,15 +41,6 @@ class LevelError(Exception):
85
41
  pass
86
42
 
87
43
 
88
- def link_nodes(*nodes: FilterContext) -> None:
89
- for c, n in zip(nodes, nodes[1:]):
90
- c.link_to(n)
91
-
92
-
93
- def to_threshold(arr: np.ndarray, t: int | float) -> NDArray[np.bool_]:
94
- return np.fromiter((x >= t for x in arr), dtype=np.bool_)
95
-
96
-
97
44
  def mut_remove_small(
98
45
  arr: NDArray[np.bool_], lim: int, replace: int, with_: int
99
46
  ) -> None:
@@ -141,9 +88,90 @@ def obj_tag(tag: str, tb: Fraction, obj: dict[str, Any]) -> str:
141
88
  return key
142
89
 
143
90
 
91
+ def iter_audio(src, tb: Fraction, stream: int = 0) -> Iterator[float]:
92
+ fifo = AudioFifo()
93
+ try:
94
+ container = av.open(src.path, "r")
95
+ audio_stream = container.streams.audio[stream]
96
+ sample_rate = audio_stream.rate
97
+
98
+ exact_size = (1 / tb) * sample_rate
99
+ accumulated_error = 0
100
+
101
+ # Resample so that audio data is between [-1, 1]
102
+ resampler = av.AudioResampler(
103
+ av.AudioFormat("flt"), audio_stream.layout, sample_rate
104
+ )
105
+
106
+ for frame in container.decode(audio=stream):
107
+ frame.pts = None # Skip time checks
108
+
109
+ for reframe in resampler.resample(frame):
110
+ fifo.write(reframe)
111
+
112
+ while fifo.samples >= ceil(exact_size):
113
+ size_with_error = exact_size + accumulated_error
114
+ current_size = round(size_with_error)
115
+ accumulated_error = size_with_error - current_size
116
+
117
+ audio_chunk = fifo.read(current_size)
118
+ assert audio_chunk is not None
119
+ arr = audio_chunk.to_ndarray().flatten()
120
+ yield float(np.max(np.abs(arr)))
121
+
122
+ finally:
123
+ container.close()
124
+
125
+
126
+ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[float]:
127
+ container = av.open(src.path, "r")
128
+
129
+ video = container.streams.video[stream]
130
+ video.thread_type = "AUTO"
131
+
132
+ prev_frame = None
133
+ current_frame = None
134
+ total_pixels = src.videos[0].width * src.videos[0].height
135
+ index = 0
136
+ prev_index = -1
137
+
138
+ graph = av.filter.Graph()
139
+ graph.link_nodes(
140
+ graph.add_buffer(template=video),
141
+ graph.add("scale", f"{width}:-1"),
142
+ graph.add("format", "gray"),
143
+ graph.add("gblur", f"sigma={blur}"),
144
+ graph.add("buffersink"),
145
+ ).configure()
146
+
147
+ for unframe in container.decode(video):
148
+ if unframe.pts is None:
149
+ continue
150
+
151
+ graph.push(unframe)
152
+ frame = graph.pull()
153
+ assert frame.time is not None
154
+ index = round(frame.time * tb)
155
+
156
+ current_frame = frame.to_ndarray()
157
+ if prev_frame is None:
158
+ value = 0.0
159
+ else:
160
+ # Use `int16` to avoid underflow with `uint8` datatype
161
+ diff = np.abs(prev_frame.astype(np.int16) - current_frame.astype(np.int16))
162
+ value = np.count_nonzero(diff) / total_pixels
163
+
164
+ for _ in range(index - prev_index):
165
+ yield value
166
+
167
+ prev_frame = current_frame
168
+ prev_index = index
169
+
170
+ container.close()
171
+
172
+
144
173
  @dataclass(slots=True)
145
174
  class Levels:
146
- ensure: Ensure
147
175
  src: FileInfo
148
176
  tb: Fraction
149
177
  bar: Bar
@@ -156,26 +184,16 @@ class Levels:
156
184
  if (arr := self.read_cache("audio", {"stream": 0})) is not None:
157
185
  return len(arr)
158
186
 
159
- sr, samples = read(self.ensure.audio(self.src, 0))
160
- samp_count = len(samples)
161
- del samples
162
-
163
- samp_per_ticks = sr / self.tb
164
- ticks = int(samp_count / samp_per_ticks)
165
- self.log.debug(f"Audio Length: {ticks}")
166
- self.log.debug(
167
- f"... without rounding: {float(samp_count / samp_per_ticks)}"
168
- )
169
- return ticks
187
+ result = sum(1 for _ in iter_audio(self.src, self.tb, 0))
188
+ self.log.debug(f"Audio Length: {result}")
189
+ return result
170
190
 
171
191
  # If there's no audio, get length in video metadata.
172
- import av
173
-
174
- with av.open(f"{self.src.path}") as cn:
175
- if len(cn.streams.video) < 1:
192
+ with av.open(self.src.path) as container:
193
+ if len(container.streams.video) == 0:
176
194
  self.log.error("Could not get media duration")
177
195
 
178
- video = cn.streams.video[0]
196
+ video = container.streams.video[0]
179
197
 
180
198
  if video.duration is None or video.time_base is None:
181
199
  dur = 0
@@ -193,107 +211,101 @@ class Levels:
193
211
 
194
212
  def read_cache(self, tag: str, obj: dict[str, Any]) -> None | np.ndarray:
195
213
  workfile = os.path.join(
196
- os.path.dirname(self.temp), f"ae-{version}", "cache.json"
214
+ os.path.dirname(self.temp), f"ae-{version}", "cache.npz"
197
215
  )
198
216
 
199
217
  try:
200
- with open(workfile, encoding="utf-8") as file:
201
- cache = Parser(Lexer(workfile, file)).expr()
202
- except Exception:
218
+ npzfile = np.load(workfile, allow_pickle=False)
219
+ except Exception as e:
220
+ self.log.debug(e)
203
221
  return None
204
222
 
205
- if f"{self.src.path.resolve()}" not in cache:
223
+ key = f"{self.src.path}:{obj_tag(tag, self.tb, obj)}"
224
+ if key not in npzfile.files:
206
225
  return None
207
226
 
208
- key = obj_tag(tag, self.tb, obj)
209
-
210
- if key not in (root := cache[f"{self.src.path.resolve()}"]):
211
- return None
212
-
213
- return np.asarray(root[key]["arr"], dtype=root[key]["type"])
227
+ self.log.debug("Using cache")
228
+ return npzfile[key]
214
229
 
215
230
  def cache(self, tag: str, obj: dict[str, Any], arr: np.ndarray) -> np.ndarray:
216
231
  workdur = os.path.join(os.path.dirname(self.temp), f"ae-{version}")
217
- workfile = os.path.join(workdur, "cache.json")
218
232
  if not os.path.exists(workdur):
219
233
  os.mkdir(workdur)
220
234
 
221
- key = obj_tag(tag, self.tb, obj)
222
-
223
- try:
224
- with open(workfile, encoding="utf-8") as file:
225
- json_object = Parser(Lexer(workfile, file)).expr()
226
- except Exception:
227
- json_object = {}
228
-
229
- entry = {"type": str(arr.dtype), "arr": arr.tolist()}
230
- src_key = f"{self.src.path}"
231
-
232
- if src_key in json_object:
233
- json_object[src_key][key] = entry
234
- else:
235
- json_object[src_key] = {key: entry}
236
-
237
- with open(os.path.join(workdur, "cache.json"), "w", encoding="utf-8") as file:
238
- dump(json_object, file)
235
+ tag = obj_tag(tag, self.tb, obj)
236
+ np.savez(os.path.join(workdur, "cache.npz"), **{f"{self.src.path}:{tag}": arr})
239
237
 
240
238
  return arr
241
239
 
242
- def audio(self, s: int) -> NDArray[np.float64]:
243
- if s > len(self.src.audios) - 1:
244
- raise LevelError(f"audio: audio stream '{s}' does not exist.")
240
+ def audio(self, stream: int) -> NDArray[np.float64]:
241
+ if stream >= len(self.src.audios):
242
+ raise LevelError(f"audio: audio stream '{stream}' does not exist.")
245
243
 
246
- if (arr := self.read_cache("audio", {"stream": s})) is not None:
244
+ if (arr := self.read_cache("audio", {"stream": stream})) is not None:
247
245
  return arr
248
246
 
249
- sr, samples = read(self.ensure.audio(self.src, s))
250
-
251
- if len(samples) == 0:
252
- raise LevelError(f"audio: stream '{s}' has no samples.")
253
-
254
- def get_max_volume(s: np.ndarray) -> float:
255
- return max(float(np.max(s)), -float(np.min(s)))
256
-
257
- max_volume = get_max_volume(samples)
258
- self.log.debug(f"Max volume: {max_volume}")
247
+ with av.open(self.src.path, "r") as container:
248
+ audio = container.streams.audio[stream]
249
+ if audio.duration is not None and audio.time_base is not None:
250
+ inaccurate_dur = int(audio.duration * audio.time_base * self.tb)
251
+ elif container.duration is not None:
252
+ inaccurate_dur = int(container.duration / av.time_base * self.tb)
253
+ else:
254
+ inaccurate_dur = 1024
259
255
 
260
- samp_count = samples.shape[0]
261
- samp_per_ticks = sr / self.tb
256
+ bar = self.bar
257
+ bar.start(inaccurate_dur, "Analyzing audio volume")
262
258
 
263
- if samp_per_ticks < 1:
264
- self.log.error(
265
- f"audio: stream '{s}'\n Samplerate ({sr}) must be greater than "
266
- f"or equal to timebase ({self.tb})\n"
267
- " Try `-fps 30` and/or `--sample-rate 48000`"
268
- )
259
+ result = np.zeros((inaccurate_dur), dtype=np.float64)
260
+ index = 0
261
+ for value in iter_audio(self.src, self.tb, stream):
262
+ if index > len(result) - 1:
263
+ result = np.concatenate(
264
+ (result, np.zeros((len(result)), dtype=np.float64))
265
+ )
266
+ result[index] = value
267
+ bar.tick(index)
268
+ index += 1
269
269
 
270
- audio_ticks = int(samp_count / samp_per_ticks)
271
- self.log.debug(
272
- f"analyze: audio length: {audio_ticks} ({float(samp_count / samp_per_ticks)})"
273
- )
274
- self.bar.start(audio_ticks, "Analyzing audio volume")
270
+ bar.end()
271
+ return self.cache("audio", {"stream": stream}, result[:index])
275
272
 
276
- threshold_list = np.zeros((audio_ticks), dtype=np.float64)
273
+ def motion(self, stream: int, blur: int, width: int) -> NDArray[np.float64]:
274
+ if stream >= len(self.src.videos):
275
+ raise LevelError(f"motion: video stream '{stream}' does not exist.")
277
276
 
278
- if max_volume == 0: # Prevent dividing by zero
279
- return threshold_list
277
+ mobj = {"stream": stream, "width": width, "blur": blur}
278
+ if (arr := self.read_cache("motion", mobj)) is not None:
279
+ return arr
280
280
 
281
- # Determine when audio is silent or loud.
282
- for i in range(audio_ticks):
283
- if i % 500 == 0:
284
- self.bar.tick(i)
281
+ with av.open(self.src.path, "r") as container:
282
+ video = container.streams.video[stream]
283
+ inaccurate_dur = (
284
+ 1024
285
+ if video.duration is None or video.time_base is None
286
+ else int(video.duration * video.time_base * self.tb)
287
+ )
285
288
 
286
- start = int(i * samp_per_ticks)
287
- end = min(int((i + 1) * samp_per_ticks), samp_count)
289
+ bar = self.bar
290
+ bar.start(inaccurate_dur, "Analyzing motion")
288
291
 
289
- threshold_list[i] = get_max_volume(samples[start:end]) / max_volume
292
+ result = np.zeros((inaccurate_dur), dtype=np.float64)
293
+ index = 0
294
+ for value in iter_motion(self.src, self.tb, stream, blur, width):
295
+ if index > len(result) - 1:
296
+ result = np.concatenate(
297
+ (result, np.zeros((len(result)), dtype=np.float64))
298
+ )
299
+ result[index] = value
300
+ bar.tick(index)
301
+ index += 1
290
302
 
291
- self.bar.end()
292
- return self.cache("audio", {"stream": s}, threshold_list)
303
+ bar.end()
304
+ return self.cache("motion", mobj, result[:index])
293
305
 
294
306
  def subtitle(
295
307
  self,
296
- patterns: str,
308
+ pattern: str,
297
309
  stream: int,
298
310
  ignore_case: bool,
299
311
  max_count: int | None,
@@ -303,14 +315,10 @@ class Levels:
303
315
 
304
316
  try:
305
317
  flags = re.IGNORECASE if ignore_case else 0
306
- pattern = re.compile(patterns, flags)
307
- del patterns # make sure we don't accidentally use it
318
+ re_pattern = re.compile(pattern, flags)
308
319
  except re.error as e:
309
320
  self.log.error(e)
310
321
 
311
- import av
312
- from av.subtitles.subtitle import AssSubtitle, TextSubtitle
313
-
314
322
  try:
315
323
  container = av.open(self.src.path, "r")
316
324
  subtitle_stream = container.streams.subtitles[stream]
@@ -357,80 +365,13 @@ class Levels:
357
365
  for sub in subset:
358
366
  if isinstance(sub, AssSubtitle):
359
367
  line = convert_ass_to_text(sub.ass.decode(errors="ignore"))
360
- elif isinstance(sub, TextSubtitle):
361
- line = sub.text.decode(errors="ignore")
362
368
  else:
363
369
  continue
364
370
 
365
- if line and re.search(pattern, line):
371
+ if line and re.search(re_pattern, line):
366
372
  result[san_start:san_end] = 1
367
373
  count += 1
368
374
 
369
375
  container.close()
370
376
 
371
377
  return result
372
-
373
- def motion(self, s: int, blur: int, width: int) -> NDArray[np.float64]:
374
- import av
375
-
376
- if s >= len(self.src.videos):
377
- raise LevelError(f"motion: video stream '{s}' does not exist.")
378
-
379
- mobj = {"stream": s, "width": width, "blur": blur}
380
- if (arr := self.read_cache("motion", mobj)) is not None:
381
- return arr
382
-
383
- container = av.open(f"{self.src.path}", "r")
384
-
385
- stream = container.streams.video[s]
386
- stream.thread_type = "AUTO"
387
-
388
- inaccurate_dur = 1 if stream.duration is None else stream.duration
389
- self.bar.start(inaccurate_dur, "Analyzing motion")
390
-
391
- prev_frame = None
392
- current_frame = None
393
- total_pixels = self.src.videos[0].width * self.src.videos[0].height
394
- index = 0
395
-
396
- graph = av.filter.Graph()
397
- link_nodes(
398
- graph.add_buffer(template=stream),
399
- graph.add("scale", f"{width}:-1"),
400
- graph.add("format", "gray"),
401
- graph.add("gblur", f"sigma={blur}"),
402
- graph.add("buffersink"),
403
- )
404
- graph.configure()
405
-
406
- threshold_list = np.zeros((1024), dtype=np.float64)
407
-
408
- for unframe in container.decode(stream):
409
- graph.push(unframe)
410
- frame = graph.pull()
411
-
412
- # Showing progress ...
413
- assert frame.time is not None
414
- index = int(frame.time * self.tb)
415
- if frame.pts is not None:
416
- self.bar.tick(frame.pts)
417
-
418
- current_frame = frame.to_ndarray()
419
-
420
- if index > len(threshold_list) - 1:
421
- threshold_list = np.concatenate(
422
- (threshold_list, np.zeros((len(threshold_list)), dtype=np.float64)),
423
- axis=0,
424
- )
425
-
426
- if prev_frame is not None:
427
- # Use `int16` to avoid underflow with `uint8` datatype
428
- diff = np.abs(
429
- prev_frame.astype(np.int16) - current_frame.astype(np.int16)
430
- )
431
- threshold_list[index] = np.count_nonzero(diff) / total_pixels
432
-
433
- prev_frame = current_frame
434
-
435
- self.bar.end()
436
- return self.cache("motion", mobj, threshold_list[:index])
auto_editor/edit.py CHANGED
@@ -68,12 +68,8 @@ def set_video_codec(
68
68
  ) -> str:
69
69
  if codec == "auto":
70
70
  codec = "h264" if (src is None or not src.videos) else src.videos[0].codec
71
- if ctr.vcodecs is not None:
72
- if ctr.vstrict and codec not in ctr.vcodecs:
73
- return ctr.vcodecs[0]
74
-
75
- if codec in ctr.disallow_v:
76
- return ctr.vcodecs[0]
71
+ if codec not in ctr.vcodecs and ctr.default_vid != "none":
72
+ return ctr.default_vid
77
73
  return codec
78
74
 
79
75
  if codec == "copy":
@@ -83,12 +79,7 @@ def set_video_codec(
83
79
  log.error("Input file does not have a video stream to copy codec from.")
84
80
  codec = src.videos[0].codec
85
81
 
86
- if ctr.vstrict:
87
- assert ctr.vcodecs is not None
88
- if codec not in ctr.vcodecs:
89
- log.error(codec_error.format(codec, out_ext))
90
-
91
- if codec in ctr.disallow_v:
82
+ if ctr.vcodecs is not None and codec not in ctr.vcodecs:
92
83
  log.error(codec_error.format(codec, out_ext))
93
84
 
94
85
  return codec
@@ -99,8 +90,10 @@ def set_audio_codec(
99
90
  ) -> str:
100
91
  if codec == "auto":
101
92
  codec = "aac" if (src is None or not src.audios) else src.audios[0].codec
102
- if ctr.acodecs is not None and codec not in ctr.acodecs:
103
- return ctr.acodecs[0]
93
+ if codec not in ctr.acodecs and ctr.default_aud != "none":
94
+ return ctr.default_aud
95
+ if codec == "mp3float":
96
+ return "mp3"
104
97
  return codec
105
98
 
106
99
  if codec == "copy":
@@ -209,10 +202,8 @@ def edit_media(
209
202
  else:
210
203
  samplerate = args.sample_rate
211
204
 
212
- ensure = Ensure(ffmpeg, bar, samplerate, temp, log)
213
-
214
205
  if tl is None:
215
- tl = make_timeline(sources, ensure, args, samplerate, bar, temp, log)
206
+ tl = make_timeline(sources, args, samplerate, bar, temp, log)
216
207
 
217
208
  if export["export"] == "timeline":
218
209
  from auto_editor.formats.json import make_json_timeline
@@ -223,7 +214,7 @@ def edit_media(
223
214
  if args.preview:
224
215
  from auto_editor.preview import preview
225
216
 
226
- preview(ensure, tl, temp, log)
217
+ preview(tl, temp, log)
227
218
  return
228
219
 
229
220
  if export["export"] == "json":
@@ -272,13 +263,15 @@ def edit_media(
272
263
  sub_output = []
273
264
  apply_later = False
274
265
 
275
- if ctr.allow_subtitle and not args.sn:
266
+ ensure = Ensure(ffmpeg, bar, samplerate, temp, log)
267
+
268
+ if ctr.default_sub != "none" and not args.sn:
276
269
  sub_output = make_new_subtitles(tl, ensure, temp)
277
270
 
278
- if ctr.allow_audio:
271
+ if ctr.default_aud != "none":
279
272
  audio_output = make_new_audio(tl, ensure, args, ffmpeg, bar, temp, log)
280
273
 
281
- if ctr.allow_video:
274
+ if ctr.default_vid != "none":
282
275
  if tl.v:
283
276
  out_path, apply_later = render_av(ffmpeg, tl, args, bar, ctr, temp, log)
284
277
  visual_output.append((True, out_path))
auto_editor/lang/palet.py CHANGED
@@ -18,12 +18,7 @@ from typing import TYPE_CHECKING
18
18
  import numpy as np
19
19
  from numpy import logical_and, logical_not, logical_or, logical_xor
20
20
 
21
- from auto_editor.analyze import (
22
- LevelError,
23
- mut_remove_large,
24
- mut_remove_small,
25
- to_threshold,
26
- )
21
+ from auto_editor.analyze import LevelError, mut_remove_large, mut_remove_small
27
22
  from auto_editor.lib.contracts import *
28
23
  from auto_editor.lib.data_structs import *
29
24
  from auto_editor.lib.err import MyError
@@ -690,6 +685,9 @@ def palet_map(proc: Proc, seq: Any) -> Any:
690
685
  return Quoted(tuple(map(proc, seq.val)))
691
686
  if isinstance(seq, list | range):
692
687
  return list(map(proc, seq))
688
+ elif isinstance(seq, np.ndarray):
689
+ vectorized_proc = np.vectorize(proc)
690
+ return vectorized_proc(seq)
693
691
  return proc(seq)
694
692
 
695
693
 
@@ -1469,6 +1467,26 @@ def edit_all() -> np.ndarray:
1469
1467
  return env["@levels"].all()
1470
1468
 
1471
1469
 
1470
+ def audio_levels(stream: int) -> np.ndarray:
1471
+ if "@levels" not in env:
1472
+ raise MyError("Can't use `audio` if there's no input media")
1473
+
1474
+ try:
1475
+ return env["@levels"].audio(stream)
1476
+ except LevelError as e:
1477
+ raise MyError(e)
1478
+
1479
+
1480
+ def motion_levels(stream: int, blur: int = 9, width: int = 400) -> np.ndarray:
1481
+ if "@levels" not in env:
1482
+ raise MyError("Can't use `motion` if there's no input media")
1483
+
1484
+ try:
1485
+ return env["@levels"].motion(stream, blur, width)
1486
+ except LevelError as e:
1487
+ raise MyError(e)
1488
+
1489
+
1472
1490
  def edit_audio(
1473
1491
  threshold: float = 0.04,
1474
1492
  stream: object = Sym("all"),
@@ -1491,7 +1509,7 @@ def edit_audio(
1491
1509
 
1492
1510
  try:
1493
1511
  for s in stream_range:
1494
- audio_list = to_threshold(levels.audio(s), threshold)
1512
+ audio_list = levels.audio(s) >= threshold
1495
1513
  if stream_data is None:
1496
1514
  stream_data = audio_list
1497
1515
  else:
@@ -1521,7 +1539,7 @@ def edit_motion(
1521
1539
  levels = env["@levels"]
1522
1540
  strict = env["@filesetup"].strict
1523
1541
  try:
1524
- return to_threshold(levels.motion(stream, blur, width), threshold)
1542
+ return levels.motion(stream, blur, width) >= threshold
1525
1543
  except LevelError as e:
1526
1544
  return raise_(e) if strict else levels.all()
1527
1545
 
@@ -1582,7 +1600,7 @@ def my_eval(env: Env, node: object) -> Any:
1582
1600
  return ref(oper, my_eval(env, node[1]))
1583
1601
 
1584
1602
  raise MyError(
1585
- f"Tried to run: {print_str(oper)} with args: {print_str(node[1:])}"
1603
+ f"{print_str(oper)} is not a function. Tried to run with args: {print_str(node[1:])}"
1586
1604
  )
1587
1605
 
1588
1606
  if type(oper) is Syntax:
@@ -1617,10 +1635,12 @@ env.update({
1617
1635
  # edit procedures
1618
1636
  "none": Proc("none", edit_none, (0, 0)),
1619
1637
  "all/e": Proc("all/e", edit_all, (0, 0)),
1638
+ "audio-levels": Proc("audio-levels", audio_levels, (1, 1), is_nat),
1620
1639
  "audio": Proc("audio", edit_audio, (0, 4),
1621
1640
  is_threshold, orc(is_nat, Sym("all")), is_nat,
1622
1641
  {"threshold": 0, "stream": 1, "minclip": 2, "mincut": 2}
1623
1642
  ),
1643
+ "motion-levels": Proc("motion-levels", motion_levels, (1, 3), is_nat, is_nat1, {"blur": 1, "width": 2}),
1624
1644
  "motion": Proc("motion", edit_motion, (0, 4),
1625
1645
  is_threshold, is_nat, is_nat1,
1626
1646
  {"threshold": 0, "stream": 1, "blur": 1, "width": 2}