bdmvkit 0.2.0__tar.gz → 0.4.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.4.0}/PKG-INFO +80 -13
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/README.md +79 -12
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/__init__.py +5 -2
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/__main__.py +26 -9
- bdmvkit-0.4.0/bdmvkit/disc.py +281 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/encode.py +121 -17
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/formats.py +26 -1
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/hdmv.py +5 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/m2ts.py +25 -1
- bdmvkit-0.4.0/bdmvkit/nav.py +417 -0
- bdmvkit-0.4.0/bdmvkit/program.py +245 -0
- bdmvkit-0.4.0/bdmvkit/uops.py +32 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0/bdmvkit.egg-info}/PKG-INFO +80 -13
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/SOURCES.txt +6 -1
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/pyproject.toml +1 -1
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_disc.py +68 -2
- bdmvkit-0.4.0/tests/test_nav.py +244 -0
- bdmvkit-0.4.0/tests/test_popup.py +107 -0
- bdmvkit-0.2.0/bdmvkit/disc.py +0 -257
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/COPYING +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/COPYING.LESSER +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/clip.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/errors.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/igs.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/pgs.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/playlist.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/entry_points.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/top_level.txt +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/setup.cfg +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_files.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_menus.py +0 -0
- {bdmvkit-0.2.0 → bdmvkit-0.4.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.4.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.4.** 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. 0.4 adds pop-up menus over a playing title. Menus
|
|
45
|
+
with motion or music come 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,44 @@ 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
|
+
A title can have a pop-up menu: buttons over its picture, opened with the
|
|
136
|
+
remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
|
|
137
|
+
as a menu's are; a button that only switches the sound or subtitle track
|
|
138
|
+
closes the menu.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
disc.add_popup(a, [
|
|
142
|
+
MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
|
|
143
|
+
action="menu:main"),
|
|
144
|
+
MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
|
|
145
|
+
])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
149
|
+
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
150
|
+
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
151
|
+
menu's buttons run, for tools that make their own IG streams.
|
|
98
152
|
|
|
99
153
|
```sh
|
|
100
154
|
bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
|
|
@@ -115,12 +169,20 @@ bdmvkit build disc.json out
|
|
|
115
169
|
How the disc is laid out:
|
|
116
170
|
|
|
117
171
|
* 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.
|
|
172
|
+
progressive frames in a 1080i stream, as the format asks. A program can
|
|
173
|
+
also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
|
|
174
|
+
`encode_clip` makes 1080 clips.
|
|
119
175
|
* Sound is AC-3, LPCM or DTS.
|
|
120
176
|
* Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
|
|
121
177
|
from pictures, and `encode_clip(..., sups=[...])` muxes them in.
|
|
122
178
|
* First play and the Top Menu key open the first menu, or start title 1
|
|
123
|
-
when there is none.
|
|
179
|
+
when there is none (`Disc(first_play=..., title_key=...)` choose).
|
|
180
|
+
* Each title, playlist, play all and menu is a Blu-ray title, in that
|
|
181
|
+
order; random and shuffled playlists play a playlist file per item, picked
|
|
182
|
+
with the player's random number command. The general registers 1 to 8
|
|
183
|
+
hold the last menu, the button a link selects, a menu's loop count, an
|
|
184
|
+
end-action override, a stream choice, and a random playlist's state
|
|
185
|
+
(`nav.py`).
|
|
124
186
|
* A menu is an interactive title after the titles: its picture held as a
|
|
125
187
|
still, with its buttons as an Interactive Graphics stream in its clip.
|
|
126
188
|
The menus are encoded when the disc is written (ffmpeg).
|
|
@@ -144,7 +206,12 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
144
206
|
* that running the disc from first play reaches the titles and chapters in
|
|
145
207
|
order;
|
|
146
208
|
* that a menu's buttons, chosen and pressed with the remote's keys, start
|
|
147
|
-
the titles, chapters and menus they link to
|
|
209
|
+
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
210
|
+
playlist plays each item once, and that a menu with a loop count goes on
|
|
211
|
+
by itself;
|
|
212
|
+
* that a pop-up menu opens over a playing title with the Pop-up Menu key and
|
|
213
|
+
its buttons start another title or a chapter;
|
|
214
|
+
* the commands every kind of action, title, playlist and menu turns into.
|
|
148
215
|
|
|
149
216
|
The files' fixed bytes are checked as well.
|
|
150
217
|
|
|
@@ -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.4.** 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. 0.4 adds pop-up menus over a playing title. Menus
|
|
28
|
+
with motion or music come 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,44 @@ 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
|
+
A title can have a pop-up menu: buttons over its picture, opened with the
|
|
119
|
+
remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
|
|
120
|
+
as a menu's are; a button that only switches the sound or subtitle track
|
|
121
|
+
closes the menu.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
disc.add_popup(a, [
|
|
125
|
+
MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
|
|
126
|
+
action="menu:main"),
|
|
127
|
+
MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
|
|
128
|
+
])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
132
|
+
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
133
|
+
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
134
|
+
menu's buttons run, for tools that make their own IG streams.
|
|
81
135
|
|
|
82
136
|
```sh
|
|
83
137
|
bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
|
|
@@ -98,12 +152,20 @@ bdmvkit build disc.json out
|
|
|
98
152
|
How the disc is laid out:
|
|
99
153
|
|
|
100
154
|
* 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.
|
|
155
|
+
progressive frames in a 1080i stream, as the format asks. A program can
|
|
156
|
+
also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
|
|
157
|
+
`encode_clip` makes 1080 clips.
|
|
102
158
|
* Sound is AC-3, LPCM or DTS.
|
|
103
159
|
* Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
|
|
104
160
|
from pictures, and `encode_clip(..., sups=[...])` muxes them in.
|
|
105
161
|
* First play and the Top Menu key open the first menu, or start title 1
|
|
106
|
-
when there is none.
|
|
162
|
+
when there is none (`Disc(first_play=..., title_key=...)` choose).
|
|
163
|
+
* Each title, playlist, play all and menu is a Blu-ray title, in that
|
|
164
|
+
order; random and shuffled playlists play a playlist file per item, picked
|
|
165
|
+
with the player's random number command. The general registers 1 to 8
|
|
166
|
+
hold the last menu, the button a link selects, a menu's loop count, an
|
|
167
|
+
end-action override, a stream choice, and a random playlist's state
|
|
168
|
+
(`nav.py`).
|
|
107
169
|
* A menu is an interactive title after the titles: its picture held as a
|
|
108
170
|
still, with its buttons as an Interactive Graphics stream in its clip.
|
|
109
171
|
The menus are encoded when the disc is written (ffmpeg).
|
|
@@ -127,7 +189,12 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
127
189
|
* that running the disc from first play reaches the titles and chapters in
|
|
128
190
|
order;
|
|
129
191
|
* that a menu's buttons, chosen and pressed with the remote's keys, start
|
|
130
|
-
the titles, chapters and menus they link to
|
|
192
|
+
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
193
|
+
playlist plays each item once, and that a menu with a loop count goes on
|
|
194
|
+
by itself;
|
|
195
|
+
* that a pop-up menu opens over a playing title with the Pop-up Menu key and
|
|
196
|
+
its buttons start another title or a chapter;
|
|
197
|
+
* the commands every kind of action, title, playlist and menu turns into.
|
|
131
198
|
|
|
132
199
|
The files' fixed bytes are checked as well.
|
|
133
200
|
|
|
@@ -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, menus and pop-up 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.4.0"
|
|
16
19
|
__all__ = ["Disc", "MenuButton", "BdmvError"]
|
|
@@ -9,7 +9,12 @@ 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", "prohibit" and "popup" ({"buttons":
|
|
15
|
+
[...], "default"}: its pop-up menu, buttons as a menu's). The disc may have
|
|
16
|
+
"playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
|
|
17
|
+
"first_play" and "title_key".
|
|
13
18
|
"""
|
|
14
19
|
|
|
15
20
|
import argparse
|
|
@@ -56,23 +61,35 @@ def main(argv=None):
|
|
|
56
61
|
with open(args.disc, encoding="utf-8") as f:
|
|
57
62
|
spec = json.load(f)
|
|
58
63
|
base = os.path.dirname(os.path.abspath(args.disc))
|
|
59
|
-
disc = Disc()
|
|
64
|
+
disc = Disc(spec.get("first_play"), spec.get("title_key"))
|
|
60
65
|
for t in spec.get("titles", []):
|
|
61
66
|
disc.add_title(os.path.join(base, t["clip"]), fps=_fps(t.get("fps", "24000/1001")),
|
|
62
67
|
chapters=t.get("chapters", [0]),
|
|
63
68
|
audio=[tuple(a) for a in t.get("audio", [["ac3", 2, "und"]])],
|
|
64
|
-
subtitles=t.get("subtitles", []), end=t.get("end")
|
|
69
|
+
subtitles=t.get("subtitles", []), end=t.get("end"),
|
|
70
|
+
default_audio=t.get("default_audio"),
|
|
71
|
+
default_subtitle=t.get("default_subtitle"),
|
|
72
|
+
prohibit=t.get("prohibit", []))
|
|
73
|
+
for p in spec.get("playlists", []):
|
|
74
|
+
disc.add_playlist(p["id"], p["items"], p.get("order", "sequential"), p.get("end"))
|
|
65
75
|
|
|
66
76
|
def file(name):
|
|
67
77
|
return os.path.join(base, name) if name else None
|
|
68
78
|
|
|
79
|
+
def buttons(spec_buttons):
|
|
80
|
+
return [MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
|
|
81
|
+
file(b.get("activated")), file(b.get("normal")), b.get("action"),
|
|
82
|
+
dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
|
|
83
|
+
b.get("subtitle"), b.get("override")) for b in spec_buttons]
|
|
84
|
+
|
|
85
|
+
for n, t in enumerate(spec.get("titles", []), 1):
|
|
86
|
+
if t.get("popup"):
|
|
87
|
+
disc.add_popup(n, buttons(t["popup"]["buttons"]), t["popup"].get("default"))
|
|
69
88
|
for m in spec.get("menus", []):
|
|
70
|
-
disc.add_menu(m["id"], file(m["background"]), [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
|
|
75
|
-
default=m.get("default"))
|
|
89
|
+
disc.add_menu(m["id"], file(m["background"]), buttons(m["buttons"]),
|
|
90
|
+
fps=_fps(m.get("fps", "24000/1001")), default=m.get("default"),
|
|
91
|
+
loop=m.get("loop"), end=m.get("end"), prohibit=m.get("prohibit", []),
|
|
92
|
+
seconds=float(m.get("seconds", 1.0)))
|
|
76
93
|
res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
|
|
77
94
|
except (BdmvError, OSError, KeyError, ValueError) as e:
|
|
78
95
|
print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
|
|
@@ -0,0 +1,281 @@
|
|
|
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, hdmv, 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 .program import parse_action
|
|
42
|
+
from .nav import DIRS, Navigation, _write, chapter_marks, write_program # noqa: F401
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass
|
|
46
|
+
class Title:
|
|
47
|
+
clip: str # an encoded .m2ts
|
|
48
|
+
fps: tuple = (24000, 1001) # its frame rate: 24000/1001, 25/1 or 30000/1001
|
|
49
|
+
chapters: list = dataclasses.field(default_factory=lambda: [0.0]) # starts, seconds
|
|
50
|
+
audio: list = dataclasses.field(default_factory=lambda: [("ac3", 2, "und")])
|
|
51
|
+
subtitles: list = dataclasses.field(default_factory=list) # PG tracks: languages
|
|
52
|
+
end: str = None # the action after it (None: the next title with play_all)
|
|
53
|
+
default_audio: int = None # the sound track the disc starts with (1-based)
|
|
54
|
+
default_subtitle: int = None # the subtitle track shown when it starts (None: off)
|
|
55
|
+
prohibit: list = dataclasses.field(default_factory=list) # uops.KEYS names
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass
|
|
59
|
+
class MenuButton:
|
|
60
|
+
id: str
|
|
61
|
+
x: int # top-left on the 1920 x 1080 menu
|
|
62
|
+
y: int
|
|
63
|
+
selected: str # picture files (PNG ...): selected,
|
|
64
|
+
activated: str = None # activated (default: the selected one),
|
|
65
|
+
normal: str = None # and before it is selected (default: nothing)
|
|
66
|
+
action: str = None # where it goes: an action (program.py), e.g. "title:2"
|
|
67
|
+
nav: dict = dataclasses.field(default_factory=dict) # direction -> button id
|
|
68
|
+
auto: bool = False # runs as soon as it is selected
|
|
69
|
+
audio: int = None # switch to this sound track (1-based) first
|
|
70
|
+
subtitle: int = None # ... and to this subtitle track (0: off)
|
|
71
|
+
override: str = None # the end action of the title it starts, instead of its own
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclasses.dataclass
|
|
75
|
+
class Menu:
|
|
76
|
+
id: str
|
|
77
|
+
background: str # picture file, fitted into the HD frame
|
|
78
|
+
buttons: list
|
|
79
|
+
fps: tuple = (24000, 1001)
|
|
80
|
+
default: str = None # the button selected first (default: the first)
|
|
81
|
+
loop: int = None # held this many times its length, then `end`
|
|
82
|
+
end: str = None
|
|
83
|
+
prohibit: list = dataclasses.field(default_factory=list)
|
|
84
|
+
seconds: float = 1.0 # its clip's length
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_streams(n, title):
|
|
88
|
+
"""The title's sound and subtitle tracks must be in its clip, with the
|
|
89
|
+
PIDs the playlist gives them (encode.encode_clip makes them so)."""
|
|
90
|
+
pids = stream_pids(title.clip)
|
|
91
|
+
want = [("audio", AUDIO_PID, k) for k in range(len(title.audio))] + \
|
|
92
|
+
[("subtitle", PG_PID, k) for k in range(len(title.subtitles))]
|
|
93
|
+
for kind, first, k in want:
|
|
94
|
+
if first + k not in pids:
|
|
95
|
+
raise BdmvError(f"title {n}: {kind} track {k + 1} is not in {title.clip} "
|
|
96
|
+
f"(no stream with PID {first + k:#06x})")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Disc:
|
|
100
|
+
"""first_play: the action the disc starts with (default: the first menu,
|
|
101
|
+
or title 1); title_key: the Top Menu key's (default: the same)."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, first_play=None, title_key=None):
|
|
104
|
+
self.titles = []
|
|
105
|
+
self.menus = []
|
|
106
|
+
self.playlists = []
|
|
107
|
+
self.popups = {} # title number -> (buttons, default)
|
|
108
|
+
self.first_play = first_play
|
|
109
|
+
self.title_key = title_key
|
|
110
|
+
|
|
111
|
+
def add_title(self, clip, fps=(24000, 1001), chapters=(0.0,), audio=(("ac3", 2, "und"),),
|
|
112
|
+
subtitles=(), end=None, default_audio=None, default_subtitle=None,
|
|
113
|
+
prohibit=()):
|
|
114
|
+
"""Add a title; returns its number (1-based), its id in actions
|
|
115
|
+
("title:2"). audio: (codec, channels, language) per track, codec
|
|
116
|
+
"ac3", "lpcm" or "dts"; languages are ISO 639-1 ("en") or 639-2
|
|
117
|
+
("eng") codes. end: the action when it ends (default: the next title
|
|
118
|
+
with play_all). default_audio: the sound track the disc starts with;
|
|
119
|
+
default_subtitle: the subtitle track shown from its start (else
|
|
120
|
+
none); prohibit: remote keys it does not allow (uops.KEYS)."""
|
|
121
|
+
if tuple(fps) not in FORMATS:
|
|
122
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
123
|
+
for codec, _, _ in audio:
|
|
124
|
+
if codec not in CODECS:
|
|
125
|
+
raise BdmvError(f"unknown audio codec {codec!r} (ac3, lpcm or dts)")
|
|
126
|
+
if not audio:
|
|
127
|
+
raise BdmvError("a title needs at least one audio track")
|
|
128
|
+
for name, k, n in (("default_audio", default_audio, len(audio)),
|
|
129
|
+
("default_subtitle", default_subtitle, len(subtitles))):
|
|
130
|
+
if k is not None and not 1 <= k <= n:
|
|
131
|
+
raise BdmvError(f"{name} {k}: the title has {n} such tracks")
|
|
132
|
+
self.titles.append(Title(clip, tuple(fps), list(chapters) or [0.0], list(audio),
|
|
133
|
+
list(subtitles), end, default_audio, default_subtitle,
|
|
134
|
+
list(prohibit)))
|
|
135
|
+
return len(self.titles)
|
|
136
|
+
|
|
137
|
+
def add_playlist(self, playlist_id, items, order="sequential", end=None):
|
|
138
|
+
"""Add a playlist: items (title, first chapter, last chapter), or
|
|
139
|
+
title numbers for whole titles, played one after another
|
|
140
|
+
("sequential"), picked at random as many times as there are items
|
|
141
|
+
("random"), or each once in a random order ("shuffle"). It is
|
|
142
|
+
started by "playlist:<id>"."""
|
|
143
|
+
if any(p.id == playlist_id for p in self.playlists):
|
|
144
|
+
raise BdmvError(f"there is a playlist {playlist_id!r} already")
|
|
145
|
+
out = []
|
|
146
|
+
for it in items:
|
|
147
|
+
n, first, last = (it, 1, None) if isinstance(it, int) else tuple(it) + (None,) * \
|
|
148
|
+
(3 - len(it))
|
|
149
|
+
out.append(program.PlaylistItem(str(n), first or 1, last))
|
|
150
|
+
self.playlists.append(program.Playlist(playlist_id, out, order, end))
|
|
151
|
+
|
|
152
|
+
def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None, loop=None,
|
|
153
|
+
end=None, prohibit=(), seconds=1.0):
|
|
154
|
+
"""Add a menu: `background` a picture, `buttons` MenuButtons. The
|
|
155
|
+
menu's clip (`seconds` long) is made when the disc is written
|
|
156
|
+
(ffmpeg). It holds its last picture; with `loop`, only for `loop`
|
|
157
|
+
times its length, then it does `end`."""
|
|
158
|
+
if tuple(fps) not in FORMATS:
|
|
159
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
160
|
+
if any(m.id == menu_id for m in self.menus):
|
|
161
|
+
raise BdmvError(f"there is a menu {menu_id!r} already")
|
|
162
|
+
if not buttons:
|
|
163
|
+
raise BdmvError(f"menu {menu_id!r} needs at least one button")
|
|
164
|
+
self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
|
|
165
|
+
end, list(prohibit), seconds))
|
|
166
|
+
|
|
167
|
+
def add_popup(self, title, buttons, default=None):
|
|
168
|
+
"""Give title `title` (its number) a pop-up menu: `buttons`
|
|
169
|
+
(MenuButtons, their pictures placed on the 1920 x 1080 picture over
|
|
170
|
+
the playing title) shown by the remote's Pop-up Menu key. A button
|
|
171
|
+
that switches the sound or subtitle track closes the menu; one that
|
|
172
|
+
goes elsewhere goes there. The menu's IG stream is muxed into the
|
|
173
|
+
title's clip when the disc is written."""
|
|
174
|
+
if not 1 <= title <= len(self.titles):
|
|
175
|
+
raise BdmvError(f"there is no title {title}")
|
|
176
|
+
if not buttons:
|
|
177
|
+
raise BdmvError(f"title {title}: a pop-up menu needs at least one button")
|
|
178
|
+
self.popups[title] = (list(buttons), default)
|
|
179
|
+
|
|
180
|
+
def program(self, play_all=True):
|
|
181
|
+
"""The disc as a program.Program (titles "1", "2" ...; menus with no
|
|
182
|
+
clip yet: write makes them)."""
|
|
183
|
+
n_titles = len(self.titles)
|
|
184
|
+
titles = []
|
|
185
|
+
for n, t in enumerate(self.titles, 1):
|
|
186
|
+
end = t.end or (f"title:{n + 1}" if play_all and n < n_titles else None)
|
|
187
|
+
titles.append(program.Title(
|
|
188
|
+
str(n), t.clip, t.fps, list(t.chapters),
|
|
189
|
+
[program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
|
|
190
|
+
for k, (c, ch, lang) in enumerate(t.audio, 1)],
|
|
191
|
+
[program.Subtitle(_lang(lang), k == t.default_subtitle)
|
|
192
|
+
for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit),
|
|
193
|
+
popup=n in self.popups,
|
|
194
|
+
popup_buttons=[_button(b) for b in self.popups.get(n, ((), None))[0]]))
|
|
195
|
+
menus = [program.Menu(m.id, None,
|
|
196
|
+
[_button(b) for b in m.buttons],
|
|
197
|
+
fps=m.fps, default=m.default, loop=m.loop, end=m.end,
|
|
198
|
+
still_time=max(1, round(m.seconds)) if m.loop is not None
|
|
199
|
+
else None,
|
|
200
|
+
prohibit=list(m.prohibit)) for m in self.menus]
|
|
201
|
+
first_menu = f"menu:{self.menus[0].id}" if self.menus else None
|
|
202
|
+
return program.Program(titles, list(self.playlists), menus,
|
|
203
|
+
first_play=self.first_play or first_menu,
|
|
204
|
+
title_key=self.title_key)
|
|
205
|
+
|
|
206
|
+
def write(self, out_dir, move=False, play_all=True, fast=False):
|
|
207
|
+
"""Write the BDMV folder into `out_dir` (copying the clips into
|
|
208
|
+
BDMV/STREAM, or moving them with `move`; menu clips are encoded,
|
|
209
|
+
quickly with `fast`). Returns {"titles", "menus", "playlists",
|
|
210
|
+
"chapters"}."""
|
|
211
|
+
if not self.titles:
|
|
212
|
+
raise BdmvError("a disc needs at least one title")
|
|
213
|
+
prog = self.program(play_all)
|
|
214
|
+
prog.check() # (the menus' clips are made below)
|
|
215
|
+
for n, t in enumerate(self.titles, 1):
|
|
216
|
+
_check_streams(n, t)
|
|
217
|
+
nav = Navigation(prog)
|
|
218
|
+
os.makedirs(os.path.join(out_dir, "BDMV", "STREAM"), exist_ok=True)
|
|
219
|
+
for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
|
|
220
|
+
pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
|
|
221
|
+
self._encode_menu(m, pm, nav, fast)
|
|
222
|
+
for n, (buttons, default) in sorted(self.popups.items()):
|
|
223
|
+
pt = prog.titles[n - 1]
|
|
224
|
+
pt.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{n:05d}.m2ts")
|
|
225
|
+
self._add_popup(self.titles[n - 1], buttons, default, pt, nav)
|
|
226
|
+
out = write_program(prog, out_dir, move=move)
|
|
227
|
+
return {"titles": len(self.titles), "menus": len(self.menus),
|
|
228
|
+
"playlists": out["playlists"], "chapters": out["chapters"]}
|
|
229
|
+
|
|
230
|
+
def _encode_menu(self, m, pm, nav, fast):
|
|
231
|
+
"""Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
|
|
232
|
+
stream made with their commands and encoded with its picture."""
|
|
233
|
+
buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
|
|
234
|
+
_, frame_rate = FORMATS[m.fps]
|
|
235
|
+
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
236
|
+
sup = os.path.join(work, "menu.sup")
|
|
237
|
+
with open(sup, "wb") as f:
|
|
238
|
+
f.write(igs.ig_stream(buttons, frame_rate, default=m.default,
|
|
239
|
+
follow_selection=nav.follows_selection(m.id)))
|
|
240
|
+
encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _add_popup(self, t, buttons, default, pt, nav):
|
|
244
|
+
"""The title's clip with its pop-up menu's IG stream, as pt.clip."""
|
|
245
|
+
_, frame_rate = FORMATS[t.fps]
|
|
246
|
+
ig = _ig_buttons(f"title {pt.id}'s pop-up menu", buttons, pt.popup_buttons, nav,
|
|
247
|
+
popup=True)
|
|
248
|
+
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
249
|
+
sup = os.path.join(work, "popup.sup")
|
|
250
|
+
with open(sup, "wb") as f:
|
|
251
|
+
f.write(igs.ig_stream(ig, frame_rate, default=default, popup=True))
|
|
252
|
+
encode.add_ig_stream(t.clip, sup, pt.clip)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _button(b):
|
|
256
|
+
return program.Button(b.id, b.action, b.audio, b.subtitle, override=b.override)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _ig_buttons(where, buttons, program_buttons, nav, popup=False):
|
|
260
|
+
"""MenuButtons as igs.Buttons: their pictures read, their commands the
|
|
261
|
+
navigation's (in a pop-up menu, a button that only switches streams
|
|
262
|
+
closes the menu too)."""
|
|
263
|
+
out = []
|
|
264
|
+
for b, pb in zip(buttons, program_buttons):
|
|
265
|
+
w, h, sel = encode.picture(b.selected)
|
|
266
|
+
if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
|
|
267
|
+
raise BdmvError(f"{where}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
|
|
268
|
+
f"is not inside the {WIDTH} x {HEIGHT} frame")
|
|
269
|
+
act = encode.picture(b.activated, (w, h))[2] if b.activated else None
|
|
270
|
+
nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
|
|
271
|
+
commands = nav.button(pb) if b.action else []
|
|
272
|
+
if popup and b.action and parse_action(b.action)[0] in ("audio", "subtitle"):
|
|
273
|
+
commands.append(hdmv.popup_off())
|
|
274
|
+
out.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor, commands, dict(b.nav),
|
|
275
|
+
b.auto))
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _lang(code):
|
|
280
|
+
code = (code or "").lower()
|
|
281
|
+
return code if len(code) == 3 and code.isalpha() else lang3(code)
|