muxtools 0.2.2__tar.gz → 0.3.0__tar.gz

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 (59) hide show
  1. muxtools-0.3.0/.github/workflows/publish.yml +26 -0
  2. muxtools-0.3.0/.gitignore +167 -0
  3. muxtools-0.3.0/.gitmodules +3 -0
  4. muxtools-0.3.0/.vscode/settings.json +10 -0
  5. {muxtools-0.2.2 → muxtools-0.3.0}/PKG-INFO +17 -17
  6. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/__init__.py +10 -2
  7. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/encoders.py +16 -2
  8. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/extractors.py +26 -14
  9. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/tools.py +1 -1
  10. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/cli.py +23 -2
  11. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/functions.py +16 -10
  12. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/main.py +33 -0
  13. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/misc/chapters.py +78 -42
  14. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/muxing/mux.py +2 -0
  15. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/muxing/tmdb.py +30 -19
  16. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/basesub.py +111 -5
  17. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/styles.py +72 -0
  18. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/sub.py +128 -59
  19. muxtools-0.3.0/muxtools/utils/convert.py +178 -0
  20. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/dataclass.py +16 -0
  21. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/download.py +35 -15
  22. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/files.py +46 -5
  23. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/log.py +7 -1
  24. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/parsing.py +18 -8
  25. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/types.py +54 -1
  26. {muxtools-0.2.2 → muxtools-0.3.0}/pyproject.toml +22 -7
  27. muxtools-0.3.0/tests/test_full_merge.py +69 -0
  28. muxtools-0.3.0/tests/test_sub_mismatch_warn.py +64 -0
  29. muxtools-0.3.0/tests/test_sub_shifting.py +61 -0
  30. muxtools-0.3.0/tests/test_tmdb.py +16 -0
  31. muxtools-0.3.0/uv.lock +1160 -0
  32. muxtools-0.2.2/muxtools/utils/convert.py +0 -172
  33. muxtools-0.2.2/muxtools.egg-info/PKG-INFO +0 -47
  34. muxtools-0.2.2/muxtools.egg-info/SOURCES.txt +0 -47
  35. muxtools-0.2.2/muxtools.egg-info/dependency_links.txt +0 -1
  36. muxtools-0.2.2/muxtools.egg-info/entry_points.txt +0 -2
  37. muxtools-0.2.2/muxtools.egg-info/requires.txt +0 -10
  38. muxtools-0.2.2/muxtools.egg-info/top_level.txt +0 -1
  39. muxtools-0.2.2/setup.cfg +0 -4
  40. {muxtools-0.2.2 → muxtools-0.3.0}/LICENSE +0 -0
  41. {muxtools-0.2.2 → muxtools-0.3.0}/README.md +0 -0
  42. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/__main__.py +0 -0
  43. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/__init__.py +0 -0
  44. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/audioutils.py +0 -0
  45. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/memecoders.py +0 -0
  46. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/audio/preprocess.py +0 -0
  47. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/misc/__init__.py +0 -0
  48. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/muxing/__init__.py +0 -0
  49. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/muxing/muxfiles.py +0 -0
  50. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/muxing/tracks.py +0 -0
  51. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/py.typed +0 -0
  52. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/__init__.py +0 -0
  53. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/font.py +0 -0
  54. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/subtitle/subutils.py +0 -0
  55. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/__init__.py +0 -0
  56. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/env.py +0 -0
  57. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/format.py +0 -0
  58. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/glob.py +0 -0
  59. {muxtools-0.2.2 → muxtools-0.3.0}/muxtools/utils/subprogress.py +0 -0
