parse-avidemux-project 0.1.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.
- parse_avidemux_project-0.1.0/LICENSE +21 -0
- parse_avidemux_project-0.1.0/PKG-INFO +85 -0
- parse_avidemux_project-0.1.0/README.md +70 -0
- parse_avidemux_project-0.1.0/pyproject.toml +35 -0
- parse_avidemux_project-0.1.0/setup.cfg +4 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project/__init__.py +11 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project/cli.py +38 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project/models.py +44 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project/parser.py +163 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/PKG-INFO +85 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/SOURCES.txt +14 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/dependency_links.txt +1 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/entry_points.txt +2 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/requires.txt +3 -0
- parse_avidemux_project-0.1.0/src/parse_avidemux_project.egg-info/top_level.txt +1 -0
- parse_avidemux_project-0.1.0/tests/test_parser.py +231 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ferris Luxor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parse-avidemux-project
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parser for Avidemux TinyPY project scripts
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ferrous-lux/parse_avidemux_project
|
|
7
|
+
Project-URL: Source, https://github.com/ferrous-lux/parse_avidemux_project
|
|
8
|
+
Project-URL: BugTracker, https://github.com/ferrous-lux/parse_avidemux_project/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# parse-avidemux-project
|
|
17
|
+
|
|
18
|
+
Standalone Python library for parsing [Avidemux](https://avidemux.sourceforge.net/) TinyPY project scripts into structured data (video path, segments, filters, audio tracks, etc.). No ffmpeg, ccextractor, or Avidemux CLI required.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install parse-avidemux-project
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## CLI Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
parse-avidemux-project input.py -o output.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Omitting `-o` prints the JSON to stdout.
|
|
33
|
+
|
|
34
|
+
## Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from parse_avidemux_project import (
|
|
38
|
+
AvidemuxProject,
|
|
39
|
+
Segment,
|
|
40
|
+
AudioTrack,
|
|
41
|
+
parse_project,
|
|
42
|
+
parse_project_file,
|
|
43
|
+
us_to_iso_time,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Parse a file
|
|
47
|
+
project: AvidemuxProject = parse_project_file("project.py")
|
|
48
|
+
|
|
49
|
+
# Or parse a string
|
|
50
|
+
project = parse_project('''
|
|
51
|
+
adm = Avidemux()
|
|
52
|
+
if not adm.loadVideo("/path/to/video.mkv"):
|
|
53
|
+
raise
|
|
54
|
+
adm.addSegment(0, 1000000, 5000000)
|
|
55
|
+
adm.markerA = 0
|
|
56
|
+
adm.markerB = 6000000
|
|
57
|
+
''')
|
|
58
|
+
|
|
59
|
+
print(project.video_file) # /path/to/video.mkv
|
|
60
|
+
print(project.segments) # [Segment(start=1000000, duration=5000000)]
|
|
61
|
+
print(project.segments[0].end) # 6000000 (computed property)
|
|
62
|
+
|
|
63
|
+
# Convert to dict for JSON serialization
|
|
64
|
+
print(project.to_dict())
|
|
65
|
+
|
|
66
|
+
# Microsecond to ISO time conversion
|
|
67
|
+
print(us_to_iso_time(3661000000)) # 01:01:01.000
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Data model
|
|
71
|
+
|
|
72
|
+
| Attribute | Type | Description |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `video_file` | `str \| None` | Path to the source video |
|
|
75
|
+
| `segments` | `list[Segment]` | List of cut segments (each has `start`, `duration`, and `end` property) |
|
|
76
|
+
| `marker_a`, `marker_b` | `int \| None` | A/B markers in microseconds |
|
|
77
|
+
| `video_codec` | `str \| None` | Output video codec |
|
|
78
|
+
| `container` | `str \| None` | Output container format |
|
|
79
|
+
| `container_options` | `dict[str, str]` | Container options |
|
|
80
|
+
| `audio_tracks` | `list[AudioTrack]` | Audio track configs (each has `index`, `language`, `codec`) |
|
|
81
|
+
| `hdr_config` | `tuple[int, ...]` | HDR parameters |
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# parse-avidemux-project
|
|
2
|
+
|
|
3
|
+
Standalone Python library for parsing [Avidemux](https://avidemux.sourceforge.net/) TinyPY project scripts into structured data (video path, segments, filters, audio tracks, etc.). No ffmpeg, ccextractor, or Avidemux CLI required.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install parse-avidemux-project
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
parse-avidemux-project input.py -o output.json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Omitting `-o` prints the JSON to stdout.
|
|
18
|
+
|
|
19
|
+
## Python API
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from parse_avidemux_project import (
|
|
23
|
+
AvidemuxProject,
|
|
24
|
+
Segment,
|
|
25
|
+
AudioTrack,
|
|
26
|
+
parse_project,
|
|
27
|
+
parse_project_file,
|
|
28
|
+
us_to_iso_time,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Parse a file
|
|
32
|
+
project: AvidemuxProject = parse_project_file("project.py")
|
|
33
|
+
|
|
34
|
+
# Or parse a string
|
|
35
|
+
project = parse_project('''
|
|
36
|
+
adm = Avidemux()
|
|
37
|
+
if not adm.loadVideo("/path/to/video.mkv"):
|
|
38
|
+
raise
|
|
39
|
+
adm.addSegment(0, 1000000, 5000000)
|
|
40
|
+
adm.markerA = 0
|
|
41
|
+
adm.markerB = 6000000
|
|
42
|
+
''')
|
|
43
|
+
|
|
44
|
+
print(project.video_file) # /path/to/video.mkv
|
|
45
|
+
print(project.segments) # [Segment(start=1000000, duration=5000000)]
|
|
46
|
+
print(project.segments[0].end) # 6000000 (computed property)
|
|
47
|
+
|
|
48
|
+
# Convert to dict for JSON serialization
|
|
49
|
+
print(project.to_dict())
|
|
50
|
+
|
|
51
|
+
# Microsecond to ISO time conversion
|
|
52
|
+
print(us_to_iso_time(3661000000)) # 01:01:01.000
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Data model
|
|
56
|
+
|
|
57
|
+
| Attribute | Type | Description |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `video_file` | `str \| None` | Path to the source video |
|
|
60
|
+
| `segments` | `list[Segment]` | List of cut segments (each has `start`, `duration`, and `end` property) |
|
|
61
|
+
| `marker_a`, `marker_b` | `int \| None` | A/B markers in microseconds |
|
|
62
|
+
| `video_codec` | `str \| None` | Output video codec |
|
|
63
|
+
| `container` | `str \| None` | Output container format |
|
|
64
|
+
| `container_options` | `dict[str, str]` | Container options |
|
|
65
|
+
| `audio_tracks` | `list[AudioTrack]` | Audio track configs (each has `index`, `language`, `codec`) |
|
|
66
|
+
| `hdr_config` | `tuple[int, ...]` | HDR parameters |
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parse-avidemux-project"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Parser for Avidemux TinyPY project scripts"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/ferrous-lux/parse_avidemux_project"
|
|
16
|
+
Source = "https://github.com/ferrous-lux/parse_avidemux_project"
|
|
17
|
+
BugTracker = "https://github.com/ferrous-lux/parse_avidemux_project/issues"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
parse-avidemux-project = "parse_avidemux_project.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
exclude = ["tests/fixtures"]
|
|
30
|
+
|
|
31
|
+
[tool.mypy]
|
|
32
|
+
exclude = ["tests/fixtures/"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .models import AudioTrack, AvidemuxProject, Segment
|
|
2
|
+
from .parser import parse_project, parse_project_file, us_to_iso_time
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AudioTrack",
|
|
6
|
+
"AvidemuxProject",
|
|
7
|
+
"Segment",
|
|
8
|
+
"parse_project",
|
|
9
|
+
"parse_project_file",
|
|
10
|
+
"us_to_iso_time",
|
|
11
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import parse_project_file
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description="Parse an Avidemux TinyPY project file to JSON."
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument("input", help="Path to the Avidemux project file (.py)")
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"-o", "--output",
|
|
15
|
+
help="Path to write JSON output (default: stdout)",
|
|
16
|
+
)
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
project = parse_project_file(args.input)
|
|
21
|
+
except FileNotFoundError as e:
|
|
22
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
except ValueError as e:
|
|
25
|
+
print(f"Parse error: {e}", file=sys.stderr)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
output = json.dumps(project.to_dict(), indent=4)
|
|
29
|
+
|
|
30
|
+
if args.output:
|
|
31
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
32
|
+
f.write(output)
|
|
33
|
+
else:
|
|
34
|
+
print(output)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, asdict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Segment:
|
|
6
|
+
start: int
|
|
7
|
+
duration: int
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def end(self) -> int:
|
|
11
|
+
return self.start + self.duration
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AudioTrack:
|
|
16
|
+
index: int
|
|
17
|
+
language: str | None = None
|
|
18
|
+
codec: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AvidemuxProject:
|
|
23
|
+
video_file: str | None = None
|
|
24
|
+
segments: list[Segment] = field(default_factory=list)
|
|
25
|
+
marker_a: int | None = None
|
|
26
|
+
marker_b: int | None = None
|
|
27
|
+
video_codec: str | None = None
|
|
28
|
+
container: str | None = None
|
|
29
|
+
container_options: dict[str, str] = field(default_factory=dict)
|
|
30
|
+
audio_tracks: list[AudioTrack] = field(default_factory=list)
|
|
31
|
+
hdr_config: tuple[int, ...] = field(default_factory=tuple)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"video_file": self.video_file,
|
|
36
|
+
"segments": [asdict(s) | {"end": s.end} for s in self.segments],
|
|
37
|
+
"marker_a": self.marker_a,
|
|
38
|
+
"marker_b": self.marker_b,
|
|
39
|
+
"video_codec": self.video_codec,
|
|
40
|
+
"container": self.container,
|
|
41
|
+
"container_options": self.container_options,
|
|
42
|
+
"audio_tracks": [asdict(t) for t in self.audio_tracks],
|
|
43
|
+
"hdr_config": list(self.hdr_config),
|
|
44
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .models import AudioTrack, AvidemuxProject, Segment
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_project(content: str) -> AvidemuxProject:
|
|
7
|
+
"""
|
|
8
|
+
Parse an Avidemux TinyPY script into a structured AvidemuxProject object.
|
|
9
|
+
|
|
10
|
+
Parameters:
|
|
11
|
+
content (str): The full text of an Avidemux TinyPY project file.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
AvidemuxProject: Structured representation of the project.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ValueError: If the content cannot be parsed as a valid Avidemux project.
|
|
18
|
+
"""
|
|
19
|
+
video_file = _parse_video_file(content)
|
|
20
|
+
segments = _parse_segments(content)
|
|
21
|
+
|
|
22
|
+
if video_file is None and not segments:
|
|
23
|
+
raise ValueError("No Avidemux project data found in content")
|
|
24
|
+
|
|
25
|
+
return AvidemuxProject(
|
|
26
|
+
video_file=video_file,
|
|
27
|
+
segments=segments,
|
|
28
|
+
marker_a=_parse_marker(content, "A"),
|
|
29
|
+
marker_b=_parse_marker(content, "B"),
|
|
30
|
+
video_codec=_parse_video_codec(content),
|
|
31
|
+
container=_parse_container(content),
|
|
32
|
+
container_options=_parse_container_options(content),
|
|
33
|
+
audio_tracks=_parse_audio_tracks(content),
|
|
34
|
+
hdr_config=_parse_hdr_config(content),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_project_file(path: str) -> AvidemuxProject:
|
|
39
|
+
"""
|
|
40
|
+
Read and parse an Avidemux TinyPY project file.
|
|
41
|
+
|
|
42
|
+
Parameters:
|
|
43
|
+
path (str): Filesystem path to a TinyPY project file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
AvidemuxProject: Structured representation of the project.
|
|
47
|
+
"""
|
|
48
|
+
with open(path, encoding="utf-8") as f:
|
|
49
|
+
return parse_project(f.read())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def us_to_iso_time(us: int) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Convert microseconds to the format HH:MM:SS.mmm.
|
|
55
|
+
|
|
56
|
+
Parameters:
|
|
57
|
+
us (int): Microseconds to convert.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
str: Formatted time string.
|
|
61
|
+
"""
|
|
62
|
+
ms = int(us) // 1000
|
|
63
|
+
hours = ms // 3600000
|
|
64
|
+
minutes = (ms % 3600000) // 60000
|
|
65
|
+
seconds = (ms % 60000) // 1000
|
|
66
|
+
milliseconds = ms % 1000
|
|
67
|
+
return f"{hours:02}:{minutes:02}:{seconds:02}.{milliseconds:03}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_PATTERN_VIDEO = re.compile(
|
|
71
|
+
r"adm = Avidemux\(\)\s*\n"
|
|
72
|
+
r'if not adm\.loadVideo\("(.+?)"\):\s*raise'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_PATTERN_SEGMENT = re.compile(r"adm\.addSegment\(0, (\d+), (\d+)\)")
|
|
76
|
+
|
|
77
|
+
_PATTERN_MARKER = re.compile(r"adm\.marker([AB])\s*=\s*(\d+)")
|
|
78
|
+
|
|
79
|
+
_PATTERN_VIDEO_CODEC = re.compile(r'adm\.videoCodec\("(.+?)"\)')
|
|
80
|
+
|
|
81
|
+
_PATTERN_CONTAINER = re.compile(r"adm\.setContainer\(([^)]+)\)")
|
|
82
|
+
|
|
83
|
+
_PATTERN_HDR_CONFIG = re.compile(r"adm\.setHDRConfig\(([^)]+)\)")
|
|
84
|
+
|
|
85
|
+
_PATTERN_SOURCE_LANG = re.compile(r'adm\.setSourceTrackLanguage\((\d+),"(.+?)"\)')
|
|
86
|
+
|
|
87
|
+
_PATTERN_AUDIO_ADD = re.compile(r"adm\.audioAddTrack\((\d+)\)")
|
|
88
|
+
|
|
89
|
+
_PATTERN_AUDIO_CODEC = re.compile(r'adm\.audioCodec\((\d+), "(.+?)"\)')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_video_file(content: str) -> str | None:
|
|
93
|
+
match = _PATTERN_VIDEO.search(content)
|
|
94
|
+
return match.group(1) if match else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_segments(content: str) -> list[Segment]:
|
|
98
|
+
matches = _PATTERN_SEGMENT.findall(content)
|
|
99
|
+
return [Segment(start=int(start), duration=int(duration)) for start, duration in matches]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_marker(content: str, marker: str) -> int | None:
|
|
103
|
+
for m, val in _PATTERN_MARKER.findall(content):
|
|
104
|
+
if m == marker:
|
|
105
|
+
return int(val)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_video_codec(content: str) -> str | None:
|
|
110
|
+
match = _PATTERN_VIDEO_CODEC.search(content)
|
|
111
|
+
return match.group(1) if match else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_container(content: str) -> str | None:
|
|
115
|
+
match = _PATTERN_CONTAINER.search(content)
|
|
116
|
+
if not match:
|
|
117
|
+
return None
|
|
118
|
+
parts = re.findall(r'"([^"]*)"', match.group(1))
|
|
119
|
+
return parts[0] if parts else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_container_options(content: str) -> dict[str, str]:
|
|
123
|
+
match = _PATTERN_CONTAINER.search(content)
|
|
124
|
+
if not match:
|
|
125
|
+
return {}
|
|
126
|
+
parts = re.findall(r'"([^"]*)"', match.group(1))
|
|
127
|
+
options = {}
|
|
128
|
+
for part in parts[1:]:
|
|
129
|
+
if "=" in part:
|
|
130
|
+
key, value = part.split("=", 1)
|
|
131
|
+
options[key] = value
|
|
132
|
+
return options
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_hdr_config(content: str) -> tuple[int, ...]:
|
|
136
|
+
match = _PATTERN_HDR_CONFIG.search(content)
|
|
137
|
+
if not match:
|
|
138
|
+
return ()
|
|
139
|
+
return tuple(int(x.strip()) for x in match.group(1).split(","))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_audio_tracks(content: str) -> list[AudioTrack]:
|
|
143
|
+
source_languages = {
|
|
144
|
+
int(index): lang
|
|
145
|
+
for index, lang in _PATTERN_SOURCE_LANG.findall(content)
|
|
146
|
+
}
|
|
147
|
+
codecs = {
|
|
148
|
+
int(index): codec
|
|
149
|
+
for index, codec in _PATTERN_AUDIO_CODEC.findall(content)
|
|
150
|
+
}
|
|
151
|
+
track_indices = sorted({
|
|
152
|
+
int(index)
|
|
153
|
+
for index in _PATTERN_AUDIO_ADD.findall(content)
|
|
154
|
+
}.union(codecs.keys()))
|
|
155
|
+
|
|
156
|
+
tracks = []
|
|
157
|
+
for idx in track_indices:
|
|
158
|
+
tracks.append(AudioTrack(
|
|
159
|
+
index=idx,
|
|
160
|
+
language=source_languages.get(idx),
|
|
161
|
+
codec=codecs.get(idx),
|
|
162
|
+
))
|
|
163
|
+
return tracks
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parse-avidemux-project
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parser for Avidemux TinyPY project scripts
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ferrous-lux/parse_avidemux_project
|
|
7
|
+
Project-URL: Source, https://github.com/ferrous-lux/parse_avidemux_project
|
|
8
|
+
Project-URL: BugTracker, https://github.com/ferrous-lux/parse_avidemux_project/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# parse-avidemux-project
|
|
17
|
+
|
|
18
|
+
Standalone Python library for parsing [Avidemux](https://avidemux.sourceforge.net/) TinyPY project scripts into structured data (video path, segments, filters, audio tracks, etc.). No ffmpeg, ccextractor, or Avidemux CLI required.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install parse-avidemux-project
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## CLI Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
parse-avidemux-project input.py -o output.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Omitting `-o` prints the JSON to stdout.
|
|
33
|
+
|
|
34
|
+
## Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from parse_avidemux_project import (
|
|
38
|
+
AvidemuxProject,
|
|
39
|
+
Segment,
|
|
40
|
+
AudioTrack,
|
|
41
|
+
parse_project,
|
|
42
|
+
parse_project_file,
|
|
43
|
+
us_to_iso_time,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Parse a file
|
|
47
|
+
project: AvidemuxProject = parse_project_file("project.py")
|
|
48
|
+
|
|
49
|
+
# Or parse a string
|
|
50
|
+
project = parse_project('''
|
|
51
|
+
adm = Avidemux()
|
|
52
|
+
if not adm.loadVideo("/path/to/video.mkv"):
|
|
53
|
+
raise
|
|
54
|
+
adm.addSegment(0, 1000000, 5000000)
|
|
55
|
+
adm.markerA = 0
|
|
56
|
+
adm.markerB = 6000000
|
|
57
|
+
''')
|
|
58
|
+
|
|
59
|
+
print(project.video_file) # /path/to/video.mkv
|
|
60
|
+
print(project.segments) # [Segment(start=1000000, duration=5000000)]
|
|
61
|
+
print(project.segments[0].end) # 6000000 (computed property)
|
|
62
|
+
|
|
63
|
+
# Convert to dict for JSON serialization
|
|
64
|
+
print(project.to_dict())
|
|
65
|
+
|
|
66
|
+
# Microsecond to ISO time conversion
|
|
67
|
+
print(us_to_iso_time(3661000000)) # 01:01:01.000
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Data model
|
|
71
|
+
|
|
72
|
+
| Attribute | Type | Description |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `video_file` | `str \| None` | Path to the source video |
|
|
75
|
+
| `segments` | `list[Segment]` | List of cut segments (each has `start`, `duration`, and `end` property) |
|
|
76
|
+
| `marker_a`, `marker_b` | `int \| None` | A/B markers in microseconds |
|
|
77
|
+
| `video_codec` | `str \| None` | Output video codec |
|
|
78
|
+
| `container` | `str \| None` | Output container format |
|
|
79
|
+
| `container_options` | `dict[str, str]` | Container options |
|
|
80
|
+
| `audio_tracks` | `list[AudioTrack]` | Audio track configs (each has `index`, `language`, `codec`) |
|
|
81
|
+
| `hdr_config` | `tuple[int, ...]` | HDR parameters |
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/parse_avidemux_project/__init__.py
|
|
5
|
+
src/parse_avidemux_project/cli.py
|
|
6
|
+
src/parse_avidemux_project/models.py
|
|
7
|
+
src/parse_avidemux_project/parser.py
|
|
8
|
+
src/parse_avidemux_project.egg-info/PKG-INFO
|
|
9
|
+
src/parse_avidemux_project.egg-info/SOURCES.txt
|
|
10
|
+
src/parse_avidemux_project.egg-info/dependency_links.txt
|
|
11
|
+
src/parse_avidemux_project.egg-info/entry_points.txt
|
|
12
|
+
src/parse_avidemux_project.egg-info/requires.txt
|
|
13
|
+
src/parse_avidemux_project.egg-info/top_level.txt
|
|
14
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parse_avidemux_project
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from parse_avidemux_project import (
|
|
6
|
+
AudioTrack,
|
|
7
|
+
AvidemuxProject,
|
|
8
|
+
Segment,
|
|
9
|
+
parse_project,
|
|
10
|
+
parse_project_file,
|
|
11
|
+
us_to_iso_time,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_project_file_adm1():
|
|
18
|
+
project = parse_project_file(str(FIXTURES / "test-adm.py"))
|
|
19
|
+
assert project.video_file == "/tmp/test.mkv"
|
|
20
|
+
assert len(project.segments) == 5
|
|
21
|
+
assert project.marker_a == 0
|
|
22
|
+
assert project.marker_b == 2597779000
|
|
23
|
+
assert project.video_codec == "Copy"
|
|
24
|
+
assert project.container == "MKV"
|
|
25
|
+
assert len(project.container_options) == 8
|
|
26
|
+
assert project.container_options["forceAspectRatio"] == "False"
|
|
27
|
+
assert project.container_options["displayWidth"] == "1280"
|
|
28
|
+
assert project.container_options["colMatrixCoeff"] == "2"
|
|
29
|
+
assert len(project.audio_tracks) == 2
|
|
30
|
+
assert project.audio_tracks[0] == AudioTrack(index=0, language="eng", codec="copy")
|
|
31
|
+
assert project.audio_tracks[1] == AudioTrack(index=1, language="spa", codec="copy")
|
|
32
|
+
assert project.hdr_config == (1, 1, 1, 1, 0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_parse_project_file_adm2():
|
|
36
|
+
project = parse_project_file(str(FIXTURES / "test2-adm.py"))
|
|
37
|
+
assert project.video_file == "/tmp/test-video2.mkv"
|
|
38
|
+
assert len(project.segments) == 10
|
|
39
|
+
assert project.marker_a == 0
|
|
40
|
+
assert project.marker_b == 4905816000
|
|
41
|
+
assert project.video_codec == "Copy"
|
|
42
|
+
assert project.container == "MKV"
|
|
43
|
+
assert len(project.container_options) == 8
|
|
44
|
+
assert len(project.audio_tracks) == 2
|
|
45
|
+
assert project.audio_tracks[0] == AudioTrack(index=0, language="eng", codec="copy")
|
|
46
|
+
assert project.audio_tracks[1] == AudioTrack(index=1, language="spa", codec="copy")
|
|
47
|
+
assert project.hdr_config == (1, 1, 1, 1, 0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_segment_end():
|
|
51
|
+
seg = Segment(start=1000000, duration=5000000)
|
|
52
|
+
assert seg.end == 6000000
|
|
53
|
+
assert seg.start == 1000000
|
|
54
|
+
assert seg.duration == 5000000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_segment_zero_duration():
|
|
58
|
+
seg = Segment(start=5000, duration=0)
|
|
59
|
+
assert seg.end == 5000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_parse_project_invalid_content():
|
|
63
|
+
with pytest.raises(ValueError, match="No Avidemux project data found"):
|
|
64
|
+
parse_project("not an avidemux file")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_parse_project_empty_string():
|
|
68
|
+
with pytest.raises(ValueError, match="No Avidemux project data found"):
|
|
69
|
+
parse_project("")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_parse_project_just_segments():
|
|
73
|
+
project = parse_project("adm.addSegment(0, 1000, 2000)")
|
|
74
|
+
assert project.video_file is None
|
|
75
|
+
assert len(project.segments) == 1
|
|
76
|
+
assert project.segments[0] == Segment(start=1000, duration=2000)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_parse_project_just_video():
|
|
80
|
+
content = (
|
|
81
|
+
"adm = Avidemux()\n"
|
|
82
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
83
|
+
" raise\n"
|
|
84
|
+
)
|
|
85
|
+
project = parse_project(content)
|
|
86
|
+
assert project.video_file == "/path/to/video.mkv"
|
|
87
|
+
assert len(project.segments) == 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_parse_project_no_segments():
|
|
91
|
+
content = (
|
|
92
|
+
"adm = Avidemux()\n"
|
|
93
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
94
|
+
" raise\n"
|
|
95
|
+
"adm.markerA = 0\n"
|
|
96
|
+
)
|
|
97
|
+
project = parse_project(content)
|
|
98
|
+
assert project.video_file == "/path/to/video.mkv"
|
|
99
|
+
assert len(project.segments) == 0
|
|
100
|
+
assert project.marker_a == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_parse_project_no_video():
|
|
104
|
+
project = parse_project("adm.addSegment(0, 1000, 2000)\nadm.addSegment(0, 5000, 3000)")
|
|
105
|
+
assert project.video_file is None
|
|
106
|
+
assert len(project.segments) == 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_parse_project_no_hdr():
|
|
110
|
+
content = (
|
|
111
|
+
"adm = Avidemux()\n"
|
|
112
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
113
|
+
" raise\n"
|
|
114
|
+
)
|
|
115
|
+
project = parse_project(content)
|
|
116
|
+
assert project.hdr_config == ()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_parse_project_no_container():
|
|
120
|
+
content = (
|
|
121
|
+
"adm = Avidemux()\n"
|
|
122
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
123
|
+
" raise\n"
|
|
124
|
+
)
|
|
125
|
+
project = parse_project(content)
|
|
126
|
+
assert project.container is None
|
|
127
|
+
assert project.container_options == {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_parse_project_no_audio():
|
|
131
|
+
content = (
|
|
132
|
+
"adm = Avidemux()\n"
|
|
133
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
134
|
+
" raise\n"
|
|
135
|
+
)
|
|
136
|
+
project = parse_project(content)
|
|
137
|
+
assert project.audio_tracks == []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_parse_project_no_codec():
|
|
141
|
+
content = (
|
|
142
|
+
"adm = Avidemux()\n"
|
|
143
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
144
|
+
" raise\n"
|
|
145
|
+
)
|
|
146
|
+
project = parse_project(content)
|
|
147
|
+
assert project.video_codec is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_parse_project_no_markers():
|
|
151
|
+
content = (
|
|
152
|
+
"adm = Avidemux()\n"
|
|
153
|
+
'if not adm.loadVideo("/path/to/video.mkv"):\n'
|
|
154
|
+
" raise\n"
|
|
155
|
+
)
|
|
156
|
+
project = parse_project(content)
|
|
157
|
+
assert project.marker_a is None
|
|
158
|
+
assert project.marker_b is None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_us_to_iso_time_zero():
|
|
162
|
+
assert us_to_iso_time(0) == "00:00:00.000"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_us_to_iso_time_one_second():
|
|
166
|
+
assert us_to_iso_time(1_000_000) == "00:00:01.000"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_us_to_iso_time_one_minute():
|
|
170
|
+
assert us_to_iso_time(60_000_000) == "00:01:00.000"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_us_to_iso_time_one_hour():
|
|
174
|
+
assert us_to_iso_time(3_600_000_000) == "01:00:00.000"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_us_to_iso_time_milliseconds():
|
|
178
|
+
assert us_to_iso_time(1_500_000) == "00:00:01.500"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_us_to_iso_time_complex():
|
|
182
|
+
assert us_to_iso_time(9_999_999_999) == "02:46:39.999"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_to_dict_shape():
|
|
186
|
+
project = AvidemuxProject(
|
|
187
|
+
video_file="/path/to/video.mkv",
|
|
188
|
+
segments=[Segment(start=1000, duration=2000)],
|
|
189
|
+
marker_a=0,
|
|
190
|
+
marker_b=5000,
|
|
191
|
+
video_codec="Copy",
|
|
192
|
+
container="MKV",
|
|
193
|
+
container_options={"forceAspectRatio": "False"},
|
|
194
|
+
audio_tracks=[AudioTrack(index=0, language="eng", codec="copy")],
|
|
195
|
+
hdr_config=(1, 1, 1, 1, 0),
|
|
196
|
+
)
|
|
197
|
+
d = project.to_dict()
|
|
198
|
+
assert d["video_file"] == "/path/to/video.mkv"
|
|
199
|
+
assert d["segments"] == [{"start": 1000, "duration": 2000, "end": 3000}]
|
|
200
|
+
assert d["marker_a"] == 0
|
|
201
|
+
assert d["marker_b"] == 5000
|
|
202
|
+
assert d["video_codec"] == "Copy"
|
|
203
|
+
assert d["container"] == "MKV"
|
|
204
|
+
assert d["container_options"] == {"forceAspectRatio": "False"}
|
|
205
|
+
assert d["audio_tracks"] == [{"index": 0, "language": "eng", "codec": "copy"}]
|
|
206
|
+
assert d["hdr_config"] == [1, 1, 1, 1, 0]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_to_dict_empty_project():
|
|
210
|
+
d = AvidemuxProject().to_dict()
|
|
211
|
+
assert d["video_file"] is None
|
|
212
|
+
assert d["segments"] == []
|
|
213
|
+
assert d["marker_a"] is None
|
|
214
|
+
assert d["marker_b"] is None
|
|
215
|
+
assert d["video_codec"] is None
|
|
216
|
+
assert d["container"] is None
|
|
217
|
+
assert d["container_options"] == {}
|
|
218
|
+
assert d["audio_tracks"] == []
|
|
219
|
+
assert d["hdr_config"] == []
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_audio_track_equality():
|
|
223
|
+
t1 = AudioTrack(index=0, language="eng", codec="copy")
|
|
224
|
+
t2 = AudioTrack(index=0, language="eng", codec="copy")
|
|
225
|
+
assert t1 == t2
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_segment_equality():
|
|
229
|
+
s1 = Segment(start=1000, duration=2000)
|
|
230
|
+
s2 = Segment(start=1000, duration=2000)
|
|
231
|
+
assert s1 == s2
|