bdmvkit 0.2.0__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.
- {bdmvkit-0.2.0/bdmvkit.egg-info → bdmvkit-0.3.0}/PKG-INFO +65 -13
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/README.md +64 -12
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/__init__.py +5 -2
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/__main__.py +16 -5
- bdmvkit-0.3.0/bdmvkit/disc.py +234 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/encode.py +74 -17
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/formats.py +26 -1
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/hdmv.py +5 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/m2ts.py +25 -1
- bdmvkit-0.3.0/bdmvkit/nav.py +417 -0
- bdmvkit-0.3.0/bdmvkit/program.py +229 -0
- bdmvkit-0.3.0/bdmvkit/uops.py +32 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0/bdmvkit.egg-info}/PKG-INFO +65 -13
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit.egg-info/SOURCES.txt +4 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/pyproject.toml +1 -1
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/tests/test_disc.py +68 -2
- bdmvkit-0.3.0/tests/test_nav.py +244 -0
- bdmvkit-0.2.0/bdmvkit/disc.py +0 -257
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/COPYING +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/COPYING.LESSER +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/clip.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/errors.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/igs.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/pgs.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit/playlist.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit.egg-info/entry_points.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/bdmvkit.egg-info/top_level.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/setup.cfg +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/tests/test_files.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/tests/test_menus.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.3.0}/tests/test_pgs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bdmvkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles
|
|
5
5
|
Author: kajisho5
|
|
6
6
|
License-Expression: LGPL-3.0-or-later
|
|
@@ -35,11 +35,14 @@ only to encode.
|
|
|
35
35
|
|
|
36
36
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
37
37
|
|
|
38
|
-
**Status: 0.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
**Status: 0.3.** Titles with chapters, sound and subtitle tracks;
|
|
39
|
+
playlists (in order, at random or shuffled) and play all; menus whose
|
|
40
|
+
buttons start titles, chapters, playlists and other menus, switch sound and
|
|
41
|
+
subtitle tracks, or open a menu on a chosen button; loop counts, end-action
|
|
42
|
+
overrides, link transitions, remote keys a title turns off, and 720p. This
|
|
43
|
+
navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
|
|
44
|
+
writes, byte for byte. Encoding pop-up menus and menus with motion or music
|
|
45
|
+
comes next.
|
|
43
46
|
|
|
44
47
|
## Why
|
|
45
48
|
|
|
@@ -75,6 +78,23 @@ disc.add_title("extras.m2ts", fps=(25, 1))
|
|
|
75
78
|
disc.write("out") # out/BDMV/...
|
|
76
79
|
```
|
|
77
80
|
|
|
81
|
+
A clip can carry several sound tracks, each from the video itself or from
|
|
82
|
+
another file; track k is the title's audio track k (silence when a file has
|
|
83
|
+
no sound, and sound shorter than the picture is made up with silence):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from bdmvkit.encode import AudioTrack
|
|
87
|
+
|
|
88
|
+
encode_clip("feature.mov", "feature.m2ts", audio=[
|
|
89
|
+
AudioTrack(), # the video's own sound, AC-3
|
|
90
|
+
AudioTrack("commentary.wav", "lpcm"), # another file, LPCM
|
|
91
|
+
AudioTrack("feature.mov", "ac3", 6, stream=1), # its second sound stream, 5.1
|
|
92
|
+
])
|
|
93
|
+
disc.add_title("feature.m2ts", audio=[("ac3", 2, "en"), ("lpcm", 2, "en"), ("ac3", 6, "ja")])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Disc.write` checks that every track a title declares is in its clip.
|
|
97
|
+
|
|
78
98
|
A menu is a picture and its buttons. Each button is a picture of how it
|
|
79
99
|
looks when selected (and, if given, when activated and before it is
|
|
80
100
|
selected), placed on the 1920 x 1080 menu, and a link:
|
|
@@ -91,10 +111,31 @@ disc.add_menu("main", "menu.png", [
|
|
|
91
111
|
disc.write("out")
|
|
92
112
|
```
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
A button's `action`, a title's `end` and the disc's first play are
|
|
115
|
+
actions: `title:<n>`, `chapter:<n>:<c>`, `playlist:<id>`, `playall`,
|
|
116
|
+
`menu:<id>` (or `menu:<id>:<button>`, that button selected), `lastmenu`,
|
|
117
|
+
`resume`, `audio:<n>`, `subtitle:<n>` or `subtitle:off`, and `stop`. The
|
|
118
|
+
arrow keys go to the nearest button that way unless a button's `nav` says
|
|
119
|
+
(`{"down": "scene2"}`). `bdmvkit.igs` makes the button stream on its own,
|
|
120
|
+
for other tools.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
disc = Disc(title_key="menu:main")
|
|
124
|
+
a = disc.add_title("feature.m2ts", chapters=[0, 300], audio=[("ac3", 6, "en"), ("ac3", 6, "ja")],
|
|
125
|
+
subtitles=["en", "ja"], default_subtitle=1, end="lastmenu")
|
|
126
|
+
n = disc.add_title("notice.m2ts", prohibit=["next", "fast_forward", "menu"], end="title:1")
|
|
127
|
+
disc.add_playlist("both", [n, (a, 2, None)], order="shuffle", end="menu:main")
|
|
128
|
+
disc.add_menu("main", "menu.png", [
|
|
129
|
+
MenuButton("play", 160, 700, "play.png", action="title:2"), # the notice first
|
|
130
|
+
MenuButton("ja", 160, 800, "ja.png", action="title:1", audio=2, subtitle=0),
|
|
131
|
+
MenuButton("both", 160, 900, "both.png", action="playlist:both"),
|
|
132
|
+
], loop=3, end="playall", seconds=10)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
136
|
+
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
137
|
+
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
138
|
+
menu's buttons run, for tools that make their own IG streams.
|
|
98
139
|
|
|
99
140
|
```sh
|
|
100
141
|
bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
|
|
@@ -115,12 +156,20 @@ bdmvkit build disc.json out
|
|
|
115
156
|
How the disc is laid out:
|
|
116
157
|
|
|
117
158
|
* Frame rates are 23.976 (1080p), 25 and 29.97. The last two are coded as
|
|
118
|
-
progressive frames in a 1080i stream, as the format asks.
|
|
159
|
+
progressive frames in a 1080i stream, as the format asks. A program can
|
|
160
|
+
also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
|
|
161
|
+
`encode_clip` makes 1080 clips.
|
|
119
162
|
* Sound is AC-3, LPCM or DTS.
|
|
120
163
|
* Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
|
|
121
164
|
from pictures, and `encode_clip(..., sups=[...])` muxes them in.
|
|
122
165
|
* First play and the Top Menu key open the first menu, or start title 1
|
|
123
|
-
when there is none.
|
|
166
|
+
when there is none (`Disc(first_play=..., title_key=...)` choose).
|
|
167
|
+
* Each title, playlist, play all and menu is a Blu-ray title, in that
|
|
168
|
+
order; random and shuffled playlists play a playlist file per item, picked
|
|
169
|
+
with the player's random number command. The general registers 1 to 8
|
|
170
|
+
hold the last menu, the button a link selects, a menu's loop count, an
|
|
171
|
+
end-action override, a stream choice, and a random playlist's state
|
|
172
|
+
(`nav.py`).
|
|
124
173
|
* A menu is an interactive title after the titles: its picture held as a
|
|
125
174
|
still, with its buttons as an Interactive Graphics stream in its clip.
|
|
126
175
|
The menus are encoded when the disc is written (ffmpeg).
|
|
@@ -144,7 +193,10 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
144
193
|
* that running the disc from first play reaches the titles and chapters in
|
|
145
194
|
order;
|
|
146
195
|
* that a menu's buttons, chosen and pressed with the remote's keys, start
|
|
147
|
-
the titles, chapters and menus they link to
|
|
196
|
+
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
197
|
+
playlist plays each item once, and that a menu with a loop count goes on
|
|
198
|
+
by itself;
|
|
199
|
+
* the commands every kind of action, title, playlist and menu turns into.
|
|
148
200
|
|
|
149
201
|
The files' fixed bytes are checked as well.
|
|
150
202
|
|
|
@@ -18,11 +18,14 @@ only to encode.
|
|
|
18
18
|
|
|
19
19
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
20
20
|
|
|
21
|
-
**Status: 0.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
**Status: 0.3.** Titles with chapters, sound and subtitle tracks;
|
|
22
|
+
playlists (in order, at random or shuffled) and play all; menus whose
|
|
23
|
+
buttons start titles, chapters, playlists and other menus, switch sound and
|
|
24
|
+
subtitle tracks, or open a menu on a chosen button; loop counts, end-action
|
|
25
|
+
overrides, link transitions, remote keys a title turns off, and 720p. This
|
|
26
|
+
navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
|
|
27
|
+
writes, byte for byte. Encoding pop-up menus and menus with motion or music
|
|
28
|
+
comes next.
|
|
26
29
|
|
|
27
30
|
## Why
|
|
28
31
|
|
|
@@ -58,6 +61,23 @@ disc.add_title("extras.m2ts", fps=(25, 1))
|
|
|
58
61
|
disc.write("out") # out/BDMV/...
|
|
59
62
|
```
|
|
60
63
|
|
|
64
|
+
A clip can carry several sound tracks, each from the video itself or from
|
|
65
|
+
another file; track k is the title's audio track k (silence when a file has
|
|
66
|
+
no sound, and sound shorter than the picture is made up with silence):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from bdmvkit.encode import AudioTrack
|
|
70
|
+
|
|
71
|
+
encode_clip("feature.mov", "feature.m2ts", audio=[
|
|
72
|
+
AudioTrack(), # the video's own sound, AC-3
|
|
73
|
+
AudioTrack("commentary.wav", "lpcm"), # another file, LPCM
|
|
74
|
+
AudioTrack("feature.mov", "ac3", 6, stream=1), # its second sound stream, 5.1
|
|
75
|
+
])
|
|
76
|
+
disc.add_title("feature.m2ts", audio=[("ac3", 2, "en"), ("lpcm", 2, "en"), ("ac3", 6, "ja")])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`Disc.write` checks that every track a title declares is in its clip.
|
|
80
|
+
|
|
61
81
|
A menu is a picture and its buttons. Each button is a picture of how it
|
|
62
82
|
looks when selected (and, if given, when activated and before it is
|
|
63
83
|
selected), placed on the 1920 x 1080 menu, and a link:
|
|
@@ -74,10 +94,31 @@ disc.add_menu("main", "menu.png", [
|
|
|
74
94
|
disc.write("out")
|
|
75
95
|
```
|
|
76
96
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
A button's `action`, a title's `end` and the disc's first play are
|
|
98
|
+
actions: `title:<n>`, `chapter:<n>:<c>`, `playlist:<id>`, `playall`,
|
|
99
|
+
`menu:<id>` (or `menu:<id>:<button>`, that button selected), `lastmenu`,
|
|
100
|
+
`resume`, `audio:<n>`, `subtitle:<n>` or `subtitle:off`, and `stop`. The
|
|
101
|
+
arrow keys go to the nearest button that way unless a button's `nav` says
|
|
102
|
+
(`{"down": "scene2"}`). `bdmvkit.igs` makes the button stream on its own,
|
|
103
|
+
for other tools.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
disc = Disc(title_key="menu:main")
|
|
107
|
+
a = disc.add_title("feature.m2ts", chapters=[0, 300], audio=[("ac3", 6, "en"), ("ac3", 6, "ja")],
|
|
108
|
+
subtitles=["en", "ja"], default_subtitle=1, end="lastmenu")
|
|
109
|
+
n = disc.add_title("notice.m2ts", prohibit=["next", "fast_forward", "menu"], end="title:1")
|
|
110
|
+
disc.add_playlist("both", [n, (a, 2, None)], order="shuffle", end="menu:main")
|
|
111
|
+
disc.add_menu("main", "menu.png", [
|
|
112
|
+
MenuButton("play", 160, 700, "play.png", action="title:2"), # the notice first
|
|
113
|
+
MenuButton("ja", 160, 800, "ja.png", action="title:1", audio=2, subtitle=0),
|
|
114
|
+
MenuButton("both", 160, 900, "both.png", action="playlist:both"),
|
|
115
|
+
], loop=3, end="playall", seconds=10)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
119
|
+
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
120
|
+
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
121
|
+
menu's buttons run, for tools that make their own IG streams.
|
|
81
122
|
|
|
82
123
|
```sh
|
|
83
124
|
bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
|
|
@@ -98,12 +139,20 @@ bdmvkit build disc.json out
|
|
|
98
139
|
How the disc is laid out:
|
|
99
140
|
|
|
100
141
|
* Frame rates are 23.976 (1080p), 25 and 29.97. The last two are coded as
|
|
101
|
-
progressive frames in a 1080i stream, as the format asks.
|
|
142
|
+
progressive frames in a 1080i stream, as the format asks. A program can
|
|
143
|
+
also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
|
|
144
|
+
`encode_clip` makes 1080 clips.
|
|
102
145
|
* Sound is AC-3, LPCM or DTS.
|
|
103
146
|
* Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
|
|
104
147
|
from pictures, and `encode_clip(..., sups=[...])` muxes them in.
|
|
105
148
|
* First play and the Top Menu key open the first menu, or start title 1
|
|
106
|
-
when there is none.
|
|
149
|
+
when there is none (`Disc(first_play=..., title_key=...)` choose).
|
|
150
|
+
* Each title, playlist, play all and menu is a Blu-ray title, in that
|
|
151
|
+
order; random and shuffled playlists play a playlist file per item, picked
|
|
152
|
+
with the player's random number command. The general registers 1 to 8
|
|
153
|
+
hold the last menu, the button a link selects, a menu's loop count, an
|
|
154
|
+
end-action override, a stream choice, and a random playlist's state
|
|
155
|
+
(`nav.py`).
|
|
107
156
|
* A menu is an interactive title after the titles: its picture held as a
|
|
108
157
|
still, with its buttons as an Interactive Graphics stream in its clip.
|
|
109
158
|
The menus are encoded when the disc is written (ffmpeg).
|
|
@@ -127,7 +176,10 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
127
176
|
* that running the disc from first play reaches the titles and chapters in
|
|
128
177
|
order;
|
|
129
178
|
* that a menu's buttons, chosen and pressed with the remote's keys, start
|
|
130
|
-
the titles, chapters and menus they link to
|
|
179
|
+
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
180
|
+
playlist plays each item once, and that a menu with a loop count goes on
|
|
181
|
+
by itself;
|
|
182
|
+
* the commands every kind of action, title, playlist and menu turns into.
|
|
131
183
|
|
|
132
184
|
The files' fixed bytes are checked as well.
|
|
133
185
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""bdmvkit: Blu-ray (BDMV) authoring in pure Python.
|
|
2
2
|
|
|
3
3
|
* `bdmvkit.Disc`: a BDMV folder from encoded clips (titles, chapters, sound
|
|
4
|
-
and subtitle tracks,
|
|
4
|
+
and subtitle tracks), playlists and menus (`MenuButton`).
|
|
5
|
+
* `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
|
|
6
|
+
playlists, menu clips, transitions, actions) and its writer,
|
|
7
|
+
`nav.write_program`, with the navigation commands (`nav.Navigation`).
|
|
5
8
|
* `bdmvkit.igs`: a menu's buttons as an Interactive Graphics stream.
|
|
6
9
|
* `bdmvkit.encode.encode_clip`: a clip encoded for Blu-ray with ffmpeg + x264.
|
|
7
10
|
* `bdmvkit.pgs`: PGS subtitle streams (.sup) from pictures.
|
|
@@ -12,5 +15,5 @@
|
|
|
12
15
|
from .disc import Disc, MenuButton
|
|
13
16
|
from .errors import BdmvError
|
|
14
17
|
|
|
15
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.3.0"
|
|
16
19
|
__all__ = ["Disc", "MenuButton", "BdmvError"]
|
|
@@ -9,7 +9,11 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
|
|
|
9
9
|
"buttons": [{"id": "play", "x": 160, "y": 700, "selected": "play.png",
|
|
10
10
|
"action": "title:1"}]}], "play_all": true}; paths are relative to the JSON
|
|
11
11
|
file. A button may also have "activated" and "normal" pictures, "nav"
|
|
12
|
-
({"up": id, ...})
|
|
12
|
+
({"up": id, ...}), "auto", "audio", "subtitle" and "override"; a menu
|
|
13
|
+
"default" (a button id), "fps", "loop", "end", "seconds" and "prohibit"; a
|
|
14
|
+
title "default_audio", "default_subtitle" and "prohibit". The disc may have
|
|
15
|
+
"playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
|
|
16
|
+
"first_play" and "title_key".
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
19
|
import argparse
|
|
@@ -56,12 +60,17 @@ def main(argv=None):
|
|
|
56
60
|
with open(args.disc, encoding="utf-8") as f:
|
|
57
61
|
spec = json.load(f)
|
|
58
62
|
base = os.path.dirname(os.path.abspath(args.disc))
|
|
59
|
-
disc = Disc()
|
|
63
|
+
disc = Disc(spec.get("first_play"), spec.get("title_key"))
|
|
60
64
|
for t in spec.get("titles", []):
|
|
61
65
|
disc.add_title(os.path.join(base, t["clip"]), fps=_fps(t.get("fps", "24000/1001")),
|
|
62
66
|
chapters=t.get("chapters", [0]),
|
|
63
67
|
audio=[tuple(a) for a in t.get("audio", [["ac3", 2, "und"]])],
|
|
64
|
-
subtitles=t.get("subtitles", []), end=t.get("end")
|
|
68
|
+
subtitles=t.get("subtitles", []), end=t.get("end"),
|
|
69
|
+
default_audio=t.get("default_audio"),
|
|
70
|
+
default_subtitle=t.get("default_subtitle"),
|
|
71
|
+
prohibit=t.get("prohibit", []))
|
|
72
|
+
for p in spec.get("playlists", []):
|
|
73
|
+
disc.add_playlist(p["id"], p["items"], p.get("order", "sequential"), p.get("end"))
|
|
65
74
|
|
|
66
75
|
def file(name):
|
|
67
76
|
return os.path.join(base, name) if name else None
|
|
@@ -70,9 +79,11 @@ def main(argv=None):
|
|
|
70
79
|
disc.add_menu(m["id"], file(m["background"]), [
|
|
71
80
|
MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
|
|
72
81
|
file(b.get("activated")), file(b.get("normal")), b.get("action"),
|
|
73
|
-
dict(b.get("nav", {})), bool(b.get("auto")))
|
|
82
|
+
dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
|
|
83
|
+
b.get("subtitle"), b.get("override"))
|
|
74
84
|
for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
|
|
75
|
-
default=m.get("default"))
|
|
85
|
+
default=m.get("default"), loop=m.get("loop"), end=m.get("end"),
|
|
86
|
+
prohibit=m.get("prohibit", []), seconds=float(m.get("seconds", 1.0)))
|
|
76
87
|
res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
|
|
77
88
|
except (BdmvError, OSError, KeyError, ValueError) as e:
|
|
78
89
|
print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""A BDMV folder from encoded clips: titles with chapters, played from the
|
|
2
|
+
first one on (or each on its own), with their sound and subtitle tracks.
|
|
3
|
+
|
|
4
|
+
disc = Disc()
|
|
5
|
+
disc.add_title("feature.m2ts", chapters=[0, 300, 600], audio=[("ac3", 6, "en")],
|
|
6
|
+
subtitles=["en", "ja"])
|
|
7
|
+
disc.add_title("extras.m2ts")
|
|
8
|
+
disc.write("out") # out/BDMV/...
|
|
9
|
+
|
|
10
|
+
Each title is a playlist with a chapter mark at every chapter (on the key
|
|
11
|
+
frame nearest to it: encode the clip with key frames there, as
|
|
12
|
+
encode.encode_clip does) and a movie object that plays it. First play and
|
|
13
|
+
the Top Menu key start title 1; with `play_all`, each title goes on to the
|
|
14
|
+
next when it ends. Clips are BDAV streams with the PIDs formats.py names.
|
|
15
|
+
|
|
16
|
+
Menus: a picture with buttons, each linked to a title, a chapter or
|
|
17
|
+
another menu:
|
|
18
|
+
|
|
19
|
+
disc.add_menu("main", "menu.png", [
|
|
20
|
+
MenuButton("play", 160, 700, "play.png", action="title:1"),
|
|
21
|
+
MenuButton("scenes", 160, 800, "scenes.png", "scenes-lit.png", action="menu:scenes"),
|
|
22
|
+
])
|
|
23
|
+
disc.add_title("feature.m2ts", end="menu:main") # back to the menu at the end
|
|
24
|
+
|
|
25
|
+
A button's pictures are its selected (and activated) look, drawn over the
|
|
26
|
+
menu where it is; with a `normal` picture it is drawn there before it is
|
|
27
|
+
selected too. The arrow keys go to the nearest button that way unless
|
|
28
|
+
`nav` says otherwise. Each menu is an interactive title after the titles:
|
|
29
|
+
its picture held as a still, its buttons an IG stream (igs.py) in its clip.
|
|
30
|
+
With menus, first play and the Top Menu key open the first menu.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import dataclasses
|
|
34
|
+
import os
|
|
35
|
+
import tempfile
|
|
36
|
+
|
|
37
|
+
from . import encode, igs, program
|
|
38
|
+
from .errors import BdmvError
|
|
39
|
+
from .formats import AUDIO_PID, CODECS, FORMATS, HEIGHT, PG_PID, WIDTH, lang3
|
|
40
|
+
from .m2ts import stream_pids
|
|
41
|
+
from .nav import DIRS, Navigation, _write, chapter_marks, write_program # noqa: F401
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass
|
|
45
|
+
class Title:
|
|
46
|
+
clip: str # an encoded .m2ts
|
|
47
|
+
fps: tuple = (24000, 1001) # its frame rate: 24000/1001, 25/1 or 30000/1001
|
|
48
|
+
chapters: list = dataclasses.field(default_factory=lambda: [0.0]) # starts, seconds
|
|
49
|
+
audio: list = dataclasses.field(default_factory=lambda: [("ac3", 2, "und")])
|
|
50
|
+
subtitles: list = dataclasses.field(default_factory=list) # PG tracks: languages
|
|
51
|
+
end: str = None # the action after it (None: the next title with play_all)
|
|
52
|
+
default_audio: int = None # the sound track the disc starts with (1-based)
|
|
53
|
+
default_subtitle: int = None # the subtitle track shown when it starts (None: off)
|
|
54
|
+
prohibit: list = dataclasses.field(default_factory=list) # uops.KEYS names
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclasses.dataclass
|
|
58
|
+
class MenuButton:
|
|
59
|
+
id: str
|
|
60
|
+
x: int # top-left on the 1920 x 1080 menu
|
|
61
|
+
y: int
|
|
62
|
+
selected: str # picture files (PNG ...): selected,
|
|
63
|
+
activated: str = None # activated (default: the selected one),
|
|
64
|
+
normal: str = None # and before it is selected (default: nothing)
|
|
65
|
+
action: str = None # where it goes: an action (program.py), e.g. "title:2"
|
|
66
|
+
nav: dict = dataclasses.field(default_factory=dict) # direction -> button id
|
|
67
|
+
auto: bool = False # runs as soon as it is selected
|
|
68
|
+
audio: int = None # switch to this sound track (1-based) first
|
|
69
|
+
subtitle: int = None # ... and to this subtitle track (0: off)
|
|
70
|
+
override: str = None # the end action of the title it starts, instead of its own
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclasses.dataclass
|
|
74
|
+
class Menu:
|
|
75
|
+
id: str
|
|
76
|
+
background: str # picture file, fitted into the HD frame
|
|
77
|
+
buttons: list
|
|
78
|
+
fps: tuple = (24000, 1001)
|
|
79
|
+
default: str = None # the button selected first (default: the first)
|
|
80
|
+
loop: int = None # held this many times its length, then `end`
|
|
81
|
+
end: str = None
|
|
82
|
+
prohibit: list = dataclasses.field(default_factory=list)
|
|
83
|
+
seconds: float = 1.0 # its clip's length
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_streams(n, title):
|
|
87
|
+
"""The title's sound and subtitle tracks must be in its clip, with the
|
|
88
|
+
PIDs the playlist gives them (encode.encode_clip makes them so)."""
|
|
89
|
+
pids = stream_pids(title.clip)
|
|
90
|
+
want = [("audio", AUDIO_PID, k) for k in range(len(title.audio))] + \
|
|
91
|
+
[("subtitle", PG_PID, k) for k in range(len(title.subtitles))]
|
|
92
|
+
for kind, first, k in want:
|
|
93
|
+
if first + k not in pids:
|
|
94
|
+
raise BdmvError(f"title {n}: {kind} track {k + 1} is not in {title.clip} "
|
|
95
|
+
f"(no stream with PID {first + k:#06x})")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Disc:
|
|
99
|
+
"""first_play: the action the disc starts with (default: the first menu,
|
|
100
|
+
or title 1); title_key: the Top Menu key's (default: the same)."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, first_play=None, title_key=None):
|
|
103
|
+
self.titles = []
|
|
104
|
+
self.menus = []
|
|
105
|
+
self.playlists = []
|
|
106
|
+
self.first_play = first_play
|
|
107
|
+
self.title_key = title_key
|
|
108
|
+
|
|
109
|
+
def add_title(self, clip, fps=(24000, 1001), chapters=(0.0,), audio=(("ac3", 2, "und"),),
|
|
110
|
+
subtitles=(), end=None, default_audio=None, default_subtitle=None,
|
|
111
|
+
prohibit=()):
|
|
112
|
+
"""Add a title; returns its number (1-based), its id in actions
|
|
113
|
+
("title:2"). audio: (codec, channels, language) per track, codec
|
|
114
|
+
"ac3", "lpcm" or "dts"; languages are ISO 639-1 ("en") or 639-2
|
|
115
|
+
("eng") codes. end: the action when it ends (default: the next title
|
|
116
|
+
with play_all). default_audio: the sound track the disc starts with;
|
|
117
|
+
default_subtitle: the subtitle track shown from its start (else
|
|
118
|
+
none); prohibit: remote keys it does not allow (uops.KEYS)."""
|
|
119
|
+
if tuple(fps) not in FORMATS:
|
|
120
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
121
|
+
for codec, _, _ in audio:
|
|
122
|
+
if codec not in CODECS:
|
|
123
|
+
raise BdmvError(f"unknown audio codec {codec!r} (ac3, lpcm or dts)")
|
|
124
|
+
if not audio:
|
|
125
|
+
raise BdmvError("a title needs at least one audio track")
|
|
126
|
+
for name, k, n in (("default_audio", default_audio, len(audio)),
|
|
127
|
+
("default_subtitle", default_subtitle, len(subtitles))):
|
|
128
|
+
if k is not None and not 1 <= k <= n:
|
|
129
|
+
raise BdmvError(f"{name} {k}: the title has {n} such tracks")
|
|
130
|
+
self.titles.append(Title(clip, tuple(fps), list(chapters) or [0.0], list(audio),
|
|
131
|
+
list(subtitles), end, default_audio, default_subtitle,
|
|
132
|
+
list(prohibit)))
|
|
133
|
+
return len(self.titles)
|
|
134
|
+
|
|
135
|
+
def add_playlist(self, playlist_id, items, order="sequential", end=None):
|
|
136
|
+
"""Add a playlist: items (title, first chapter, last chapter), or
|
|
137
|
+
title numbers for whole titles, played one after another
|
|
138
|
+
("sequential"), picked at random as many times as there are items
|
|
139
|
+
("random"), or each once in a random order ("shuffle"). It is
|
|
140
|
+
started by "playlist:<id>"."""
|
|
141
|
+
if any(p.id == playlist_id for p in self.playlists):
|
|
142
|
+
raise BdmvError(f"there is a playlist {playlist_id!r} already")
|
|
143
|
+
out = []
|
|
144
|
+
for it in items:
|
|
145
|
+
n, first, last = (it, 1, None) if isinstance(it, int) else tuple(it) + (None,) * \
|
|
146
|
+
(3 - len(it))
|
|
147
|
+
out.append(program.PlaylistItem(str(n), first or 1, last))
|
|
148
|
+
self.playlists.append(program.Playlist(playlist_id, out, order, end))
|
|
149
|
+
|
|
150
|
+
def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None, loop=None,
|
|
151
|
+
end=None, prohibit=(), seconds=1.0):
|
|
152
|
+
"""Add a menu: `background` a picture, `buttons` MenuButtons. The
|
|
153
|
+
menu's clip (`seconds` long) is made when the disc is written
|
|
154
|
+
(ffmpeg). It holds its last picture; with `loop`, only for `loop`
|
|
155
|
+
times its length, then it does `end`."""
|
|
156
|
+
if tuple(fps) not in FORMATS:
|
|
157
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
158
|
+
if any(m.id == menu_id for m in self.menus):
|
|
159
|
+
raise BdmvError(f"there is a menu {menu_id!r} already")
|
|
160
|
+
if not buttons:
|
|
161
|
+
raise BdmvError(f"menu {menu_id!r} needs at least one button")
|
|
162
|
+
self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
|
|
163
|
+
end, list(prohibit), seconds))
|
|
164
|
+
|
|
165
|
+
def program(self, play_all=True):
|
|
166
|
+
"""The disc as a program.Program (titles "1", "2" ...; menus with no
|
|
167
|
+
clip yet: write makes them)."""
|
|
168
|
+
n_titles = len(self.titles)
|
|
169
|
+
titles = []
|
|
170
|
+
for n, t in enumerate(self.titles, 1):
|
|
171
|
+
end = t.end or (f"title:{n + 1}" if play_all and n < n_titles else None)
|
|
172
|
+
titles.append(program.Title(
|
|
173
|
+
str(n), t.clip, t.fps, list(t.chapters),
|
|
174
|
+
[program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
|
|
175
|
+
for k, (c, ch, lang) in enumerate(t.audio, 1)],
|
|
176
|
+
[program.Subtitle(_lang(lang), k == t.default_subtitle)
|
|
177
|
+
for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit)))
|
|
178
|
+
menus = [program.Menu(m.id, None,
|
|
179
|
+
[program.Button(b.id, b.action, b.audio, b.subtitle,
|
|
180
|
+
override=b.override) for b in m.buttons],
|
|
181
|
+
fps=m.fps, default=m.default, loop=m.loop, end=m.end,
|
|
182
|
+
still_time=max(1, round(m.seconds)) if m.loop is not None
|
|
183
|
+
else None,
|
|
184
|
+
prohibit=list(m.prohibit)) for m in self.menus]
|
|
185
|
+
first_menu = f"menu:{self.menus[0].id}" if self.menus else None
|
|
186
|
+
return program.Program(titles, list(self.playlists), menus,
|
|
187
|
+
first_play=self.first_play or first_menu,
|
|
188
|
+
title_key=self.title_key)
|
|
189
|
+
|
|
190
|
+
def write(self, out_dir, move=False, play_all=True, fast=False):
|
|
191
|
+
"""Write the BDMV folder into `out_dir` (copying the clips into
|
|
192
|
+
BDMV/STREAM, or moving them with `move`; menu clips are encoded,
|
|
193
|
+
quickly with `fast`). Returns {"titles", "menus", "playlists",
|
|
194
|
+
"chapters"}."""
|
|
195
|
+
if not self.titles:
|
|
196
|
+
raise BdmvError("a disc needs at least one title")
|
|
197
|
+
prog = self.program(play_all)
|
|
198
|
+
prog.check() # (the menus' clips are made below)
|
|
199
|
+
for n, t in enumerate(self.titles, 1):
|
|
200
|
+
_check_streams(n, t)
|
|
201
|
+
nav = Navigation(prog)
|
|
202
|
+
os.makedirs(os.path.join(out_dir, "BDMV", "STREAM"), exist_ok=True)
|
|
203
|
+
for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
|
|
204
|
+
pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
|
|
205
|
+
self._encode_menu(m, pm, nav, fast)
|
|
206
|
+
out = write_program(prog, out_dir, move=move)
|
|
207
|
+
return {"titles": len(self.titles), "menus": len(self.menus),
|
|
208
|
+
"playlists": out["playlists"], "chapters": out["chapters"]}
|
|
209
|
+
|
|
210
|
+
def _encode_menu(self, m, pm, nav, fast):
|
|
211
|
+
"""Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
|
|
212
|
+
stream made with their commands and encoded with its picture."""
|
|
213
|
+
buttons = []
|
|
214
|
+
for b, pb in zip(m.buttons, pm.buttons):
|
|
215
|
+
w, h, sel = encode.picture(b.selected)
|
|
216
|
+
if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
|
|
217
|
+
raise BdmvError(f"menu {m.id!r}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
|
|
218
|
+
f"is not inside the {WIDTH} x {HEIGHT} frame")
|
|
219
|
+
act = encode.picture(b.activated, (w, h))[2] if b.activated else None
|
|
220
|
+
nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
|
|
221
|
+
buttons.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor,
|
|
222
|
+
nav.button(pb) if b.action else [], dict(b.nav), b.auto))
|
|
223
|
+
_, frame_rate = FORMATS[m.fps]
|
|
224
|
+
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
225
|
+
sup = os.path.join(work, "menu.sup")
|
|
226
|
+
with open(sup, "wb") as f:
|
|
227
|
+
f.write(igs.ig_stream(buttons, frame_rate, default=m.default,
|
|
228
|
+
follow_selection=nav.follows_selection(m.id)))
|
|
229
|
+
encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _lang(code):
|
|
233
|
+
code = (code or "").lower()
|
|
234
|
+
return code if len(code) == 3 and code.isalpha() else lang3(code)
|