@@ -0,0 +1,26 @@
1
+ name: Publish releases when new tag
2
+ on:
3
+ push:
4
+ tags:
5
+ - v[0-9]+**
6
+
7
+ jobs:
8
+ package_build:
9
+ name: Build and push to PyPI
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Prep Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.11"
18
+
19
+ - name: Build
20
+ run: pipx run build
21
+
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
24
+ with:
25
+ user: __token__
26
+ password: ${{ secrets.AUTH_TOKEN }}
@@ -0,0 +1,167 @@
1
+ # Specific to this package
2
+ _binaries/
3
+ premux/
4
+ _workdir/
5
+ config.ini
6
+ /test.py
7
+
8
+ # Byte-compiled / optimized / DLL files
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+
13
+ # C extensions
14
+ *.so
15
+
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ share/python-wheels/
31
+ *.egg-info/
32
+ .installed.cfg
33
+ *.egg
34
+ MANIFEST
35
+
36
+ # PyInstaller
37
+ # Usually these files are written by a python script from a template
38
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
39
+ *.manifest
40
+ *.spec
41
+
42
+ # Installer logs
43
+ pip-log.txt
44
+ pip-delete-this-directory.txt
45
+
46
+ # Unit test / coverage reports
47
+ htmlcov/
48
+ .tox/
49
+ .nox/
50
+ .coverage
51
+ .coverage.*
52
+ .cache
53
+ nosetests.xml
54
+ coverage.xml
55
+ *.cover
56
+ *.py,cover
57
+ .hypothesis/
58
+ .pytest_cache/
59
+ cover/
60
+
61
+ # Translations
62
+ *.mo
63
+ *.pot
64
+
65
+ # Django stuff:
66
+ *.log
67
+ local_settings.py
68
+ db.sqlite3
69
+ db.sqlite3-journal
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ .pybuilder/
83
+ target/
84
+
85
+ # Jupyter Notebook
86
+ .ipynb_checkpoints
87
+
88
+ # IPython
89
+ profile_default/
90
+ ipython_config.py
91
+
92
+ # pyenv
93
+ # For a library or package, you might want to ignore these files since the code is
94
+ # intended to run in multiple environments; otherwise, check them in:
95
+ # .python-version
96
+
97
+ # pipenv
98
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
99
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
100
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
101
+ # install all needed dependencies.
102
+ #Pipfile.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ #poetry.lock
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ #pdm.lock
114
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
115
+ # in version control.
116
+ # https://pdm.fming.dev/#use-with-ide
117
+ .pdm.toml
118
+
119
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
120
+ __pypackages__/
121
+
122
+ # Celery stuff
123
+ celerybeat-schedule
124
+ celerybeat.pid
125
+
126
+ # SageMath parsed files
127
+ *.sage.py
128
+
129
+ # Environments
130
+ .env
131
+ .venv
132
+ env/
133
+ venv/
134
+ ENV/
135
+ env.bak/
136
+ venv.bak/
137
+
138
+ # Spyder project settings
139
+ .spyderproject
140
+ .spyproject
141
+
142
+ # Rope project settings
143
+ .ropeproject
144
+
145
+ # mkdocs documentation
146
+ /site
147
+
148
+ # mypy
149
+ .mypy_cache/
150
+ .dmypy.json
151
+ dmypy.json
152
+
153
+ # Pyre type checker
154
+ .pyre/
155
+
156
+ # pytype static type analyzer
157
+ .pytype/
158
+
159
+ # Cython debug symbols
160
+ cython_debug/
161
+
162
+ # PyCharm
163
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
164
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
165
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
166
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
167
+ .idea/
@@ -0,0 +1,3 @@
1
+ [submodule "tests/test-data"]
2
+ path = tests/test-data
3
+ url = https://github.com/Vodes/muxtools-test-data.git
@@ -0,0 +1,10 @@
1
+ {
2
+ "[python]": {
3
+ "editor.formatOnType": false,
4
+ "editor.formatOnSave": true,
5
+ "editor.defaultFormatter": "charliermarsh.ruff"
6
+ },
7
+ "ruff.lint.enable": true,
8
+ "ruff.lint.args": ["--config", "./pyproject.toml"],
9
+ "ruff.format.args": ["--config", "./pyproject.toml"]
10
+ }
@@ -1,34 +1,34 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: muxtools
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: A library for various muxing and automation tools for anything and everything fansubbing related
5
- Author-email: Vodes <vodes.imp@gmail.com>
6
- License: MPL 2.0
7
5
  Project-URL: Source Code, https://github.com/Vodes/muxtools
