auto-editor 25.2.0__py3-none-any.whl → 25.3.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 +1 -1
- auto_editor/__main__.py +72 -6
- auto_editor/analyze.py +24 -26
- auto_editor/edit.py +37 -14
- auto_editor/ffwrapper.py +55 -67
- auto_editor/formats/fcp11.py +24 -27
- auto_editor/formats/utils.py +0 -18
- auto_editor/output.py +7 -7
- auto_editor/preview.py +2 -2
- auto_editor/render/audio.py +69 -38
- auto_editor/render/subtitle.py +5 -3
- auto_editor/render/video.py +1 -1
- auto_editor/subcommands/levels.py +2 -2
- auto_editor/subcommands/repl.py +2 -3
- auto_editor/subcommands/test.py +12 -4
- auto_editor/timeline.py +45 -0
- auto_editor/utils/bar.py +56 -49
- auto_editor/utils/func.py +0 -26
- auto_editor/utils/types.py +1 -0
- auto_editor/wavfile.py +25 -16
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/METADATA +2 -2
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/RECORD +27 -29
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/WHEEL +1 -1
- docs/build.py +1 -0
- auto_editor/utils/subtitle_tools.py +0 -29
- auto_editor/validate_input.py +0 -88
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/LICENSE +0 -0
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/entry_points.txt +0 -0
- {auto_editor-25.2.0.dist-info → auto_editor-25.3.1.dist-info}/top_level.txt +0 -0
auto_editor/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "25.
|
1
|
+
__version__ = "25.3.1"
|
auto_editor/__main__.py
CHANGED
@@ -1,11 +1,15 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
|
3
|
+
import re
|
3
4
|
import sys
|
4
5
|
from os import environ
|
6
|
+
from os.path import exists, isdir, isfile, lexists, splitext
|
7
|
+
from subprocess import run
|
5
8
|
|
6
9
|
import auto_editor
|
7
10
|
from auto_editor.edit import edit_media
|
8
|
-
from auto_editor.ffwrapper import FFmpeg
|
11
|
+
from auto_editor.ffwrapper import FFmpeg, initFFmpeg
|
12
|
+
from auto_editor.utils.func import get_stdout
|
9
13
|
from auto_editor.utils.log import Log
|
10
14
|
from auto_editor.utils.types import (
|
11
15
|
Args,
|
@@ -20,7 +24,6 @@ from auto_editor.utils.types import (
|
|
20
24
|
speed_range,
|
21
25
|
time_range,
|
22
26
|
)
|
23
|
-
from auto_editor.validate_input import valid_input
|
24
27
|
from auto_editor.vanparse import ArgumentParser
|
25
28
|
|
26
29
|
|
@@ -254,6 +257,11 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
|
|
254
257
|
flag=True,
|
255
258
|
help="Disable the inclusion of subtitle streams in the output file",
|
256
259
|
)
|
260
|
+
parser.add_argument(
|
261
|
+
"-dn",
|
262
|
+
flag=True,
|
263
|
+
help="Disable the inclusion of data streams in the output file",
|
264
|
+
)
|
257
265
|
parser.add_argument(
|
258
266
|
"--extras",
|
259
267
|
metavar="CMD",
|
@@ -269,6 +277,50 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
|
|
269
277
|
return parser
|
270
278
|
|
271
279
|
|
280
|
+
def download_video(my_input: str, args: Args, ffmpeg: FFmpeg, log: Log) -> str:
|
281
|
+
log.conwrite("Downloading video...")
|
282
|
+
|
283
|
+
def get_domain(url: str) -> str:
|
284
|
+
t = __import__("urllib").parse.urlparse(url).netloc
|
285
|
+
return ".".join(t.split(".")[-2:])
|
286
|
+
|
287
|
+
download_format = args.download_format
|
288
|
+
if download_format is None and get_domain(my_input) == "youtube.com":
|
289
|
+
download_format = "bestvideo[ext=mp4]+bestaudio[ext=m4a]"
|
290
|
+
|
291
|
+
if args.output_format is None:
|
292
|
+
output_format = re.sub(r"\W+", "-", splitext(my_input)[0]) + ".%(ext)s"
|
293
|
+
else:
|
294
|
+
output_format = args.output_format
|
295
|
+
|
296
|
+
yt_dlp_path = args.yt_dlp_location
|
297
|
+
|
298
|
+
cmd = ["--ffmpeg-location", ffmpeg.path]
|
299
|
+
|
300
|
+
if download_format is not None:
|
301
|
+
cmd.extend(["-f", download_format])
|
302
|
+
|
303
|
+
cmd.extend(["-o", output_format, my_input])
|
304
|
+
|
305
|
+
if args.yt_dlp_extras is not None:
|
306
|
+
cmd.extend(args.yt_dlp_extras.split(" "))
|
307
|
+
|
308
|
+
try:
|
309
|
+
location = get_stdout(
|
310
|
+
[yt_dlp_path, "--get-filename", "--no-warnings"] + cmd
|
311
|
+
).strip()
|
312
|
+
except FileNotFoundError:
|
313
|
+
log.error("Program `yt-dlp` must be installed and on PATH.")
|
314
|
+
|
315
|
+
if not isfile(location):
|
316
|
+
run([yt_dlp_path] + cmd)
|
317
|
+
|
318
|
+
if not isfile(location):
|
319
|
+
log.error(f"Download file wasn't created: {location}")
|
320
|
+
|
321
|
+
return location
|
322
|
+
|
323
|
+
|
272
324
|
def main() -> None:
|
273
325
|
subcommands = ("test", "info", "levels", "subdump", "desc", "repl", "palet")
|
274
326
|
|
@@ -279,8 +331,7 @@ def main() -> None:
|
|
279
331
|
obj.main(sys.argv[2:])
|
280
332
|
return
|
281
333
|
|
282
|
-
|
283
|
-
no_color = bool(environ.get("NO_COLOR")) or bool(environ.get(ff_color))
|
334
|
+
no_color = bool(environ.get("NO_COLOR") or environ.get("AV_LOG_FORCE_NOCOLOR"))
|
284
335
|
log = Log(no_color=no_color)
|
285
336
|
|
286
337
|
args = main_options(ArgumentParser("Auto-Editor")).parse_args(
|
@@ -322,13 +373,28 @@ def main() -> None:
|
|
322
373
|
is_machine = args.progress == "machine"
|
323
374
|
log = Log(args.debug, args.quiet, args.temp_dir, is_machine, no_color)
|
324
375
|
|
325
|
-
ffmpeg =
|
376
|
+
ffmpeg = initFFmpeg(
|
377
|
+
log,
|
326
378
|
args.ffmpeg_location,
|
327
379
|
args.my_ffmpeg,
|
328
380
|
args.show_ffmpeg_commands,
|
329
381
|
args.show_ffmpeg_output,
|
330
382
|
)
|
331
|
-
paths =
|
383
|
+
paths = []
|
384
|
+
for my_input in args.input:
|
385
|
+
if my_input.startswith("http://") or my_input.startswith("https://"):
|
386
|
+
paths.append(download_video(my_input, args, ffmpeg, log))
|
387
|
+
else:
|
388
|
+
if not splitext(my_input)[1]:
|
389
|
+
if isdir(my_input):
|
390
|
+
log.error("Input must be a file or a URL, not a directory.")
|
391
|
+
if exists(my_input):
|
392
|
+
log.error(f"Input file must have an extension: {my_input}")
|
393
|
+
if lexists(my_input):
|
394
|
+
log.error(f"Input file is a broken symbolic link: {my_input}")
|
395
|
+
if my_input.startswith("-"):
|
396
|
+
log.error(f"Option/Input file doesn't exist: {my_input}")
|
397
|
+
paths.append(my_input)
|
332
398
|
|
333
399
|
try:
|
334
400
|
edit_media(paths, ffmpeg, args, log)
|
auto_editor/analyze.py
CHANGED
@@ -14,12 +14,11 @@ from av.audio.fifo import AudioFifo
|
|
14
14
|
from av.subtitles.subtitle import AssSubtitle
|
15
15
|
|
16
16
|
from auto_editor import __version__
|
17
|
-
from auto_editor.utils.subtitle_tools import convert_ass_to_text
|
18
17
|
|
19
18
|
if TYPE_CHECKING:
|
20
|
-
from collections.abc import Iterator
|
19
|
+
from collections.abc import Iterator, Sequence
|
21
20
|
from fractions import Fraction
|
22
|
-
from
|
21
|
+
from pathlib import Path
|
23
22
|
|
24
23
|
from numpy.typing import NDArray
|
25
24
|
|
@@ -70,15 +69,6 @@ def mut_remove_large(
|
|
70
69
|
active = False
|
71
70
|
|
72
71
|
|
73
|
-
def obj_tag(tag: str, tb: Fraction, obj: dict[str, Any]) -> str:
|
74
|
-
key = f"{tag}:{tb}:"
|
75
|
-
for k, v in obj.items():
|
76
|
-
key += f"{k}={v},"
|
77
|
-
|
78
|
-
key = key[:-1] # remove unnecessary char
|
79
|
-
return key
|
80
|
-
|
81
|
-
|
82
72
|
def iter_audio(src, tb: Fraction, stream: int = 0) -> Iterator[np.float32]:
|
83
73
|
fifo = AudioFifo()
|
84
74
|
try:
|
@@ -122,7 +112,7 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
|
|
122
112
|
|
123
113
|
prev_frame = None
|
124
114
|
current_frame = None
|
125
|
-
total_pixels =
|
115
|
+
total_pixels = None
|
126
116
|
index = 0
|
127
117
|
prev_index = -1
|
128
118
|
|
@@ -140,10 +130,13 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
|
|
140
130
|
continue
|
141
131
|
|
142
132
|
graph.push(unframe)
|
143
|
-
frame = graph.
|
133
|
+
frame = graph.vpull()
|
144
134
|
assert frame.time is not None
|
145
135
|
index = round(frame.time * tb)
|
146
136
|
|
137
|
+
if total_pixels is None:
|
138
|
+
total_pixels = frame.width * frame.height
|
139
|
+
|
147
140
|
current_frame = frame.to_ndarray()
|
148
141
|
if prev_frame is None:
|
149
142
|
value = np.float32(0.0)
|
@@ -161,6 +154,12 @@ def iter_motion(src, tb, stream: int, blur: int, width: int) -> Iterator[np.floa
|
|
161
154
|
container.close()
|
162
155
|
|
163
156
|
|
157
|
+
def obj_tag(path: Path, kind: str, tb: Fraction, obj: Sequence[object]) -> str:
|
158
|
+
mod_time = int(path.stat().st_mtime)
|
159
|
+
key = f"{path.name}:{mod_time:x}:{kind}:{tb}:"
|
160
|
+
return key + ",".join(f"{v}" for v in obj)
|
161
|
+
|
162
|
+
|
164
163
|
@dataclass(slots=True)
|
165
164
|
class Levels:
|
166
165
|
src: FileInfo
|
@@ -173,7 +172,7 @@ class Levels:
|
|
173
172
|
@property
|
174
173
|
def media_length(self) -> int:
|
175
174
|
if self.src.audios:
|
176
|
-
if (arr := self.read_cache("audio",
|
175
|
+
if (arr := self.read_cache("audio", (0,))) is not None:
|
177
176
|
return len(arr)
|
178
177
|
|
179
178
|
result = sum(1 for _ in iter_audio(self.src, self.tb, 0))
|
@@ -201,7 +200,7 @@ class Levels:
|
|
201
200
|
def all(self) -> NDArray[np.bool_]:
|
202
201
|
return np.zeros(self.media_length, dtype=np.bool_)
|
203
202
|
|
204
|
-
def read_cache(self,
|
203
|
+
def read_cache(self, kind: str, obj: Sequence[object]) -> None | np.ndarray:
|
205
204
|
if self.no_cache:
|
206
205
|
return None
|
207
206
|
|
@@ -213,14 +212,14 @@ class Levels:
|
|
213
212
|
self.log.debug(e)
|
214
213
|
return None
|
215
214
|
|
216
|
-
key =
|
215
|
+
key = obj_tag(self.src.path, kind, self.tb, obj)
|
217
216
|
if key not in npzfile.files:
|
218
217
|
return None
|
219
218
|
|
220
219
|
self.log.debug("Using cache")
|
221
220
|
return npzfile[key]
|
222
221
|
|
223
|
-
def cache(self, arr: np.ndarray,
|
222
|
+
def cache(self, arr: np.ndarray, kind: str, obj: Sequence[object]) -> np.ndarray:
|
224
223
|
if self.no_cache:
|
225
224
|
return arr
|
226
225
|
|
@@ -228,8 +227,8 @@ class Levels:
|
|
228
227
|
if not os.path.exists(workdur):
|
229
228
|
os.mkdir(workdur)
|
230
229
|
|
231
|
-
|
232
|
-
np.savez(os.path.join(workdur, "cache.npz"), **{
|
230
|
+
key = obj_tag(self.src.path, kind, self.tb, obj)
|
231
|
+
np.savez(os.path.join(workdur, "cache.npz"), **{key: arr})
|
233
232
|
|
234
233
|
return arr
|
235
234
|
|
@@ -237,7 +236,7 @@ class Levels:
|
|
237
236
|
if stream >= len(self.src.audios):
|
238
237
|
raise LevelError(f"audio: audio stream '{stream}' does not exist.")
|
239
238
|
|
240
|
-
if (arr := self.read_cache("audio",
|
239
|
+
if (arr := self.read_cache("audio", (stream,))) is not None:
|
241
240
|
return arr
|
242
241
|
|
243
242
|
with av.open(self.src.path, "r") as container:
|
@@ -264,13 +263,13 @@ class Levels:
|
|
264
263
|
index += 1
|
265
264
|
|
266
265
|
bar.end()
|
267
|
-
return self.cache(result[:index], "audio",
|
266
|
+
return self.cache(result[:index], "audio", (stream,))
|
268
267
|
|
269
268
|
def motion(self, stream: int, blur: int, width: int) -> NDArray[np.float32]:
|
270
269
|
if stream >= len(self.src.videos):
|
271
270
|
raise LevelError(f"motion: video stream '{stream}' does not exist.")
|
272
271
|
|
273
|
-
mobj =
|
272
|
+
mobj = (stream, width, blur)
|
274
273
|
if (arr := self.read_cache("motion", mobj)) is not None:
|
275
274
|
return arr
|
276
275
|
|
@@ -359,11 +358,10 @@ class Levels:
|
|
359
358
|
san_end = round((start + dur) * self.tb)
|
360
359
|
|
361
360
|
for sub in subset:
|
362
|
-
if isinstance(sub, AssSubtitle):
|
363
|
-
line = convert_ass_to_text(sub.ass.decode(errors="ignore"))
|
364
|
-
else:
|
361
|
+
if not isinstance(sub, AssSubtitle):
|
365
362
|
continue
|
366
363
|
|
364
|
+
line = sub.dialogue.decode(errors="ignore")
|
367
365
|
if line and re.search(re_pattern, line):
|
368
366
|
result[san_start:san_end] = 1
|
369
367
|
count += 1
|
auto_editor/edit.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os
|
4
|
+
import sys
|
5
|
+
from subprocess import run
|
4
6
|
from typing import Any
|
5
7
|
|
6
8
|
from auto_editor.ffwrapper import FFmpeg, FileInfo, initFileInfo
|
@@ -11,11 +13,10 @@ from auto_editor.render.audio import make_new_audio
|
|
11
13
|
from auto_editor.render.subtitle import make_new_subtitles
|
12
14
|
from auto_editor.render.video import render_av
|
13
15
|
from auto_editor.timeline import v1, v3
|
14
|
-
from auto_editor.utils.bar import
|
16
|
+
from auto_editor.utils.bar import initBar
|
15
17
|
from auto_editor.utils.chunks import Chunk, Chunks
|
16
18
|
from auto_editor.utils.cmdkw import ParserError, parse_with_palet, pAttr, pAttrs
|
17
19
|
from auto_editor.utils.container import Container, container_constructor
|
18
|
-
from auto_editor.utils.func import open_with_system_default
|
19
20
|
from auto_editor.utils.log import Log
|
20
21
|
from auto_editor.utils.types import Args
|
21
22
|
|
@@ -125,7 +126,9 @@ def parse_export(export: str, log: Log) -> dict[str, Any]:
|
|
125
126
|
"default": pAttrs("default"),
|
126
127
|
"premiere": pAttrs("premiere", name_attr),
|
127
128
|
"resolve-fcp7": pAttrs("resolve-fcp7", name_attr),
|
128
|
-
"final-cut-pro": pAttrs(
|
129
|
+
"final-cut-pro": pAttrs(
|
130
|
+
"final-cut-pro", name_attr, pAttr("version", 11, is_int)
|
131
|
+
),
|
129
132
|
"resolve": pAttrs("resolve", name_attr),
|
130
133
|
"shotcut": pAttrs("shotcut"),
|
131
134
|
"json": pAttrs("json", pAttr("api", 3, is_int)),
|
@@ -146,7 +149,7 @@ def parse_export(export: str, log: Log) -> dict[str, Any]:
|
|
146
149
|
|
147
150
|
|
148
151
|
def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
|
149
|
-
bar =
|
152
|
+
bar = initBar(args.progress)
|
150
153
|
tl = None
|
151
154
|
|
152
155
|
if paths:
|
@@ -232,11 +235,19 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
|
|
232
235
|
fcp7_write_xml(export_ops["name"], output, is_resolve, tl, log)
|
233
236
|
return
|
234
237
|
|
235
|
-
if export
|
238
|
+
if export == "final-cut-pro":
|
236
239
|
from auto_editor.formats.fcp11 import fcp11_write_xml
|
237
240
|
|
238
|
-
|
239
|
-
fcp11_write_xml(export_ops["name"],
|
241
|
+
ver = export_ops["version"]
|
242
|
+
fcp11_write_xml(export_ops["name"], ver, output, False, tl, log)
|
243
|
+
return
|
244
|
+
|
245
|
+
if export == "resolve":
|
246
|
+
from auto_editor.formats.fcp11 import fcp11_write_xml
|
247
|
+
from auto_editor.timeline import set_stream_to_0
|
248
|
+
|
249
|
+
set_stream_to_0(tl, log)
|
250
|
+
fcp11_write_xml(export_ops["name"], 10, output, True, tl, log)
|
240
251
|
return
|
241
252
|
|
242
253
|
if export == "shotcut":
|
@@ -318,7 +329,7 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
|
|
318
329
|
total_frames = tl.v1.chunks[-1][1] - 1
|
319
330
|
clip_num = 0
|
320
331
|
for chunk in tl.v1.chunks:
|
321
|
-
if chunk[2] == 99999:
|
332
|
+
if chunk[2] == 0 or chunk[2] >= 99999:
|
322
333
|
continue
|
323
334
|
|
324
335
|
padded_chunks = pad_chunk(chunk, total_frames)
|
@@ -344,11 +355,23 @@ def edit_media(paths: list[str], ffmpeg: FFmpeg, args: Args, log: Log) -> None:
|
|
344
355
|
|
345
356
|
log.stop_timer()
|
346
357
|
|
347
|
-
if not args.no_open and export in ("default", "audio"
|
358
|
+
if not args.no_open and export in ("default", "audio"):
|
348
359
|
if args.player is None:
|
349
|
-
|
360
|
+
if sys.platform == "win32":
|
361
|
+
try:
|
362
|
+
os.startfile(output)
|
363
|
+
except OSError:
|
364
|
+
log.warning(f"Could not find application to open file: {output}")
|
365
|
+
else:
|
366
|
+
try: # MacOS case
|
367
|
+
run(["open", output])
|
368
|
+
except Exception:
|
369
|
+
try: # WSL2 case
|
370
|
+
run(["cmd.exe", "/C", "start", output])
|
371
|
+
except Exception:
|
372
|
+
try: # Linux case
|
373
|
+
run(["xdg-open", output])
|
374
|
+
except Exception:
|
375
|
+
log.warning(f"Could not open output file: {output}")
|
350
376
|
else:
|
351
|
-
|
352
|
-
from shlex import split
|
353
|
-
|
354
|
-
subprocess.run(split(args.player) + [output])
|
377
|
+
run(__import__("shlex").split(args.player) + [output])
|
auto_editor/ffwrapper.py
CHANGED
@@ -1,83 +1,59 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os.path
|
4
|
-
import subprocess
|
5
4
|
import sys
|
6
5
|
from dataclasses import dataclass
|
7
6
|
from fractions import Fraction
|
8
7
|
from pathlib import Path
|
9
8
|
from re import search
|
10
9
|
from shutil import which
|
11
|
-
from subprocess import PIPE, Popen
|
10
|
+
from subprocess import PIPE, Popen, run
|
12
11
|
from typing import Any
|
13
12
|
|
14
13
|
import av
|
15
14
|
|
16
|
-
from auto_editor.utils.func import get_stdout
|
17
15
|
from auto_editor.utils.log import Log
|
18
16
|
|
19
17
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
18
|
+
def initFFmpeg(
|
19
|
+
log: Log, ff_location: str | None, my_ffmpeg: bool, show_cmd: bool, debug: bool
|
20
|
+
) -> FFmpeg:
|
21
|
+
if ff_location is not None:
|
22
|
+
program = ff_location
|
23
|
+
elif my_ffmpeg:
|
24
|
+
program = "ffmpeg"
|
25
|
+
else:
|
54
26
|
try:
|
55
|
-
|
56
|
-
self.version = _version.replace("ffmpeg version", "").strip().split(" ")[0]
|
57
|
-
except FileNotFoundError:
|
58
|
-
Log().error("ffmpeg must be installed and on PATH.")
|
27
|
+
import ae_ffmpeg
|
59
28
|
|
60
|
-
|
61
|
-
|
62
|
-
|
29
|
+
program = ae_ffmpeg.get_path()
|
30
|
+
except ImportError:
|
31
|
+
program = "ffmpeg"
|
63
32
|
|
64
|
-
|
65
|
-
|
66
|
-
|
33
|
+
path: str | None = which(program)
|
34
|
+
if path is None:
|
35
|
+
log.error("Did not find ffmpeg on PATH.")
|
36
|
+
|
37
|
+
return FFmpeg(log, path, show_cmd, debug)
|
38
|
+
|
39
|
+
|
40
|
+
@dataclass(slots=True)
|
41
|
+
class FFmpeg:
|
42
|
+
log: Log
|
43
|
+
path: str
|
44
|
+
show_cmd: bool
|
45
|
+
debug: bool
|
67
46
|
|
68
47
|
def run(self, cmd: list[str]) -> None:
|
69
48
|
cmd = [self.path, "-hide_banner", "-y"] + cmd
|
70
49
|
if not self.debug:
|
71
50
|
cmd.extend(["-nostats", "-loglevel", "error"])
|
72
|
-
self.
|
73
|
-
|
51
|
+
if self.show_cmd:
|
52
|
+
sys.stderr.write(f"{' '.join(cmd)}\n\n")
|
53
|
+
run(cmd)
|
74
54
|
|
75
55
|
def run_check_errors(
|
76
|
-
self,
|
77
|
-
cmd: list[str],
|
78
|
-
log: Log,
|
79
|
-
show_out: bool = False,
|
80
|
-
path: str | None = None,
|
56
|
+
self, cmd: list[str], show_out: bool = False, path: str | None = None
|
81
57
|
) -> None:
|
82
58
|
process = self.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
83
59
|
_, stderr = process.communicate()
|
@@ -104,27 +80,39 @@ class FFmpeg:
|
|
104
80
|
|
105
81
|
for item in error_list:
|
106
82
|
if check := search(item, output):
|
107
|
-
log.error(check.group())
|
83
|
+
self.log.error(check.group())
|
108
84
|
|
109
85
|
if path is not None and not os.path.isfile(path):
|
110
|
-
log.error(f"The file {path} was not created.")
|
111
|
-
|
86
|
+
self.log.error(f"The file {path} was not created.")
|
87
|
+
if show_out and not self.debug:
|
112
88
|
print(f"stderr: {output}")
|
113
89
|
|
114
90
|
def Popen(
|
115
91
|
self, cmd: list[str], stdin: Any = None, stdout: Any = PIPE, stderr: Any = None
|
116
92
|
) -> Popen:
|
117
|
-
|
118
|
-
|
119
|
-
return Popen(cmd, stdin=stdin, stdout=stdout, stderr=stderr)
|
93
|
+
if self.show_cmd:
|
94
|
+
sys.stderr.write(f"{self.path} {' '.join(cmd)}\n\n")
|
95
|
+
return Popen([self.path] + cmd, stdin=stdin, stdout=stdout, stderr=stderr)
|
96
|
+
|
97
|
+
|
98
|
+
def mux(input: Path, output: Path, stream: int) -> None:
|
99
|
+
input_container = av.open(input, "r")
|
100
|
+
output_container = av.open(output, "w")
|
101
|
+
|
102
|
+
input_audio_stream = input_container.streams.audio[stream]
|
103
|
+
output_audio_stream = output_container.add_stream("pcm_s16le")
|
104
|
+
|
105
|
+
for frame in input_container.decode(input_audio_stream):
|
106
|
+
packet = output_audio_stream.encode(frame)
|
107
|
+
if packet:
|
108
|
+
output_container.mux(packet)
|
120
109
|
|
121
|
-
|
122
|
-
|
110
|
+
packet = output_audio_stream.encode(None)
|
111
|
+
if packet:
|
112
|
+
output_container.mux(packet)
|
123
113
|
|
124
|
-
|
125
|
-
|
126
|
-
self.print(output)
|
127
|
-
return output
|
114
|
+
output_container.close()
|
115
|
+
input_container.close()
|
128
116
|
|
129
117
|
|
130
118
|
@dataclass(slots=True, frozen=True)
|
@@ -269,7 +257,7 @@ def initFileInfo(path: str, log: Log) -> FileInfo:
|
|
269
257
|
|
270
258
|
desc = cont.metadata.get("description", None)
|
271
259
|
bitrate = 0 if cont.bit_rate is None else cont.bit_rate
|
272
|
-
dur = 0 if cont.duration is None else cont.duration /
|
260
|
+
dur = 0 if cont.duration is None else cont.duration / av.time_base
|
273
261
|
|
274
262
|
cont.close()
|
275
263
|
|
auto_editor/formats/fcp11.py
CHANGED
@@ -3,17 +3,15 @@ from __future__ import annotations
|
|
3
3
|
from typing import TYPE_CHECKING, Any, cast
|
4
4
|
from xml.etree.ElementTree import Element, ElementTree, SubElement, indent
|
5
5
|
|
6
|
-
from auto_editor.ffwrapper import FFmpeg, FileInfo, initFileInfo
|
7
|
-
|
8
|
-
from .utils import make_tracks_dir
|
9
|
-
|
10
6
|
if TYPE_CHECKING:
|
11
7
|
from collections.abc import Sequence
|
12
8
|
from fractions import Fraction
|
13
9
|
|
10
|
+
from auto_editor.ffwrapper import FileInfo
|
14
11
|
from auto_editor.timeline import TlAudio, TlVideo, v3
|
15
12
|
from auto_editor.utils.log import Log
|
16
13
|
|
14
|
+
|
17
15
|
"""
|
18
16
|
Export a FCPXML 11 file readable with Final Cut Pro 10.6.8 or later.
|
19
17
|
|
@@ -54,7 +52,7 @@ def make_name(src: FileInfo, tb: Fraction) -> str:
|
|
54
52
|
|
55
53
|
|
56
54
|
def fcp11_write_xml(
|
57
|
-
group_name: str,
|
55
|
+
group_name: str, version: int, output: str, resolve: bool, tl: v3, log: Log
|
58
56
|
) -> None:
|
59
57
|
def fraction(val: int) -> str:
|
60
58
|
if val == 0:
|
@@ -68,23 +66,17 @@ def fcp11_write_xml(
|
|
68
66
|
src_dur = int(src.duration * tl.tb)
|
69
67
|
tl_dur = src_dur if resolve else tl.out_len()
|
70
68
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
newtrack = fold / f"{i}.wav"
|
78
|
-
ffmpeg.run(
|
79
|
-
["-i", f"{src.path.resolve()}", "-map", f"0:a:{i}", f"{newtrack}"]
|
80
|
-
)
|
81
|
-
all_srcs.append(initFileInfo(f"{newtrack}", log))
|
82
|
-
all_refs.append(f"r{(i + 1) * 2}")
|
69
|
+
if version == 11:
|
70
|
+
ver_str = "1.11"
|
71
|
+
elif version == 10:
|
72
|
+
ver_str = "1.10"
|
73
|
+
else:
|
74
|
+
log.error(f"Unknown final cut pro version: {version}")
|
83
75
|
|
84
|
-
fcpxml = Element("fcpxml", version=
|
76
|
+
fcpxml = Element("fcpxml", version=ver_str)
|
85
77
|
resources = SubElement(fcpxml, "resources")
|
86
78
|
|
87
|
-
for i, one_src in enumerate(
|
79
|
+
for i, one_src in enumerate(tl.unique_sources()):
|
88
80
|
SubElement(
|
89
81
|
resources,
|
90
82
|
"format",
|
@@ -126,13 +118,6 @@ def fcp11_write_xml(
|
|
126
118
|
)
|
127
119
|
spine = SubElement(sequence, "spine")
|
128
120
|
|
129
|
-
if tl.v and tl.v[0]:
|
130
|
-
clips: Sequence[TlVideo | TlAudio] = cast(Any, tl.v[0])
|
131
|
-
elif tl.a and tl.a[0]:
|
132
|
-
clips = tl.a[0]
|
133
|
-
else:
|
134
|
-
clips = []
|
135
|
-
|
136
121
|
def make_clip(ref: str, clip: TlVideo | TlAudio) -> None:
|
137
122
|
clip_properties = {
|
138
123
|
"name": proj_name,
|
@@ -157,7 +142,19 @@ def fcp11_write_xml(
|
|
157
142
|
interp="smooth2",
|
158
143
|
)
|
159
144
|
|
160
|
-
|
145
|
+
if tl.v and tl.v[0]:
|
146
|
+
clips: Sequence[TlVideo | TlAudio] = cast(Any, tl.v[0])
|
147
|
+
elif tl.a and tl.a[0]:
|
148
|
+
clips = tl.a[0]
|
149
|
+
else:
|
150
|
+
clips = []
|
151
|
+
|
152
|
+
all_refs: list[str] = ["r2"]
|
153
|
+
if resolve:
|
154
|
+
for i in range(1, len(tl.a)):
|
155
|
+
all_refs.append(f"r{(i + 1) * 2}")
|
156
|
+
|
157
|
+
for my_ref in reversed(all_refs):
|
161
158
|
for clip in clips:
|
162
159
|
make_clip(my_ref, clip)
|
163
160
|
|
auto_editor/formats/utils.py
CHANGED
@@ -4,9 +4,6 @@ from typing import TYPE_CHECKING
|
|
4
4
|
from xml.etree.ElementTree import Element
|
5
5
|
|
6
6
|
if TYPE_CHECKING:
|
7
|
-
from pathlib import Path
|
8
|
-
|
9
|
-
from auto_editor.ffwrapper import FileInfo
|
10
7
|
from auto_editor.utils.log import Log
|
11
8
|
|
12
9
|
|
@@ -19,21 +16,6 @@ def show(ele: Element, limit: int, depth: int = 0) -> None:
|
|
19
16
|
show(child, limit, depth + 1)
|
20
17
|
|
21
18
|
|
22
|
-
def make_tracks_dir(src: FileInfo) -> Path:
|
23
|
-
from os import mkdir
|
24
|
-
from shutil import rmtree
|
25
|
-
|
26
|
-
fold = src.path.parent / f"{src.path.stem}_tracks"
|
27
|
-
|
28
|
-
try:
|
29
|
-
mkdir(fold)
|
30
|
-
except OSError:
|
31
|
-
rmtree(fold)
|
32
|
-
mkdir(fold)
|
33
|
-
|
34
|
-
return fold
|
35
|
-
|
36
|
-
|
37
19
|
class Validator:
|
38
20
|
def __init__(self, log: Log):
|
39
21
|
self.log = log
|