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
auto_editor/analyze.py DELETED
@@ -1,393 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import re
5
- from dataclasses import dataclass
6
- from fractions import Fraction
7
- from hashlib import sha1
8
- from math import ceil
9
- from tempfile import gettempdir
10
- from typing import TYPE_CHECKING
11
-
12
- import bv
13
- import numpy as np
14
- from bv.audio.fifo import AudioFifo
15
- from bv.subtitles.subtitle import AssSubtitle
16
-
17
- from auto_editor import __version__
18
-
19
- if TYPE_CHECKING:
20
- from collections.abc import Iterator, Sequence
21
- from fractions import Fraction
22
-
23
- from numpy.typing import NDArray
24
-
25
- from auto_editor.ffwrapper import FileInfo
26
- from auto_editor.utils.bar import Bar
27
- from auto_editor.utils.log import Log
28
-
29
-
30
- __all__ = ("LevelError", "initLevels", "iter_audio", "iter_motion")
31
-
32
-
33
- class LevelError(Exception):
34
- pass
35
-
36
-
37
- def mut_remove_small(
38
- arr: NDArray[np.bool_], lim: int, replace: int, with_: int
39
- ) -> None:
40
- start_p = 0
41
- active = False
42
- for j, item in enumerate(arr):
43
- if item == replace:
44
- if not active:
45
- start_p = j
46
- active = True
47
-
48
- if j == len(arr) - 1 and j - start_p < lim:
49
- arr[start_p:] = with_
50
- elif active:
51
- if j - start_p < lim:
52
- arr[start_p:j] = with_
53
- active = False
54
-
55
-
56
- def mut_remove_large(
57
- arr: NDArray[np.bool_], lim: int, replace: int, with_: int
58
- ) -> None:
59
- start_p = 0
60
- active = False
61
- for j, item in enumerate(arr):
62
- if item == replace:
63
- if not active:
64
- start_p = j
65
- active = True
66
-
67
- if j == len(arr) - 1 and j - start_p >= lim:
68
- arr[start_p:] = with_
69
- elif active:
70
- if j - start_p > lim:
71
- arr[start_p:j] = with_
72
- active = False
73
-
74
-
75
- def iter_audio(audio_stream: bv.AudioStream, tb: Fraction) -> Iterator[np.float32]:
76
- fifo = AudioFifo()
77
- sr = audio_stream.rate
78
-
79
- exact_size = (1 / tb) * sr
80
- accumulated_error = Fraction(0)
81
-
82
- # Resample so that audio data is between [-1, 1]
83
- resampler = bv.AudioResampler(bv.AudioFormat("flt"), audio_stream.layout, sr)
84
-
85
- container = audio_stream.container
86
- assert isinstance(container, bv.container.InputContainer)
87
-
88
- for frame in container.decode(audio_stream):
89
- frame.pts = None # Skip time checks
90
-
91
- for reframe in resampler.resample(frame):
92
- fifo.write(reframe)
93
-
94
- while fifo.samples >= ceil(exact_size):
95
- size_with_error = exact_size + accumulated_error
96
- current_size = round(size_with_error)
97
- accumulated_error = size_with_error - current_size
98
-
99
- audio_chunk = fifo.read(current_size)
100
- assert audio_chunk is not None
101
- arr = audio_chunk.to_ndarray().flatten()
102
- yield np.max(np.abs(arr))
103
-
104
-
105
- def iter_motion(
106
- video: bv.VideoStream, tb: Fraction, blur: int, width: int
107
- ) -> Iterator[np.float32]:
108
- video.thread_type = "AUTO"
109
-
110
- prev_frame = None
111
- current_frame = None
112
- total_pixels = None
113
- index = 0
114
- prev_index = -1
115
-
116
- graph = bv.filter.Graph()
117
- graph.link_nodes(
118
- graph.add_buffer(template=video),
119
- graph.add("scale", f"{width}:-1"),
120
- graph.add("format", "gray"),
121
- graph.add("gblur", f"sigma={blur}"),
122
- graph.add("buffersink"),
123
- ).configure()
124
-
125
- container = video.container
126
- assert isinstance(container, bv.container.InputContainer)
127
-
128
- for unframe in container.decode(video):
129
- if unframe.pts is None:
130
- continue
131
-
132
- graph.push(unframe)
133
- frame = graph.vpull()
134
- assert frame.time is not None
135
- index = round(frame.time * tb)
136
-
137
- if total_pixels is None:
138
- total_pixels = frame.width * frame.height
139
-
140
- current_frame = frame.to_ndarray()
141
- if prev_frame is None:
142
- value = np.float32(0.0)
143
- else:
144
- # Use `int16` to avoid underflow with `uint8` datatype
145
- diff = np.abs(prev_frame.astype(np.int16) - current_frame.astype(np.int16))
146
- value = np.float32(np.count_nonzero(diff) / total_pixels)
147
-
148
- for _ in range(index - prev_index):
149
- yield value
150
-
151
- prev_frame = current_frame
152
- prev_index = index
153
-
154
-
155
- @dataclass(slots=True)
156
- class Levels:
157
- container: bv.container.InputContainer
158
- name: str
159
- mod_time: int
160
- tb: Fraction
161
- bar: Bar
162
- no_cache: bool
163
- log: Log
164
-
165
- @property
166
- def media_length(self) -> int:
167
- container = self.container
168
- if container.streams.audio:
169
- if (arr := self.read_cache("audio", (0,))) is not None:
170
- return len(arr)
171
-
172
- audio_stream = container.streams.audio[0]
173
- result = sum(1 for _ in iter_audio(audio_stream, self.tb))
174
- container.seek(0)
175
- self.log.debug(f"Audio Length: {result}")
176
- return result
177
-
178
- # If there's no audio, get length in video metadata.
179
- if not container.streams.video:
180
- self.log.error("Could not get media duration")
181
-
182
- video = container.streams.video[0]
183
- if video.duration is None or video.time_base is None:
184
- dur = 0
185
- else:
186
- dur = int(video.duration * video.time_base * self.tb)
187
- self.log.debug(f"Video duration: {dur}")
188
- container.seek(0)
189
-
190
- return dur
191
-
192
- def obj_tag(self, kind: str, obj: Sequence[object]) -> str:
193
- mod_time = self.mod_time
194
- key = f"{self.name}:{mod_time:x}:{self.tb}:" + ",".join(f"{v}" for v in obj)
195
- return f"{sha1(key.encode()).hexdigest()[:16]}{kind}"
196
-
197
- def none(self) -> NDArray[np.bool_]:
198
- return np.ones(self.media_length, dtype=np.bool_)
199
-
200
- def all(self) -> NDArray[np.bool_]:
201
- return np.zeros(self.media_length, dtype=np.bool_)
202
-
203
- def read_cache(self, kind: str, obj: Sequence[object]) -> None | np.ndarray:
204
- if self.no_cache:
205
- return None
206
-
207
- key = self.obj_tag(kind, obj)
208
- cache_file = os.path.join(gettempdir(), f"ae-{__version__}", f"{key}.npz")
209
-
210
- try:
211
- with np.load(cache_file, allow_pickle=False) as npzfile:
212
- return npzfile["data"]
213
- except Exception as e:
214
- self.log.debug(e)
215
- return None
216
-
217
- def cache(self, arr: np.ndarray, kind: str, obj: Sequence[object]) -> np.ndarray:
218
- if self.no_cache:
219
- return arr
220
-
221
- workdir = os.path.join(gettempdir(), f"ae-{__version__}")
222
- os.makedirs(workdir, exist_ok=True)
223
- cache_file = os.path.join(workdir, f"{self.obj_tag(kind, obj)}.npz")
224
-
225
- try:
226
- np.savez(cache_file, data=arr)
227
- except Exception as e:
228
- self.log.warning(f"Cache write failed: {e}")
229
-
230
- cache_entries = []
231
- with os.scandir(workdir) as entries:
232
- for entry in entries:
233
- if entry.name.endswith(".npz"):
234
- cache_entries.append((entry.path, entry.stat().st_mtime))
235
-
236
- if len(cache_entries) > 10:
237
- # Sort by modification time, oldest first
238
- cache_entries.sort(key=lambda x: x[1])
239
- # Remove oldest files until we're back to 10
240
- for filepath, _ in cache_entries[:-10]:
241
- try:
242
- os.remove(filepath)
243
- except OSError:
244
- pass
245
-
246
- return arr
247
-
248
- def audio(self, stream: int) -> NDArray[np.float32]:
249
- container = self.container
250
- if stream >= len(container.streams.audio):
251
- raise LevelError(f"audio: audio stream '{stream}' does not exist.")
252
-
253
- if (arr := self.read_cache("audio", (stream,))) is not None:
254
- return arr
255
-
256
- audio = container.streams.audio[stream]
257
-
258
- if audio.duration is not None and audio.time_base is not None:
259
- inaccurate_dur = int(audio.duration * audio.time_base * self.tb)
260
- elif container.duration is not None:
261
- inaccurate_dur = int(container.duration / bv.time_base * self.tb)
262
- else:
263
- inaccurate_dur = 1024
264
-
265
- bar = self.bar
266
- bar.start(inaccurate_dur, "Analyzing audio volume")
267
-
268
- result: NDArray[np.float32] = np.zeros(inaccurate_dur, dtype=np.float32)
269
- index = 0
270
-
271
- for value in iter_audio(audio, self.tb):
272
- if index > len(result) - 1:
273
- result = np.concatenate(
274
- (result, np.zeros(len(result), dtype=np.float32))
275
- )
276
-
277
- result[index] = value
278
- bar.tick(index)
279
- index += 1
280
-
281
- bar.end()
282
- container.seek(0)
283
- assert len(result) > 0
284
- return self.cache(result[:index], "audio", (stream,))
285
-
286
- def motion(self, stream: int, blur: int, width: int) -> NDArray[np.float32]:
287
- container = self.container
288
- if stream >= len(container.streams.video):
289
- raise LevelError(f"motion: video stream '{stream}' does not exist.")
290
-
291
- mobj = (stream, width, blur)
292
- if (arr := self.read_cache("motion", mobj)) is not None:
293
- return arr
294
-
295
- video = container.streams.video[stream]
296
- inaccurate_dur = (
297
- 1024
298
- if video.duration is None or video.time_base is None
299
- else int(video.duration * video.time_base * self.tb)
300
- )
301
- bar = self.bar
302
- bar.start(inaccurate_dur, "Analyzing motion")
303
-
304
- result: NDArray[np.float32] = np.zeros(inaccurate_dur, dtype=np.float32)
305
- index = 0
306
- for value in iter_motion(video, self.tb, blur, width):
307
- if index > len(result) - 1:
308
- result = np.concatenate(
309
- (result, np.zeros(len(result), dtype=np.float32))
310
- )
311
- result[index] = value
312
- bar.tick(index)
313
- index += 1
314
-
315
- bar.end()
316
- container.seek(0)
317
- return self.cache(result[:index], "motion", mobj)
318
-
319
- def subtitle(
320
- self,
321
- pattern: str,
322
- stream: int,
323
- ignore_case: bool,
324
- max_count: int | None,
325
- ) -> NDArray[np.bool_]:
326
- container = self.container
327
- if stream >= len(container.streams.subtitles):
328
- raise LevelError(f"subtitle: subtitle stream '{stream}' does not exist.")
329
-
330
- try:
331
- flags = re.IGNORECASE if ignore_case else 0
332
- re_pattern = re.compile(pattern, flags)
333
- except re.error as e:
334
- self.log.error(e)
335
- try:
336
- subtitle_stream = container.streams.subtitles[stream]
337
- assert isinstance(subtitle_stream.time_base, Fraction)
338
- except Exception as e:
339
- self.log.error(e)
340
-
341
- # Get the length of the subtitle stream.
342
- sub_length = 0
343
- for packet in container.demux(subtitle_stream):
344
- if packet.pts is None or packet.duration is None:
345
- continue
346
- sub_length = max(sub_length, packet.pts + packet.duration)
347
-
348
- sub_length = round(sub_length * subtitle_stream.time_base * self.tb)
349
- result = np.zeros((sub_length), dtype=np.bool_)
350
- del sub_length
351
-
352
- count = 0
353
- early_exit = False
354
- container.seek(0)
355
- for packet in container.demux(subtitle_stream):
356
- if packet.pts is None or packet.duration is None:
357
- continue
358
- if early_exit:
359
- break
360
-
361
- if max_count is not None and count >= max_count:
362
- early_exit = True
363
- break
364
-
365
- start = float(packet.pts * subtitle_stream.time_base)
366
- dur = float(packet.duration * subtitle_stream.time_base)
367
-
368
- san_start = round(start * self.tb)
369
- san_end = round((start + dur) * self.tb)
370
-
371
- for sub in packet.decode():
372
- if not isinstance(sub, AssSubtitle):
373
- continue
374
-
375
- line = sub.dialogue.decode(errors="ignore")
376
- if line and re.search(re_pattern, line):
377
- result[san_start:san_end] = 1
378
- count += 1
379
-
380
- container.seek(0)
381
- return result
382
-
383
-
384
- def initLevels(
385
- src: FileInfo, tb: Fraction, bar: Bar, no_cache: bool, log: Log
386
- ) -> Levels:
387
- try:
388
- container = bv.open(src.path)
389
- except bv.FFmpegError as e:
390
- log.error(e)
391
-
392
- mod_time = int(src.path.stat().st_mtime)
393
- return Levels(container, src.path.name, mod_time, tb, bar, no_cache, log)
File without changes
auto_editor/cmds/cache.py DELETED
@@ -1,69 +0,0 @@
1
- import glob
2
- import os
3
- import sys
4
- from shutil import rmtree
5
- from tempfile import gettempdir
6
-
7
- import numpy as np
8
-
9
- from auto_editor import __version__
10
-
11
-
12
- def main(sys_args: list[str] = sys.argv[1:]) -> None:
13
- cache_dir = os.path.join(gettempdir(), f"ae-{__version__}")
14
-
15
- if sys_args and sys_args[0] in {"clean", "clear"}:
16
- rmtree(cache_dir, ignore_errors=True)
17
- return
18
-
19
- if not os.path.exists(cache_dir):
20
- print("Empty cache")
21
- return
22
-
23
- cache_files = glob.glob(os.path.join(cache_dir, "*.npz"))
24
- if not cache_files:
25
- print("Empty cache")
26
- return
27
-
28
- def format_bytes(size: float) -> str:
29
- for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
30
- if size < 1024:
31
- return f"{size:.2f} {unit}"
32
- size /= 1024
33
- return f"{size:.2f} PiB"
34
-
35
- GRAY = "\033[90m"
36
- GREEN = "\033[32m"
37
- BLUE = "\033[34m"
38
- YELLOW = "\033[33m"
39
- RESET = "\033[0m"
40
-
41
- total_size = 0
42
- for cache_file in cache_files:
43
- try:
44
- with np.load(cache_file, allow_pickle=False) as npzfile:
45
- array = npzfile["data"]
46
- key = os.path.basename(cache_file)[:-4] # Remove .npz extension
47
-
48
- hash_part = key[:16]
49
- rest_part = key[16:]
50
-
51
- size = array.nbytes
52
- total_size += size
53
- size_str = format_bytes(size)
54
- size_num, size_unit = size_str.rsplit(" ", 1)
55
-
56
- print(
57
- f"{YELLOW}entry: {GRAY}{hash_part}{RESET}{rest_part} "
58
- f"{YELLOW}size: {GREEN}{size_num} {BLUE}{size_unit}{RESET}"
59
- )
60
- except Exception as e:
61
- print(f"Error reading {cache_file}: {e}")
62
-
63
- total_str = format_bytes(total_size)
64
- total_num, total_unit = total_str.rsplit(" ", 1)
65
- print(f"\n{YELLOW}total cache size: {GREEN}{total_num} {BLUE}{total_unit}{RESET}")
66
-
67
-
68
- if __name__ == "__main__":
69
- main()
auto_editor/cmds/desc.py DELETED
@@ -1,32 +0,0 @@
1
- import sys
2
- from dataclasses import dataclass, field
3
-
4
- import bv
5
-
6
- from auto_editor.vanparse import ArgumentParser
7
-
8
-
9
- @dataclass(slots=True)
10
- class DescArgs:
11
- help: bool = False
12
- input: list[str] = field(default_factory=list)
13
-
14
-
15
- def desc_options(parser: ArgumentParser) -> ArgumentParser:
16
- parser.add_required("input", nargs="*")
17
- return parser
18
-
19
-
20
- def main(sys_args: list[str] = sys.argv[1:]) -> None:
21
- args = desc_options(ArgumentParser("desc")).parse_args(DescArgs, sys_args)
22
- for path in args.input:
23
- try:
24
- container = bv.open(path)
25
- desc = container.metadata.get("description", None)
26
- except Exception:
27
- desc = None
28
- sys.stdout.write("\nNo description.\n\n" if desc is None else f"\n{desc}\n\n")
29
-
30
-
31
- if __name__ == "__main__":
32
- main()
auto_editor/cmds/info.py DELETED
@@ -1,213 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os.path
4
- import sys
5
- from dataclasses import dataclass, field
6
- from typing import Any, Literal, TypedDict
7
-
8
- from auto_editor.ffwrapper import FileInfo
9
- from auto_editor.json import dump
10
- from auto_editor.make_layers import make_sane_timebase
11
- from auto_editor.utils.func import aspect_ratio
12
- from auto_editor.utils.log import Log
13
- from auto_editor.vanparse import ArgumentParser
14
-
15
-
16
- @dataclass(slots=True)
17
- class InfoArgs:
18
- json: bool = False
19
- help: bool = False
20
- input: list[str] = field(default_factory=list)
21
-
22
-
23
- def info_options(parser: ArgumentParser) -> ArgumentParser:
24
- parser.add_required("input", nargs="*")
25
- parser.add_argument("--json", flag=True, help="Export info in JSON format")
26
- return parser
27
-
28
-
29
- class VideoJson(TypedDict):
30
- codec: str
31
- fps: str
32
- resolution: list[int]
33
- aspect_ratio: list[int]
34
- pixel_aspect_ratio: str
35
- duration: float
36
- pix_fmt: str | None
37
- color_range: int
38
- color_space: int
39
- color_primaries: int
40
- color_transfer: int
41
- timebase: str
42
- bitrate: int
43
- lang: str | None
44
-
45
-
46
- class AudioJson(TypedDict):
47
- codec: str
48
- samplerate: int
49
- layout: str
50
- duration: float
51
- bitrate: int
52
- lang: str | None
53
-
54
-
55
- class SubtitleJson(TypedDict):
56
- codec: str
57
- lang: str | None
58
-
59
-
60
- class ContainerJson(TypedDict):
61
- duration: float
62
- bitrate: int
63
-
64
-
65
- class MediaJson(TypedDict, total=False):
66
- video: list[VideoJson]
67
- audio: list[AudioJson]
68
- subtitle: list[SubtitleJson]
69
- container: ContainerJson
70
- type: Literal["media", "timeline", "unknown"]
71
- recommendedTimebase: str
72
- version: Literal["v1", "v3"]
73
- clips: int
74
-
75
-
76
- def main(sys_args: list[str] = sys.argv[1:]) -> None:
77
- args = info_options(ArgumentParser("info")).parse_args(InfoArgs, sys_args)
78
-
79
- log = Log(quiet=not args.json)
80
-
81
- file_info: dict[str, MediaJson] = {}
82
-
83
- for file in args.input:
84
- if not os.path.isfile(file):
85
- log.error(f"Could not find '{file}'")
86
-
87
- ext = os.path.splitext(file)[1]
88
- if ext in {".v1", ".v3", ".json", ".xml", ".fcpxml", ".mlt"}:
89
- file_info[file] = {"type": "timeline"}
90
- continue
91
-
92
- src = FileInfo.init(file, log)
93
-
94
- if len(src.videos) + len(src.audios) + len(src.subtitles) == 0:
95
- file_info[file] = {"type": "unknown"}
96
- continue
97
-
98
- file_info[file] = {
99
- "type": "media",
100
- "recommendedTimebase": "30/1",
101
- "video": [],
102
- "audio": [],
103
- "subtitle": [],
104
- "container": {
105
- "duration": src.duration,
106
- "bitrate": src.bitrate,
107
- },
108
- }
109
-
110
- if src.videos:
111
- recTb = make_sane_timebase(src.videos[0].fps)
112
- file_info[file]["recommendedTimebase"] = (
113
- f"{recTb.numerator}/{recTb.denominator}"
114
- )
115
-
116
- for v in src.videos:
117
- w, h = v.width, v.height
118
-
119
- vid: VideoJson = {
120
- "codec": v.codec,
121
- "fps": str(v.fps),
122
- "resolution": [w, h],
123
- "aspect_ratio": list(aspect_ratio(w, h)),
124
- "pixel_aspect_ratio": str(v.sar).replace("/", ":"),
125
- "duration": v.duration,
126
- "pix_fmt": v.pix_fmt,
127
- "color_range": v.color_range,
128
- "color_space": v.color_space,
129
- "color_primaries": v.color_primaries,
130
- "color_transfer": v.color_transfer,
131
- "timebase": str(v.time_base),
132
- "bitrate": v.bitrate,
133
- "lang": v.lang,
134
- }
135
- file_info[file]["video"].append(vid)
136
-
137
- for a in src.audios:
138
- aud: AudioJson = {
139
- "codec": a.codec,
140
- "layout": a.layout,
141
- "samplerate": a.samplerate,
142
- "duration": a.duration,
143
- "bitrate": a.bitrate,
144
- "lang": a.lang,
145
- }
146
- file_info[file]["audio"].append(aud)
147
-
148
- for s_stream in src.subtitles:
149
- sub: SubtitleJson = {"codec": s_stream.codec, "lang": s_stream.lang}
150
- file_info[file]["subtitle"].append(sub)
151
-
152
- if args.json:
153
- if sys.platform == "win32":
154
- sys.stdout.reconfigure(encoding="utf-8")
155
- dump(file_info, sys.stdout, indent=4)
156
- return
157
-
158
- def is_null(key: str, val: object) -> bool:
159
- return val is None or (key in {"bitrate", "duration"} and val == 0.0)
160
-
161
- def stream_to_text(text: str, label: str, streams: list[dict[str, Any]]) -> str:
162
- if len(streams) > 0:
163
- text += f" - {label}:\n"
164
-
165
- for s, stream in enumerate(streams):
166
- text += f" - track {s}:\n"
167
- for key, value in stream.items():
168
- if not is_null(key, value):
169
- if isinstance(value, list):
170
- sep = "x" if key == "resolution" else ":"
171
- value = sep.join(f"{x}" for x in value)
172
-
173
- if key in {
174
- "color_range",
175
- "color_space",
176
- "color_transfer",
177
- "color_primaries",
178
- }:
179
- if key == "color_range":
180
- if value == 1:
181
- text += " - color range: 1 (tv)\n"
182
- elif value == 2:
183
- text += " - color range: 2 (pc)\n"
184
- elif value == 1:
185
- text += f" - {key.replace('_', ' ')}: 1 (bt709)\n"
186
- elif value != 2:
187
- text += f" - {key.replace('_', ' ')}: {value}\n"
188
- else:
189
- text += f" - {key.replace('_', ' ')}: {value}\n"
190
- return text
191
-
192
- text = ""
193
- for name, info in file_info.items():
194
- text += f"{name}:\n"
195
-
196
- for label, streams in info.items():
197
- if isinstance(streams, list):
198
- text = stream_to_text(text, label, streams)
199
- continue
200
- elif isinstance(streams, dict):
201
- text += " - container:\n"
202
- for key, value in streams.items():
203
- if value is not None:
204
- text += f" - {key}: {value}\n"
205
- elif label != "type" or streams != "media":
206
- text += f" - {label}: {streams}\n"
207
- text += "\n"
208
-
209
- sys.stdout.write(text)
210
-
211
-
212
- if __name__ == "__main__":
213
- main()