8
- Classifier: Natural Language :: English
6
+ Author-email: Vodes <vodes.imp@gmail.com>
7
+ License-Expression: MPL-2.0
8
+ License-File: LICENSE
9
9
  Classifier: Intended Audience :: Developers
10
10
  Classifier: Intended Audience :: End Users/Desktop
11
- Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
12
+ Classifier: Natural Language :: English
12
13
  Classifier: Operating System :: OS Independent
13
- Classifier: Typing :: Typed
14
+ Classifier: Programming Language :: Python :: 3.10
14
15
  Classifier: Topic :: Multimedia :: Sound/Audio
15
16
  Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
16
17
  Classifier: Topic :: Multimedia :: Video
17
18
  Classifier: Topic :: Multimedia :: Video :: Conversion
18
- Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
19
+ Classifier: Typing :: Typed
19
20
  Requires-Python: >=3.10
20
- Description-Content-Type: text/markdown
21
- License-File: LICENSE
22
- Requires-Dist: requests>=2.31.0
23
- Requires-Dist: fontcollector>=3.0.0
24
21
  Requires-Dist: ass>=0.5.3
25
- Requires-Dist: wget>=3.2
26
- Requires-Dist: py7zr>=0.20.5
22
+ Requires-Dist: fontcollector>=3.0.0
23
+ Requires-Dist: psutil>=5.6.4
24
+ Requires-Dist: pydantic>=2.5.0
27
25
  Requires-Dist: pymediainfo>=6.0.1
28
- Requires-Dist: rich>=13.1.0
29
26
  Requires-Dist: pyparsebluray>=0.1.4
30
- Requires-Dist: pydantic>=2.5.0
31
- Requires-Dist: psutil>=5.6.4
27
+ Requires-Dist: requests>=2.31.0
28
+ Requires-Dist: rich>=13.1.0
29
+ Requires-Dist: videotimestamps>=0.2.0
30
+ Requires-Dist: wget>=3.2
31
+ Description-Content-Type: text/markdown
32
32
 
33
33
  # muxtools
34
34
 
@@ -12,7 +12,7 @@ from .functions import *
12
12
 
13
13
  def entry_point():
14
14
  import sys
15
- from .cli import install_libraries, install_dependencies
15
+ from .cli import install_libraries, install_dependencies, generate_videometa
16
16
 
17
17
  if sys.argv:
18
18
  if sys.argv[-1].lower() in ["libs", "libraries"]:
@@ -21,8 +21,16 @@ def entry_point():
21
21
  elif sys.argv[-1].lower() in ["install", "deps", "dependencies"]:
22
22
  install_dependencies()
23
23
  sys.exit(0)
24
+ else:
25
+ if len(sys.argv) > 1:
26
+ if sys.argv[1].lower() in ["gen-vm", "generate-videometa"]:
27
+ file_in = None if len(sys.argv) < 3 else sys.argv[2]
28
+ file_out = None if len(sys.argv) < 4 else sys.argv[3]
29
+ generate_videometa(file_in, file_out)
30
+ sys.exit(0)
24
31
 
25
32
  error(
26
33
  "No arguments passed.\nYou can use [b]libs[/] or [b]libraries[/] to install/update libraries of qaac and eac3to."
27
- + "\nYou can use [b]install[/], [b]deps[/] or [b]dependencies[/] to install all sorts of executables."
34
+ + "\nYou can use [b]install[/], [b]deps[/] or [b]dependencies[/] to install all sorts of executables.\n\n"
35
+ + "You can also use [b]generate-videometa[/] or [b]gen-vm[/] to generate a VideoMeta file for a video."
28
36
  )
