auto-editor 28.0.2__py3-none-any.whl → 29.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.
Files changed (58) hide show
  1. {auto_editor-28.0.2.dist-info → auto_editor-29.0.0.dist-info}/METADATA +5 -4
  2. auto_editor-29.0.0.dist-info/RECORD +5 -0
  3. auto_editor-29.0.0.dist-info/top_level.txt +1 -0
  4. auto_editor/__init__.py +0 -1
  5. auto_editor/__main__.py +0 -503
  6. auto_editor/analyze.py +0 -393
  7. auto_editor/cmds/__init__.py +0 -0
  8. auto_editor/cmds/cache.py +0 -69
  9. auto_editor/cmds/desc.py +0 -32
  10. auto_editor/cmds/info.py +0 -213
  11. auto_editor/cmds/levels.py +0 -199
  12. auto_editor/cmds/palet.py +0 -29
  13. auto_editor/cmds/repl.py +0 -113
  14. auto_editor/cmds/subdump.py +0 -72
  15. auto_editor/cmds/test.py +0 -812
  16. auto_editor/edit.py +0 -548
  17. auto_editor/exports/__init__.py +0 -0
  18. auto_editor/exports/fcp11.py +0 -195
  19. auto_editor/exports/fcp7.py +0 -313
  20. auto_editor/exports/json.py +0 -63
  21. auto_editor/exports/shotcut.py +0 -147
  22. auto_editor/ffwrapper.py +0 -187
  23. auto_editor/help.py +0 -223
  24. auto_editor/imports/__init__.py +0 -0
  25. auto_editor/imports/fcp7.py +0 -275
  26. auto_editor/imports/json.py +0 -234
  27. auto_editor/json.py +0 -297
  28. auto_editor/lang/__init__.py +0 -0
  29. auto_editor/lang/libintrospection.py +0 -10
  30. auto_editor/lang/libmath.py +0 -23
  31. auto_editor/lang/palet.py +0 -724
  32. auto_editor/lang/stdenv.py +0 -1184
  33. auto_editor/lib/__init__.py +0 -0
  34. auto_editor/lib/contracts.py +0 -235
  35. auto_editor/lib/data_structs.py +0 -278
  36. auto_editor/lib/err.py +0 -2
  37. auto_editor/make_layers.py +0 -315
  38. auto_editor/preview.py +0 -93
  39. auto_editor/render/__init__.py +0 -0
  40. auto_editor/render/audio.py +0 -517
  41. auto_editor/render/subtitle.py +0 -205
  42. auto_editor/render/video.py +0 -312
  43. auto_editor/timeline.py +0 -331
  44. auto_editor/utils/__init__.py +0 -0
  45. auto_editor/utils/bar.py +0 -142
  46. auto_editor/utils/chunks.py +0 -2
  47. auto_editor/utils/cmdkw.py +0 -206
  48. auto_editor/utils/container.py +0 -102
  49. auto_editor/utils/func.py +0 -128
  50. auto_editor/utils/log.py +0 -124
  51. auto_editor/utils/types.py +0 -277
  52. auto_editor/vanparse.py +0 -313
  53. auto_editor-28.0.2.dist-info/RECORD +0 -56
  54. auto_editor-28.0.2.dist-info/entry_points.txt +0 -6
  55. auto_editor-28.0.2.dist-info/top_level.txt +0 -2
  56. docs/build.py +0 -70
  57. {auto_editor-28.0.2.dist-info → auto_editor-29.0.0.dist-info}/WHEEL +0 -0
  58. {auto_editor-28.0.2.dist-info → auto_editor-29.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,517 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from fractions import Fraction
4
- from io import BytesIO
5
- from pathlib import Path
6
- from typing import TYPE_CHECKING
7
-
8
- import bv
9
- import numpy as np
10
- from bv import AudioFrame
11
- from bv.filter.loudnorm import stats
12
-
13
- from auto_editor.ffwrapper import FileInfo
14
- from auto_editor.json import load
15
- from auto_editor.lang.palet import env
16
- from auto_editor.lib.contracts import andc, between_c, is_int_or_float
17
- from auto_editor.lib.err import MyError
18
- from auto_editor.timeline import Clip, v3
19
- from auto_editor.utils.cmdkw import ParserError, parse_with_palet, pAttr, pAttrs
20
- from auto_editor.utils.func import parse_bitrate
21
- from auto_editor.utils.log import Log
22
-
23
- if TYPE_CHECKING:
24
- from collections.abc import Iterator
25
- from typing import Any
26
-
27
- from auto_editor.__main__ import Args
28
-
29
-
30
- norm_types = {
31
- "ebu": pAttrs(
32
- "ebu",
33
- pAttr("i", -24.0, andc(is_int_or_float, between_c(-70, 5))),
34
- pAttr("lra", 7.0, andc(is_int_or_float, between_c(1, 50))),
35
- pAttr("tp", -2.0, andc(is_int_or_float, between_c(-9, 0))),
36
- pAttr("gain", 0.0, andc(is_int_or_float, between_c(-99, 99))),
37
- ),
38
- "peak": pAttrs(
39
- "peak",
40
- pAttr("t", -8.0, andc(is_int_or_float, between_c(-99, 0))),
41
- ),
42
- }
43
-
44
-
45
- def parse_norm(norm: str, log: Log) -> dict | None:
46
- if norm == "#f":
47
- return None
48
-
49
- exploded = norm.split(":", 1)
50
- norm_type = exploded[0]
51
- attrs = "" if len(exploded) == 1 else exploded[1]
52
-
53
- obj = norm_types.get(norm_type, None)
54
- if obj is None:
55
- log.error(f"Unknown audio normalize object: '{norm_type}'")
56
-
57
- try:
58
- obj_dict = parse_with_palet(attrs, obj, env)
59
- obj_dict["tag"] = norm_type
60
- return obj_dict
61
- except ParserError as e:
62
- log.error(e)
63
-
64
-
65
- def parse_ebu_bytes(norm: dict, stat: bytes, log: Log) -> tuple[str, str]:
66
- try:
67
- parsed = load("loudnorm", stat)
68
- except MyError:
69
- log.error(f"Invalid loudnorm stats.\n{stat!r}")
70
-
71
- for key in {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"}:
72
- val_ = parsed[key]
73
- assert isinstance(val_, int | float | str | bytes)
74
- val = float(val_)
75
- if val == float("-inf"):
76
- parsed[key] = -99
77
- elif val == float("inf"):
78
- parsed[key] = 0
79
- else:
80
- parsed[key] = val
81
-
82
- log.debug(f"{parsed}")
83
- m_i = parsed["input_i"]
84
- m_tp = parsed["input_tp"]
85
- m_lra = parsed["input_lra"]
86
- m_thresh = parsed["input_thresh"]
87
- target_offset = parsed["target_offset"]
88
-
89
- filter = (
90
- f"i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:offset={target_offset}"
91
- f":measured_i={m_i}:measured_lra={m_lra}:measured_tp={m_tp}"
92
- f":measured_thresh={m_thresh}:linear=true:print_format=json"
93
- )
94
- return "loudnorm", filter
95
-
96
-
97
- def apply_audio_normalization(
98
- norm: dict, pre_master: Path, path: Path, log: Log
99
- ) -> None:
100
- if norm["tag"] == "ebu":
101
- first_pass = (
102
- f"i={norm['i']}:lra={norm['lra']}:tp={norm['tp']}:offset={norm['gain']}"
103
- )
104
- log.debug(f"audio norm first pass: {first_pass}")
105
- with bv.open(f"{pre_master}") as container:
106
- stats_ = stats(first_pass, container.streams.audio[0])
107
-
108
- name, filter_args = parse_ebu_bytes(norm, stats_, log)
109
- else:
110
- assert "t" in norm
111
-
112
- def get_peak_level(frame: AudioFrame) -> float:
113
- # Calculate peak level in dB
114
- # Should be equivalent to: -af astats=measure_overall=Peak_level:measure_perchannel=0
115
- max_amplitude = np.abs(frame.to_ndarray()).max()
116
- if max_amplitude > 0.0:
117
- return -20.0 * np.log10(max_amplitude)
118
- return -99.0
119
-
120
- with bv.open(pre_master) as container:
121
- max_peak_level = -99.0
122
- assert len(container.streams.video) == 0
123
- for frame in container.decode(audio=0):
124
- peak_level = get_peak_level(frame)
125
- max_peak_level = max(max_peak_level, peak_level)
126
-
127
- adjustment = norm["t"] - max_peak_level
128
- log.debug(f"current peak level: {max_peak_level}")
129
- log.print(f"peak adjustment: {adjustment:.3f}dB")
130
- name, filter_args = "volume", f"{adjustment}"
131
-
132
- with bv.open(pre_master) as container:
133
- input_stream = container.streams.audio[0]
134
-
135
- output_file = bv.open(path, mode="w")
136
- output_stream = output_file.add_stream("pcm_s16le", rate=input_stream.rate)
137
-
138
- graph = bv.filter.Graph()
139
- graph.link_nodes(
140
- graph.add_abuffer(template=input_stream),
141
- graph.add(name, filter_args),
142
- graph.add("abuffersink"),
143
- ).configure()
144
- for frame in container.decode(input_stream):
145
- graph.push(frame)
146
- while True:
147
- try:
148
- aframe = graph.pull()
149
- assert isinstance(aframe, AudioFrame)
150
- output_file.mux(output_stream.encode(aframe))
151
- except (bv.BlockingIOError, bv.EOFError):
152
- break
153
-
154
- output_file.mux(output_stream.encode(None))
155
- output_file.close()
156
-
157
-
158
- def process_audio_clip(clip: Clip, data: np.ndarray, sr: int, log: Log) -> np.ndarray:
159
- to_s16 = bv.AudioResampler(format="s16", layout="stereo", rate=sr)
160
- input_buffer = BytesIO()
161
-
162
- with bv.open(input_buffer, "w", format="wav") as container:
163
- output_stream = container.add_stream(
164
- "pcm_s16le", sample_rate=sr, format="s16", layout="stereo"
165
- )
166
-
167
- frame = AudioFrame.from_ndarray(data, format="s16p", layout="stereo")
168
- frame.rate = sr
169
-
170
- for reframe in to_s16.resample(frame):
171
- container.mux(output_stream.encode(reframe))
172
- container.mux(output_stream.encode(None))
173
-
174
- input_buffer.seek(0)
175
-
176
- input_file = bv.open(input_buffer, "r")
177
- input_stream = input_file.streams.audio[0]
178
-
179
- graph = bv.filter.Graph()
180
- args = [graph.add_abuffer(template=input_stream)]
181
-
182
- if clip.speed != 1:
183
- if clip.speed > 10_000:
184
- for _ in range(3):
185
- args.append(graph.add("atempo", f"{clip.speed ** (1 / 3)}"))
186
- elif clip.speed > 100:
187
- for _ in range(2):
188
- args.append(graph.add("atempo", f"{clip.speed**0.5}"))
189
- elif clip.speed >= 0.5:
190
- args.append(graph.add("atempo", f"{clip.speed}"))
191
- else:
192
- start = 0.5
193
- while start * 0.5 > clip.speed:
194
- start *= 0.5
195
- args.append(graph.add("atempo", "0.5"))
196
- args.append(graph.add("atempo", f"{clip.speed / start}"))
197
-
198
- if clip.volume != 1:
199
- args.append(graph.add("volume", f"{clip.volume}"))
200
-
201
- args.append(graph.add("abuffersink"))
202
- graph.link_nodes(*args).configure()
203
-
204
- all_frames = []
205
- resampler = bv.AudioResampler(format="s16p", layout="stereo", rate=sr)
206
-
207
- for frame in input_file.decode(input_stream):
208
- graph.push(frame)
209
- while True:
210
- try:
211
- aframe = graph.pull()
212
- assert isinstance(aframe, AudioFrame)
213
-
214
- for resampled_frame in resampler.resample(aframe):
215
- all_frames.append(resampled_frame.to_ndarray())
216
-
217
- except (bv.BlockingIOError, bv.EOFError):
218
- break
219
-
220
- if not all_frames:
221
- log.debug(f"No audio frames at {clip=}")
222
- return np.zeros_like(data)
223
- return np.concatenate(all_frames, axis=1)
224
-
225
-
226
- def mix_audio_files(sr: int, audio_paths: list[str], output_path: str) -> None:
227
- mixed_audio = None
228
- max_length = 0
229
-
230
- # First pass: determine the maximum length
231
- for path in audio_paths:
232
- container = bv.open(path)
233
- stream = container.streams.audio[0]
234
-
235
- # Calculate duration in samples
236
- assert stream.duration is not None
237
- assert stream.time_base is not None
238
- duration_samples = int(stream.duration * sr / stream.time_base.denominator)
239
- max_length = max(max_length, duration_samples)
240
- container.close()
241
-
242
- # Second pass: read and mix audio
243
- for path in audio_paths:
244
- container = bv.open(path)
245
- stream = container.streams.audio[0]
246
-
247
- resampler = bv.audio.resampler.AudioResampler(
248
- format="s16", layout="mono", rate=sr
249
- )
250
-
251
- audio_array: list[np.ndarray] = []
252
- for frame in container.decode(audio=0):
253
- frame.pts = None
254
- resampled = resampler.resample(frame)[0]
255
- audio_array.extend(resampled.to_ndarray().flatten())
256
-
257
- # Pad or truncate to max_length
258
- current_audio = np.array(audio_array[:max_length])
259
- if len(current_audio) < max_length:
260
- current_audio = np.pad(
261
- current_audio, (0, max_length - len(current_audio)), "constant"
262
- )
263
-
264
- if mixed_audio is None:
265
- mixed_audio = current_audio.astype(np.float32)
266
- else:
267
- mixed_audio += current_audio.astype(np.float32)
268
-
269
- container.close()
270
-
271
- if mixed_audio is None:
272
- raise ValueError("mixed_audio is None")
273
-
274
- # Normalize the mixed audio
275
- max_val = np.max(np.abs(mixed_audio))
276
- if max_val > 0:
277
- mixed_audio = mixed_audio * (32767 / max_val)
278
- mixed_audio = mixed_audio.astype(np.int16)
279
-
280
- output_container = bv.open(output_path, mode="w")
281
- output_stream = output_container.add_stream("pcm_s16le", rate=sr)
282
-
283
- chunk_size = sr # Process 1 second at a time
284
- for i in range(0, len(mixed_audio), chunk_size):
285
- # Shape becomes (1, samples) for mono
286
- chunk = np.array([mixed_audio[i : i + chunk_size]])
287
-
288
- frame = AudioFrame.from_ndarray(chunk, format="s16", layout="mono")
289
- frame.rate = sr
290
- frame.pts = i # Set presentation timestamp
291
-
292
- output_container.mux(output_stream.encode(frame))
293
-
294
- output_container.mux(output_stream.encode(None))
295
- output_container.close()
296
-
297
-
298
- def file_to_ndarray(src: FileInfo, stream: int, sr: int) -> np.ndarray:
299
- all_frames = []
300
-
301
- resampler = bv.AudioResampler(format="s16p", layout="stereo", rate=sr)
302
-
303
- with bv.open(src.path) as container:
304
- for frame in container.decode(audio=stream):
305
- for resampled_frame in resampler.resample(frame):
306
- all_frames.append(resampled_frame.to_ndarray())
307
-
308
- return np.concatenate(all_frames, axis=1)
309
-
310
-
311
- def ndarray_to_file(audio_data: np.ndarray, rate: int, out: str | Path) -> None:
312
- layout = "stereo"
313
-
314
- with bv.open(out, mode="w") as output:
315
- stream = output.add_stream("pcm_s16le", rate=rate, format="s16", layout=layout)
316
-
317
- frame = bv.AudioFrame.from_ndarray(audio_data, format="s16p", layout=layout)
318
- frame.rate = rate
319
-
320
- output.mux(stream.encode(frame))
321
- output.mux(stream.encode(None))
322
-
323
-
324
- def ndarray_to_iter(
325
- audio_data: np.ndarray, fmt: bv.AudioFormat, layout: str, rate: int
326
- ) -> Iterator[AudioFrame]:
327
- chunk_size = rate // 4 # Process 0.25 seconds at a time
328
-
329
- resampler = bv.AudioResampler(rate=rate, format=fmt, layout=layout)
330
- for i in range(0, audio_data.shape[1], chunk_size):
331
- chunk = audio_data[:, i : i + chunk_size]
332
-
333
- frame = AudioFrame.from_ndarray(chunk, format="s16p", layout="stereo")
334
- frame.rate = rate
335
- frame.pts = i
336
-
337
- yield from resampler.resample(frame)
338
-
339
-
340
- def make_new_audio(
341
- output: bv.container.OutputContainer,
342
- audio_format: bv.AudioFormat,
343
- tl: v3,
344
- args: Args,
345
- log: Log,
346
- ) -> tuple[list[bv.AudioStream], list[Iterator[AudioFrame]]]:
347
- audio_inputs = []
348
- audio_gen_frames = []
349
- audio_streams: list[bv.AudioStream] = []
350
- audio_paths = _make_new_audio(tl, audio_format, args, log)
351
-
352
- for i, audio_path in enumerate(audio_paths):
353
- audio_stream = output.add_stream(
354
- args.audio_codec,
355
- rate=tl.sr,
356
- format=audio_format,
357
- layout=tl.T.layout,
358
- time_base=Fraction(1, tl.sr),
359
- )
360
- if not isinstance(audio_stream, bv.AudioStream):
361
- log.error(f"Not a known audio codec: {args.audio_codec}")
362
-
363
- if args.audio_bitrate != "auto":
364
- audio_stream.bit_rate = parse_bitrate(args.audio_bitrate, log)
365
- log.debug(f"audio bitrate: {audio_stream.bit_rate}")
366
- else:
367
- log.debug(f"[auto] audio bitrate: {audio_stream.bit_rate}")
368
-
369
- if i < len(tl.T.audios) and (lang := tl.T.audios[i].lang) is not None:
370
- audio_stream.metadata["language"] = lang
371
-
372
- audio_streams.append(audio_stream)
373
-
374
- if isinstance(audio_path, str):
375
- audio_input = bv.open(audio_path)
376
- audio_inputs.append(audio_input)
377
- audio_gen_frames.append(audio_input.decode(audio=0))
378
- else:
379
- audio_gen_frames.append(audio_path)
380
-
381
- return audio_streams, audio_gen_frames
382
-
383
-
384
- class Getter:
385
- __slots__ = ("container", "stream", "rate")
386
-
387
- def __init__(self, path: Path, stream: int, rate: int):
388
- self.container = bv.open(path)
389
- self.stream = self.container.streams.audio[stream]
390
- self.rate = rate
391
-
392
- def get(self, start: int, end: int) -> np.ndarray:
393
- # start/end is in samples
394
-
395
- container = self.container
396
- stream = self.stream
397
- resampler = bv.AudioResampler(format="s16p", layout="stereo", rate=self.rate)
398
-
399
- time_base = stream.time_base
400
- assert time_base is not None
401
- start_pts = int(start / self.rate / time_base)
402
-
403
- # Seek to the approximate position
404
- container.seek(start_pts, stream=stream)
405
-
406
- all_frames = []
407
- total_samples = 0
408
- target_samples = end - start
409
-
410
- # Decode frames until we have enough samples
411
- for frame in container.decode(stream):
412
- for resampled_frame in resampler.resample(frame):
413
- frame_array = resampled_frame.to_ndarray()
414
- all_frames.append(frame_array)
415
- total_samples += frame_array.shape[1]
416
-
417
- if total_samples >= target_samples:
418
- break
419
-
420
- if total_samples >= target_samples:
421
- break
422
-
423
- result = np.concatenate(all_frames, axis=1)
424
-
425
- # Trim to exact size
426
- if result.shape[1] > target_samples:
427
- result = result[:, :target_samples]
428
- elif result.shape[1] < target_samples:
429
- # Pad with zeros if we don't have enough samples
430
- padding = np.zeros(
431
- (result.shape[0], target_samples - result.shape[1]), dtype=result.dtype
432
- )
433
- result = np.concatenate([result, padding], axis=1)
434
-
435
- assert result.shape[1] == end - start
436
- return result # Return NumPy array with shape (channels, samples)
437
-
438
-
439
- def _make_new_audio(tl: v3, fmt: bv.AudioFormat, args: Args, log: Log) -> list[Any]:
440
- sr = tl.sr
441
- tb = tl.tb
442
- output: list[Any] = []
443
- samples: dict[tuple[FileInfo, int], Getter] = {}
444
-
445
- norm = parse_norm(args.audio_normalize, log)
446
-
447
- if not tl.a[0]:
448
- log.error("Trying to render empty audio timeline")
449
-
450
- layout = tl.T.layout
451
- try:
452
- bv.AudioLayout(layout)
453
- except ValueError:
454
- log.error(f"Invalid audio layout: {layout}")
455
-
456
- for i, layer in enumerate(tl.a):
457
- arr: np.ndarray | None = None
458
- use_iter = False
459
-
460
- for clip in layer:
461
- if (clip.src, clip.stream) not in samples:
462
- samples[(clip.src, clip.stream)] = Getter(
463
- clip.src.path, clip.stream, sr
464
- )
465
-
466
- log.conwrite("Creating audio")
467
- if arr is None:
468
- leng = max(round((layer[-1].start + layer[-1].dur) * sr / tb), sr // tb)
469
- map_path = Path(log.temp, f"{i}.map")
470
- arr = np.memmap(map_path, mode="w+", dtype=np.int16, shape=(2, leng))
471
-
472
- samp_start = round(clip.offset * clip.speed * sr / tb)
473
- samp_end = round((clip.offset + clip.dur) * clip.speed * sr / tb)
474
-
475
- getter = samples[(clip.src, clip.stream)]
476
-
477
- if clip.speed != 1 or clip.volume != 1:
478
- clip_arr = process_audio_clip(
479
- clip, getter.get(samp_start, samp_end), sr, log
480
- )
481
- else:
482
- clip_arr = getter.get(samp_start, samp_end)
483
-
484
- # Mix numpy arrays
485
- start = clip.start * sr // tb
486
- clip_samples = clip_arr.shape[1]
487
- if start + clip_samples > arr.shape[1]:
488
- # Shorten `clip_arr` if bigger than expected.
489
- arr[:, start:] += clip_arr[:, : arr.shape[1] - start]
490
- else:
491
- arr[:, start : start + clip_samples] += clip_arr
492
-
493
- if arr is not None:
494
- if norm is None:
495
- if args.mix_audio_streams:
496
- path = Path(log.temp, f"new{i}.wav")
497
- ndarray_to_file(arr, sr, path)
498
- output.append(f"{path}")
499
- else:
500
- use_iter = True
501
- else:
502
- path = Path(log.temp, f"new{i}.wav")
503
- pre_master = Path(log.temp, "premaster.wav")
504
-
505
- ndarray_to_file(arr, sr, pre_master)
506
- apply_audio_normalization(norm, pre_master, path, log)
507
- output.append(f"{path}")
508
-
509
- if use_iter and arr is not None:
510
- output.append(ndarray_to_iter(arr, fmt, layout, sr))
511
-
512
- if args.mix_audio_streams and len(output) > 1:
513
- new_a_file = f"{Path(log.temp, 'new_audio.wav')}"
514
- mix_audio_files(sr, output, new_a_file)
515
- return [new_a_file]
516
-
517
- return output