@@ -2,6 +2,7 @@ from collections.abc import Sequence
2
2
  from pydantic.dataclasses import dataclass, Field
3
3
  import subprocess
4
4
  import os
5
+ import re
5
6
 
6
7
  from .tools import Encoder, LosslessEncoder
7
8
  from .preprocess import Preprocessor, Resample, Downmix
@@ -26,6 +27,8 @@ class FLAC(LosslessEncoder):
26
27
  :param compression_level: Any int value from 0 to 8 (Higher = better but slower)
27
28
  :param preprocess: Any amount of preprocessors to run before passing it to the encoder.
28
29
  :param verify: Make the encoder verify each encoded sample while encoding to ensure valid output.
30
+ :param threads: Number of threads to use for encoding. (Requires FLAC 1.5.0)
31
+ `None` will choose whatever your CPU has with a max of 8.
29
32
  :param output: Custom output. Can be a dir or a file.
30
33
  Do not specify an extension unless you know what you're doing.
31
34
  """
@@ -33,12 +36,14 @@ class FLAC(LosslessEncoder):
33
36
  compression_level: int = 8
34
37
  preprocess: Preprocessor | Sequence[Preprocessor] | None = Field(default_factory=Resample)
35
38
  verify: bool = True
39
+ threads: int | None = None
36
40
  output: PathLike | None = None
37
41
 
38
42
  def encode_audio(self, fileIn: AudioFile | PathLike, quiet: bool = True, **kwargs) -> AudioFile:
39
43
  if not isinstance(fileIn, AudioFile):
40
44
  fileIn = AudioFile.from_file(fileIn, self)
41
45
  flac = get_executable("flac")
46
+ version = get_binary_version(flac, r"flac .+? version (\d\.\d+\.\d+)")
42
47
  output = make_output(fileIn.file, "flac", "libflac", self.output)
43
48
  source = ensure_valid_in(fileIn, preprocess=self.preprocess, caller=self, valid_type=ValidInputType.W64_OR_FLAC, supports_pipe=False)
44
49
  info(f"Encoding '{fileIn.file.stem}' to FLAC using libFLAC...", self)
@@ -46,12 +51,21 @@ class FLAC(LosslessEncoder):
46
51
  args = [flac, f"-{self.compression_level}", "-o", str(output)] + self.get_custom_args()
47
52
  if self.verify:
48
53
  args.append("--verify")
54
+
55
+ if self.threads or self.threads is None:
56
+ if re.search(r"1\.[2|3|4]\.\d+?", version):
57
+ warn("Using outdated FLAC encoder that does not support threading!", self)
58
+ else:
59
+ if self.threads is None:
60
+ self.threads = min(os.cpu_count(), 8)
61
+ args.append(f"--threads={self.threads}")
62
+
49
63
  args.append(str(source.file.resolve()) if isinstance(source, AudioFile) else "-")
50
64
 
51
65
  stdin = subprocess.DEVNULL if isinstance(source, AudioFile) else source.stdout
52
66
 
53
67
  if not run_cmd_pb(args, quiet, ProgressBarConfig("Encoding..."), shell=False, stdin=stdin):
54
- tags = version_settings_dict(self.get_mediainfo_settings(args), flac, r"flac .+? version (\d\.\d+\.\d+)", prepend="FLAC")
68
+ tags = dict(ENCODER=f"FLAC {version}", ENCODER_SETTINGS=self.get_mediainfo_settings(args))
55
69
  clean_temp_files()
56
70
  return AudioFile(output, fileIn.container_delay, fileIn.source, tags=tags)
57
71
  else:
@@ -304,7 +318,7 @@ class FDK_AAC(Encoder):
304
318
  exe = get_executable("ffmpeg") if not self.use_binary else get_executable("fdkaac")
305
319
 
306
320
  if os.name == "nt":
307
- warn("It is strongly recommended to use qAAC on windows. See docs.", self, 5)
321
+ warn("It is strongly recommended to use qAAC on windows. See docs.", self, 1)
308
322
  info(f"Encoding '{fileIn.file.stem}' to AAC using libFDK...", self)
309
323
 
310
324
  tags = dict[str, str]()
@@ -14,10 +14,10 @@ from ..utils.log import error, warn, debug, info
14
14
  from ..utils.download import get_executable
15
15
  from ..utils.parsing import parse_audioinfo
16
16
  from ..utils.files import get_absolute_track
17
- from ..utils.types import Trim, PathLike, TrackType
17
+ from ..utils.types import Trim, PathLike, TrackType, TimeSourceT, TimeScaleT, TimeScale
18
18
  from ..utils.env import get_temp_workdir, run_commandline, communicate_stdout
19
19
  from ..utils.subprogress import run_cmd_pb, ProgressBarConfig
20
- from ..utils.convert import frame_to_timedelta, format_timedelta, frame_to_ms
20
+ from ..utils.convert import format_timedelta, resolve_timesource_and_scale, TimeType
21
21
 
22
22
  __all__ = ["Eac3to", "Sox", "FFMpeg", "MkvExtract"]
23
23
 
@@ -63,7 +63,7 @@ class Eac3to(Extractor):
63
63
  info(f"Extracting audio track {self.track} from '{input.stem}'...", self)
64
64
 
65
65
  out = make_output(input, extension, f"extracted_{self.track}", self.output)
66
- code, stdout = communicate_stdout(f'"{eac3to}" "{input}" {track.track_id+1}: "{out}" {self.append}')
66
+ code, stdout = communicate_stdout(f'"{eac3to}" "{input}" {track.track_id + 1}: "{out}" {self.append}')
67
67
  if code == 0:
68
68
  if not out.exists():
69
69
  pattern_str = rf"{re.escape(out.stem)} DELAY.*\.{extension}"
@@ -171,9 +171,11 @@ class FFMpeg(HasExtractor, HasTrimmer):
171
171
  If you're working with lossless files it is strongly recommended to use SoX instead.
172
172
 
173
173
  :param trim: Can be a single trim or a sequence of trims.
174
- :param trim_use_ms: Will use milliseconds instead of frame numbers
175
- :param fps: Fps fraction that will be used for the conversion. Also accepts a timecode (v2) file.
176
174
  :param preserve_delay: Will preserve existing container delay
175
+ :param trim_use_ms: Will use milliseconds instead of frame numbers
176
+ :param timesource: The source of timestamps/timecodes. For details check the docstring on the type.
177
+ :param timescale: Unit of time (in seconds) in terms of which frame timestamps are represented.\n
178
+ For details check the docstring on the type.
177
179
  :param num_frames: Total number of frames used for calculations
178
180
  :param output: Custom output. Can be a dir or a file.
179
181
  Do not specify an extension unless you know what you're doing.
@@ -182,7 +184,8 @@ class FFMpeg(HasExtractor, HasTrimmer):
182
184
  trim: Trim | list[Trim] | None = None
183
185
  preserve_delay: bool = False
184
186
  trim_use_ms: bool = False
185
- fps: Fraction | PathLike = Fraction(24000, 1001)
187
+ timesource: TimeSourceT = Fraction(24000, 1001)
188
+ timescale: TimeScaleT = TimeScale.MKV
186
189
  num_frames: int = 0
187
190
  output: PathLike | None = None
188
191
 
@@ -208,18 +211,21 @@ class FFMpeg(HasExtractor, HasTrimmer):
208
211
  if self.trim_use_ms:
209
212
  arg += f" -ss {format_timedelta(timedelta(milliseconds=trim[0]))}"
210
213
  else:
211
- arg += f" -ss {format_timedelta(frame_to_timedelta(trim[0], self.fps))}"
214
+ millis = self.resolved_ts.frame_to_time(trim[0], TimeType.EXACT, 3)
215
+ arg += f" -ss {format_timedelta(timedelta(milliseconds=millis))}"
212
216
  if trim[1] is not None and trim[1] != 0:
213
217
  end_frame = self.num_frames + trim[1] if trim[1] < 0 else trim[1]
214
218
  if self.trim_use_ms:
215
219
  arg += f" -to {format_timedelta(timedelta(milliseconds=trim[1]))}"
216
220
  else:
217
- arg += f" -to {format_timedelta(frame_to_timedelta(end_frame, self.fps))}"
221
+ millis = self.resolved_ts.frame_to_time(end_frame, TimeType.EXACT, 3)
222
+ arg += f" -to {format_timedelta(timedelta(milliseconds=millis))}"
218
223
  return arg
219
224
 
220
225
  def trim_audio(self, input: AudioFile, quiet: bool = True) -> AudioFile:
221
226
  if not isinstance(input, AudioFile):
222
227
  input = AudioFile.from_file(input, self)
228
+ self.resolved_ts = resolve_timesource_and_scale(self.timesource, self.timescale, caller=self)
223
229
  self.trim = sanitize_trims(self.trim, self.num_frames, not self.trim_use_ms, caller=self)
224
230
  minfo = input.get_mediainfo()
225
231
  form = format_from_track(minfo)
@@ -248,7 +254,7 @@ class FFMpeg(HasExtractor, HasTrimmer):
248
254
  args.append(str(out.resolve()))
249
255
  if not run_commandline(args, quiet):
250
256
  if tr[0] and lossy:
251
- ms = tr[0] if self.trim_use_ms else frame_to_ms(tr[0], self.fps)
257
+ ms = tr[0] if self.trim_use_ms else self.resolved_ts.frame_to_time(tr[0], TimeType.EXACT, 3)
252
258
  cont_delay = self._calc_delay(ms, ainfo.num_samples(), getattr(minfo, "sampling_rate", 48000))
253
259
  debug(f"Additional delay of {cont_delay} ms will be applied to fix remaining sync", self)
254
260
  if self.preserve_delay:
@@ -269,8 +275,10 @@ class FFMpeg(HasExtractor, HasTrimmer):
269
275
  if lossy:
270
276
  nArgs[2:1] = splitcommand(self._targs(tr))
271
277
  if first:
272
- cont_delay = self._calc_delay(ms, ainfo.num_samples(), getattr(minfo, "sampling_rate", 48000))
273
- debug(f"Additional delay of {cont_delay} ms will be applied to fix remaining sync", self)
278
+ if tr[0]:
279
+ ms = tr[0] if self.trim_use_ms else self.resolved_ts.frame_to_time(tr[0], TimeType.EXACT, 3)
280
+ cont_delay = self._calc_delay(ms, ainfo.num_samples(), getattr(minfo, "sampling_rate", 48000))
281
+ debug(f"Additional delay of {cont_delay} ms will be applied to fix remaining sync", self)
274
282
  first = False
275
283
  else:
276
284
  nArgs.extend(splitcommand(self._targs(tr)))
@@ -366,7 +374,9 @@ class Sox(Trimmer):
366
374
  :param trim: List of Trims or a single Trim, which is a Tuple of two frame numbers or milliseconds
367
375
  :param preserve_delay: Keeps existing container delay if True
368
376
  :param trim_use_ms: Will use milliseconds instead of frame numbers
369
- :param fps: The fps fraction used for the calculations. Also accepts a timecode (v2) file.
377
+ :param timesource: The source of timestamps/timecodes. For details check the docstring on the type.
378
+ :param timescale: Unit of time (in seconds) in terms of which frame timestamps are represented.\n
379
+ For details check the docstring on the type.
370
380
  :param num_frames: Total number of frames used for calculations
371
381
  :param output: Custom output. Can be a dir or a file.
372
382
  Do not specify an extension unless you know what you're doing.
@@ -375,7 +385,8 @@ class Sox(Trimmer):
375
385
  trim: Trim | list[Trim] | None = None
376
386
  preserve_delay: bool = False
377
387
  trim_use_ms: bool = False
378
- fps: Fraction | PathLike = Fraction(24000, 1001)
388
+ timesource: TimeSourceT = Fraction(24000, 1001)
389
+ timescale: TimeScaleT = TimeScale.MKV
379
390
  num_frames: int = 0
380
391
  output: PathLike | None = None
381
392
 
@@ -386,7 +397,7 @@ class Sox(Trimmer):
386
397
  if self.trim_use_ms:
387
398
  return abs(val) / 1000
388
399
  else:
389
- return frame_to_timedelta(abs(val), self.fps).total_seconds()
400
+ return self.resolved_ts.frame_to_time(abs(val), TimeType.EXACT).__float__()
390
401
 
391
402
  def trim_audio(self, input: AudioFile, quiet: bool = True) -> AudioFile:
392
403
  import sox
@@ -394,6 +405,7 @@ class Sox(Trimmer):
394
405
  if not isinstance(input, AudioFile):
395
406
  input = AudioFile.from_file(input, self)
396
407
  out = make_output(input.file, "flac", "trimmed", self.output)
408
+ self.resolved_ts = resolve_timesource_and_scale(self.timesource, self.timescale, caller=self)
397
409
  self.trim = sanitize_trims(self.trim, self.num_frames, not self.trim_use_ms, allow_negative_start=True, caller=self)
398
410
  source = ensure_valid_in(input, caller=self, supports_pipe=False)
399
411
 
@@ -15,7 +15,7 @@ class HasTrimmer(ABC):
15
15
 
16
16
  class Extractor(CLIKwargs):
17
17
  _no_print = False
18
-
18
+
19
19
  @abstractmethod
20
20
  def extract_audio(self, input: PathLike, quiet: bool = True) -> AudioFile:
21
21
  pass
@@ -1,15 +1,17 @@
1
1
  import os
2
2
  import re
3
+ import sys
3
4
  import wget
4
5
  import shlex
5
6
  import shutil
6
7
  import subprocess
7
8
  from pathlib import Path
8
9
 
9
- from .utils.log import info, warn
10
+ from .utils.log import info, warn, error
10
11
  from .utils.download import unpack_all
11
12
  from .utils.env import get_temp_workdir
12
- from .utils.files import clean_temp_files
13
+ from .utils.files import clean_temp_files, ensure_path_exists, ensure_path, is_video_file
14
+ from .utils.convert import get_timemeta_from_video
13
15
 
14
16
  CONF = "([green bold]Y[/] | [red]n[/])"
15
17
 
@@ -212,3 +214,22 @@ def _run_powershell(args: str | list[str], quiet: bool = False) -> int:
212
214
  p.communicate()
213
215
  print("")
214
216
  return p.returncode
217
+
218
+
219
+ def generate_videometa(file: str | None = None, output: str | None = None):
220
+ if not file:
221
+ error("You have to pass an input video file!", None)
222
+ sys.exit(1)
223
+
224
+ in_path = ensure_path_exists(file, None)
225
+ if not is_video_file(in_path):
226
+ error(f'"{in_path.name}" is not a video file!', None)
227
+ sys.exit(1)
228
+
229
+ if not output:
230
+ info("Generating VideoMeta file in your current work directory.", None)
231
+ output = ensure_path(os.getcwd(), None) / f"{in_path.stem}_meta.json"
232
+ else:
233
+ output = ensure_path(output, None)
234
+
235
+ get_timemeta_from_video(in_path, output, None)
@@ -1,8 +1,8 @@
1
1
  from fractions import Fraction
2
2
  from datetime import timedelta
3
- from collections.abc import Sequence
4
3
 
5
4
  from .utils.log import warn, error, info, danger
5
+ from .utils.types import TimeScaleT, TimeScale, TimeSourceT
6
6
  from .muxing.muxfiles import AudioFile
7
7
  from .audio.audioutils import is_fancy_codec
8
8
  from .audio.encoders import Opus
@@ -19,9 +19,10 @@ def do_audio(
19
19
  fileIn: PathLike | list[PathLike],
20
20
  track: int = 0,
21
21
  trims: Trim | list[Trim] | None = None,
22
- fps: Fraction | PathLike | Sequence[int] = Fraction(24000, 1001),
22
+ timesource: TimeSourceT = Fraction(24000, 1001),
23
+ timescale: TimeScaleT = TimeScale.MKV,
23
24
  num_frames: int = 0,
24
- extractor: Extractor = FFMpeg.Extractor(),
25
+ extractor: Extractor | None = FFMpeg.Extractor(),
25
26
  trimmer: Trimmer | None = AutoTrimmer(),
26
27
  encoder: Encoder | None = AutoEncoder(),
27
28
  quiet: bool = True,
@@ -33,14 +34,16 @@ def do_audio(
33
34
  :param fileIn: Input file
34
35
  :param track: Audio track number
35
36
  :param trims: Frame ranges to trim and/or combine, e.g. (24, -24) or [(24, 500), (700, 900)]
36
- :param fps: FPS Fraction used for the conversion to time. Also accepts a timecode (v2) file.
37
+ :param timesource: The source of timestamps/timecodes. For details check the docstring on the type.
38
+ :param timescale: Unit of time (in seconds) in terms of which frame timestamps are represented.\n
39
+ For details check the docstring on the type.
37
40
  :param num_frames: Total number of frames, used for negative numbers in trims
38
41
  :param extractor: Tool used to extract the audio
39
42
  :param trimmer: Tool used to trim the audio
40
43
  AutoTrimmer means it will choose ffmpeg for lossy and Sox for lossless
41
44
 
42
45
  :param encoder: Tool used to encode the audio
43
- AutoEncoder means it won't reencode lossy and choose opus otherwise
46
+ AutoEncoder means it won't reencode lossy and choose opus otherwise.
44
47
 
45
48
  :param quiet: Whether the tool output should be visible
46
49
  :param output: Custom output file or directory, extensions will be automatically added
@@ -77,6 +80,8 @@ def do_audio(
77
80
  audio = FFMpeg.Concat(extracted).concat_audio()
78
81
  else:
79
82
  audio = extractor.extract_audio(fileIn, quiet)
83
+ else:
84
+ audio = ensure_path_exists(fileIn, do_audio)
80
85
 
81
86
  if not isinstance(audio, AudioFile):
82
87
  audio = AudioFile.from_file(audio, do_audio)
@@ -89,22 +94,23 @@ def do_audio(
89
94
  else:
90
95
  trimmer = Sox()
91
96
 
97
+ mediainfo = audio.get_mediainfo()
98
+
92
99
  if isinstance(encoder, AutoEncoder):
93
100
  if lossy:
94
101
  encoder = None
95
- elif is_fancy_codec(audio.get_mediainfo()):
102
+ elif is_fancy_codec(mediainfo):
96
103
  encoder = None
97
104
  warn("Audio will not be reencoded due to having Atmos or special DTS features.", do_audio, 2)
98
105
  else:
99
106
  encoder = Opus()
100
107
 
101
108
  if trimmer and trims:
102
- if not isinstance(fps, str) and isinstance(fps, Sequence):
103
- fps = Fraction(*fps)
104
109
  setattr(trimmer, "trim", trims)
105
- setattr(trimmer, "fps", fps)
110
+ setattr(trimmer, "timesource", timesource)
111
+ setattr(trimmer, "timescale", timescale)
106
112
  setattr(trimmer, "num_frames", num_frames)
107
- if not extractor and not encoder:
113
+ if not encoder:
108
114
  setattr(trimmer, "output", output)
109
115
  trimmed = trimmer.trim_audio(audio, quiet)
110
116
  ensure_path(audio.file, do_audio).unlink(missing_ok=True)