bdmvkit 0.3.0__tar.gz → 0.5.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.3.0/bdmvkit.egg-info → bdmvkit-0.5.0}/PKG-INFO +30 -4
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/README.md +29 -3
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/__init__.py +3 -2
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/__main__.py +18 -10
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/disc.py +107 -32
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/encode.py +97 -11
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/nav.py +1 -1
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/program.py +17 -1
- {bdmvkit-0.3.0 → bdmvkit-0.5.0/bdmvkit.egg-info}/PKG-INFO +30 -4
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/SOURCES.txt +3 -1
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/pyproject.toml +1 -1
- bdmvkit-0.5.0/tests/test_motion.py +104 -0
- bdmvkit-0.5.0/tests/test_popup.py +107 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/COPYING +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/COPYING.LESSER +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/clip.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/errors.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/formats.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/hdmv.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/igs.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/m2ts.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/pgs.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/playlist.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/uops.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/entry_points.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/top_level.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/setup.cfg +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_disc.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_files.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_menus.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_nav.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.5.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.5.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,14 +35,14 @@ only to encode.
|
|
|
35
35
|
|
|
36
36
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
37
37
|
|
|
38
|
-
**Status: 0.
|
|
38
|
+
**Status: 0.5.** Titles with chapters, sound and subtitle tracks;
|
|
39
39
|
playlists (in order, at random or shuffled) and play all; menus whose
|
|
40
40
|
buttons start titles, chapters, playlists and other menus, switch sound and
|
|
41
41
|
subtitle tracks, or open a menu on a chosen button; loop counts, end-action
|
|
42
42
|
overrides, link transitions, remote keys a title turns off, and 720p. This
|
|
43
43
|
navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
|
|
44
|
-
writes, byte for byte.
|
|
45
|
-
|
|
44
|
+
writes, byte for byte. 0.4 adds pop-up menus over a playing title, 0.5
|
|
45
|
+
menus with motion or music.
|
|
46
46
|
|
|
47
47
|
## Why
|
|
48
48
|
|
|
@@ -132,6 +132,28 @@ disc.add_menu("main", "menu.png", [
|
|
|
132
132
|
], loop=3, end="playall", seconds=10)
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
A menu's background may be a video, and a menu may have music. Such a menu
|
|
136
|
+
plays again when it ends, from `loop_point` (seconds) the second time
|
|
137
|
+
round; it lasts as long as its video, else its music, unless `seconds`
|
|
138
|
+
says:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A title can have a pop-up menu: buttons over its picture, opened with the
|
|
145
|
+
remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
|
|
146
|
+
as a menu's are; a button that only switches the sound or subtitle track
|
|
147
|
+
closes the menu.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
disc.add_popup(a, [
|
|
151
|
+
MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
|
|
152
|
+
action="menu:main"),
|
|
153
|
+
MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
|
|
154
|
+
])
|
|
155
|
+
```
|
|
156
|
+
|
|
135
157
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
136
158
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
137
159
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -196,6 +218,10 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
196
218
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
197
219
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
198
220
|
by itself;
|
|
221
|
+
* that a menu with a video and music plays again from its loop point, and
|
|
222
|
+
that one with a loop count goes on by itself after playing that often;
|
|
223
|
+
* that a pop-up menu opens over a playing title with the Pop-up Menu key and
|
|
224
|
+
its buttons start another title or a chapter;
|
|
199
225
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
200
226
|
|
|
201
227
|
The files' fixed bytes are checked as well.
|
|
@@ -18,14 +18,14 @@ only to encode.
|
|
|
18
18
|
|
|
19
19
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
20
20
|
|
|
21
|
-
**Status: 0.
|
|
21
|
+
**Status: 0.5.** Titles with chapters, sound and subtitle tracks;
|
|
22
22
|
playlists (in order, at random or shuffled) and play all; menus whose
|
|
23
23
|
buttons start titles, chapters, playlists and other menus, switch sound and
|
|
24
24
|
subtitle tracks, or open a menu on a chosen button; loop counts, end-action
|
|
25
25
|
overrides, link transitions, remote keys a title turns off, and 720p. This
|
|
26
26
|
navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
|
|
27
|
-
writes, byte for byte.
|
|
28
|
-
|
|
27
|
+
writes, byte for byte. 0.4 adds pop-up menus over a playing title, 0.5
|
|
28
|
+
menus with motion or music.
|
|
29
29
|
|
|
30
30
|
## Why
|
|
31
31
|
|
|
@@ -115,6 +115,28 @@ disc.add_menu("main", "menu.png", [
|
|
|
115
115
|
], loop=3, end="playall", seconds=10)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
A menu's background may be a video, and a menu may have music. Such a menu
|
|
119
|
+
plays again when it ends, from `loop_point` (seconds) the second time
|
|
120
|
+
round; it lasts as long as its video, else its music, unless `seconds`
|
|
121
|
+
says:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A title can have a pop-up menu: buttons over its picture, opened with the
|
|
128
|
+
remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
|
|
129
|
+
as a menu's are; a button that only switches the sound or subtitle track
|
|
130
|
+
closes the menu.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
disc.add_popup(a, [
|
|
134
|
+
MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
|
|
135
|
+
action="menu:main"),
|
|
136
|
+
MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
|
|
137
|
+
])
|
|
138
|
+
```
|
|
139
|
+
|
|
118
140
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
119
141
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
120
142
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -179,6 +201,10 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
179
201
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
180
202
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
181
203
|
by itself;
|
|
204
|
+
* that a menu with a video and music plays again from its loop point, and
|
|
205
|
+
that one with a loop count goes on by itself after playing that often;
|
|
206
|
+
* that a pop-up menu opens over a playing title with the Pop-up Menu key and
|
|
207
|
+
its buttons start another title or a chapter;
|
|
182
208
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
183
209
|
|
|
184
210
|
The files' fixed bytes are checked as well.
|
|
@@ -1,7 +1,8 @@
|
|
|
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), playlists
|
|
4
|
+
and subtitle tracks), playlists, menus (still, or with motion or music)
|
|
5
|
+
and pop-up menus (`MenuButton`).
|
|
5
6
|
* `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
|
|
6
7
|
playlists, menu clips, transitions, actions) and its writer,
|
|
7
8
|
`nav.write_program`, with the navigation commands (`nav.Navigation`).
|
|
@@ -15,5 +16,5 @@
|
|
|
15
16
|
from .disc import Disc, MenuButton
|
|
16
17
|
from .errors import BdmvError
|
|
17
18
|
|
|
18
|
-
__version__ = "0.
|
|
19
|
+
__version__ = "0.5.0"
|
|
19
20
|
__all__ = ["Disc", "MenuButton", "BdmvError"]
|
|
@@ -10,8 +10,10 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
|
|
|
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
12
|
({"up": id, ...}), "auto", "audio", "subtitle" and "override"; a menu
|
|
13
|
-
"default" (a button id), "fps", "loop", "end", "seconds"
|
|
14
|
-
|
|
13
|
+
"default" (a button id), "fps", "loop", "end", "seconds", "prohibit",
|
|
14
|
+
"music" (a sound file) and "loop_point" (its "background" may be a video); a
|
|
15
|
+
title "default_audio", "default_subtitle", "prohibit" and "popup" ({"buttons":
|
|
16
|
+
[...], "default"}: its pop-up menu, buttons as a menu's). The disc may have
|
|
15
17
|
"playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
|
|
16
18
|
"first_play" and "title_key".
|
|
17
19
|
"""
|
|
@@ -75,15 +77,21 @@ def main(argv=None):
|
|
|
75
77
|
def file(name):
|
|
76
78
|
return os.path.join(base, name) if name else None
|
|
77
79
|
|
|
80
|
+
def buttons(spec_buttons):
|
|
81
|
+
return [MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
|
|
82
|
+
file(b.get("activated")), file(b.get("normal")), b.get("action"),
|
|
83
|
+
dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
|
|
84
|
+
b.get("subtitle"), b.get("override")) for b in spec_buttons]
|
|
85
|
+
|
|
86
|
+
for n, t in enumerate(spec.get("titles", []), 1):
|
|
87
|
+
if t.get("popup"):
|
|
88
|
+
disc.add_popup(n, buttons(t["popup"]["buttons"]), t["popup"].get("default"))
|
|
78
89
|
for m in spec.get("menus", []):
|
|
79
|
-
disc.add_menu(m["id"], file(m["background"]), [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
|
|
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)))
|
|
90
|
+
disc.add_menu(m["id"], file(m["background"]), buttons(m["buttons"]),
|
|
91
|
+
fps=_fps(m.get("fps", "24000/1001")), default=m.get("default"),
|
|
92
|
+
loop=m.get("loop"), end=m.get("end"), prohibit=m.get("prohibit", []),
|
|
93
|
+
seconds=m.get("seconds"), music=file(m.get("music")),
|
|
94
|
+
loop_point=m.get("loop_point"))
|
|
87
95
|
res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
|
|
88
96
|
except (BdmvError, OSError, KeyError, ValueError) as e:
|
|
89
97
|
print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
|
|
@@ -34,10 +34,11 @@ import dataclasses
|
|
|
34
34
|
import os
|
|
35
35
|
import tempfile
|
|
36
36
|
|
|
37
|
-
from . import encode, igs, program
|
|
37
|
+
from . import encode, hdmv, igs, program
|
|
38
38
|
from .errors import BdmvError
|
|
39
39
|
from .formats import AUDIO_PID, CODECS, FORMATS, HEIGHT, PG_PID, WIDTH, lang3
|
|
40
40
|
from .m2ts import stream_pids
|
|
41
|
+
from .program import parse_action
|
|
41
42
|
from .nav import DIRS, Navigation, _write, chapter_marks, write_program # noqa: F401
|
|
42
43
|
|
|
43
44
|
|
|
@@ -73,14 +74,16 @@ class MenuButton:
|
|
|
73
74
|
@dataclasses.dataclass
|
|
74
75
|
class Menu:
|
|
75
76
|
id: str
|
|
76
|
-
background: str # picture file, fitted into the HD frame
|
|
77
|
+
background: str # picture (or video) file, fitted into the HD frame
|
|
77
78
|
buttons: list
|
|
78
79
|
fps: tuple = (24000, 1001)
|
|
79
80
|
default: str = None # the button selected first (default: the first)
|
|
80
81
|
loop: int = None # held this many times its length, then `end`
|
|
81
82
|
end: str = None
|
|
82
83
|
prohibit: list = dataclasses.field(default_factory=list)
|
|
83
|
-
seconds: float =
|
|
84
|
+
seconds: float = None # its clip's length (default: 1, or the video's or music's)
|
|
85
|
+
music: str = None # sound file played with it
|
|
86
|
+
loop_point: float = None # where it plays again from (seconds), with motion or music
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
def _check_streams(n, title):
|
|
@@ -103,6 +106,7 @@ class Disc:
|
|
|
103
106
|
self.titles = []
|
|
104
107
|
self.menus = []
|
|
105
108
|
self.playlists = []
|
|
109
|
+
self.popups = {} # title number -> (buttons, default)
|
|
106
110
|
self.first_play = first_play
|
|
107
111
|
self.title_key = title_key
|
|
108
112
|
|
|
@@ -148,23 +152,42 @@ class Disc:
|
|
|
148
152
|
self.playlists.append(program.Playlist(playlist_id, out, order, end))
|
|
149
153
|
|
|
150
154
|
def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None, loop=None,
|
|
151
|
-
end=None, prohibit=(), seconds=
|
|
152
|
-
"""Add a menu: `background` a picture, `buttons`
|
|
153
|
-
menu's clip (`seconds` long
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
end=None, prohibit=(), seconds=None, music=None, loop_point=None):
|
|
156
|
+
"""Add a menu: `background` a picture or a video, `buttons`
|
|
157
|
+
MenuButtons, `music` a sound file. The menu's clip (`seconds` long:
|
|
158
|
+
by default 1, or as long as the video, else the music) is made when
|
|
159
|
+
the disc is written (ffmpeg). A still menu holds its last picture;
|
|
160
|
+
one with a video or music plays again, from `loop_point` (seconds)
|
|
161
|
+
the second time round. With `loop`, it does so `loop` times (a
|
|
162
|
+
still one holds its picture for its length each time), then `end`."""
|
|
156
163
|
if tuple(fps) not in FORMATS:
|
|
157
164
|
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
158
165
|
if any(m.id == menu_id for m in self.menus):
|
|
159
166
|
raise BdmvError(f"there is a menu {menu_id!r} already")
|
|
160
167
|
if not buttons:
|
|
161
168
|
raise BdmvError(f"menu {menu_id!r} needs at least one button")
|
|
169
|
+
if loop_point is not None and loop_point < 0:
|
|
170
|
+
raise BdmvError(f"menu {menu_id!r}: loop point {loop_point} before the start")
|
|
162
171
|
self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
|
|
163
|
-
end, list(prohibit), seconds))
|
|
172
|
+
end, list(prohibit), seconds, music, loop_point))
|
|
164
173
|
|
|
165
|
-
def
|
|
174
|
+
def add_popup(self, title, buttons, default=None):
|
|
175
|
+
"""Give title `title` (its number) a pop-up menu: `buttons`
|
|
176
|
+
(MenuButtons, their pictures placed on the 1920 x 1080 picture over
|
|
177
|
+
the playing title) shown by the remote's Pop-up Menu key. A button
|
|
178
|
+
that switches the sound or subtitle track closes the menu; one that
|
|
179
|
+
goes elsewhere goes there. The menu's IG stream is muxed into the
|
|
180
|
+
title's clip when the disc is written."""
|
|
181
|
+
if not 1 <= title <= len(self.titles):
|
|
182
|
+
raise BdmvError(f"there is no title {title}")
|
|
183
|
+
if not buttons:
|
|
184
|
+
raise BdmvError(f"title {title}: a pop-up menu needs at least one button")
|
|
185
|
+
self.popups[title] = (list(buttons), default)
|
|
186
|
+
|
|
187
|
+
def program(self, play_all=True, probe=False):
|
|
166
188
|
"""The disc as a program.Program (titles "1", "2" ...; menus with no
|
|
167
|
-
clip yet: write makes them).
|
|
189
|
+
clip yet: write makes them). With `probe`, menu backgrounds are read
|
|
190
|
+
to tell videos from pictures (else they count as pictures)."""
|
|
168
191
|
n_titles = len(self.titles)
|
|
169
192
|
titles = []
|
|
170
193
|
for n, t in enumerate(self.titles, 1):
|
|
@@ -174,14 +197,18 @@ class Disc:
|
|
|
174
197
|
[program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
|
|
175
198
|
for k, (c, ch, lang) in enumerate(t.audio, 1)],
|
|
176
199
|
[program.Subtitle(_lang(lang), k == t.default_subtitle)
|
|
177
|
-
for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit),
|
|
201
|
+
popup=n in self.popups,
|
|
202
|
+
popup_buttons=[_button(b) for b in self.popups.get(n, ((), None))[0]]))
|
|
203
|
+
menus = []
|
|
204
|
+
for m in self.menus:
|
|
205
|
+
still = not (m.music or (probe and _motion(m)))
|
|
206
|
+
menus.append(program.Menu(
|
|
207
|
+
m.id, None, [_button(b) for b in m.buttons], fps=m.fps, default=m.default,
|
|
208
|
+
still=still,
|
|
209
|
+
still_time=max(1, round(_seconds(m))) if still and m.loop is not None else None,
|
|
210
|
+
loop_point=m.loop_point if not still else None, loop=m.loop, end=m.end,
|
|
211
|
+
music=bool(m.music), prohibit=list(m.prohibit)))
|
|
185
212
|
first_menu = f"menu:{self.menus[0].id}" if self.menus else None
|
|
186
213
|
return program.Program(titles, list(self.playlists), menus,
|
|
187
214
|
first_play=self.first_play or first_menu,
|
|
@@ -194,8 +221,8 @@ class Disc:
|
|
|
194
221
|
"chapters"}."""
|
|
195
222
|
if not self.titles:
|
|
196
223
|
raise BdmvError("a disc needs at least one title")
|
|
197
|
-
|
|
198
|
-
prog.
|
|
224
|
+
self.program(play_all).check() # (before any file is read)
|
|
225
|
+
prog = self.program(play_all, probe=True)
|
|
199
226
|
for n, t in enumerate(self.titles, 1):
|
|
200
227
|
_check_streams(n, t)
|
|
201
228
|
nav = Navigation(prog)
|
|
@@ -203,6 +230,10 @@ class Disc:
|
|
|
203
230
|
for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
|
|
204
231
|
pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
|
|
205
232
|
self._encode_menu(m, pm, nav, fast)
|
|
233
|
+
for n, (buttons, default) in sorted(self.popups.items()):
|
|
234
|
+
pt = prog.titles[n - 1]
|
|
235
|
+
pt.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{n:05d}.m2ts")
|
|
236
|
+
self._add_popup(self.titles[n - 1], buttons, default, pt, nav)
|
|
206
237
|
out = write_program(prog, out_dir, move=move)
|
|
207
238
|
return {"titles": len(self.titles), "menus": len(self.menus),
|
|
208
239
|
"playlists": out["playlists"], "chapters": out["chapters"]}
|
|
@@ -210,23 +241,67 @@ class Disc:
|
|
|
210
241
|
def _encode_menu(self, m, pm, nav, fast):
|
|
211
242
|
"""Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
|
|
212
243
|
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))
|
|
244
|
+
buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
|
|
223
245
|
_, frame_rate = FORMATS[m.fps]
|
|
224
246
|
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
225
247
|
sup = os.path.join(work, "menu.sup")
|
|
226
248
|
with open(sup, "wb") as f:
|
|
227
249
|
f.write(igs.ig_stream(buttons, frame_rate, default=m.default,
|
|
228
250
|
follow_selection=nav.follows_selection(m.id)))
|
|
229
|
-
encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m
|
|
251
|
+
encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=_seconds(m), fast=fast,
|
|
252
|
+
motion=_motion(m), music=m.music,
|
|
253
|
+
keys=[m.loop_point] if m.loop_point else [])
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _add_popup(self, t, buttons, default, pt, nav):
|
|
257
|
+
"""The title's clip with its pop-up menu's IG stream, as pt.clip."""
|
|
258
|
+
_, frame_rate = FORMATS[t.fps]
|
|
259
|
+
ig = _ig_buttons(f"title {pt.id}'s pop-up menu", buttons, pt.popup_buttons, nav,
|
|
260
|
+
popup=True)
|
|
261
|
+
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
262
|
+
sup = os.path.join(work, "popup.sup")
|
|
263
|
+
with open(sup, "wb") as f:
|
|
264
|
+
f.write(igs.ig_stream(ig, frame_rate, default=default, popup=True))
|
|
265
|
+
encode.add_ig_stream(t.clip, sup, pt.clip)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _motion(m):
|
|
269
|
+
"""Whether menu m's background is a video (asked once)."""
|
|
270
|
+
if not hasattr(m, "_motion"):
|
|
271
|
+
m._motion = encode.is_video(m.background)
|
|
272
|
+
return m._motion
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _seconds(m):
|
|
276
|
+
if m.seconds is not None:
|
|
277
|
+
return m.seconds
|
|
278
|
+
if _motion(m):
|
|
279
|
+
return encode.media_seconds(m.background)
|
|
280
|
+
return encode.media_seconds(m.music) if m.music else 1.0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _button(b):
|
|
284
|
+
return program.Button(b.id, b.action, b.audio, b.subtitle, override=b.override)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _ig_buttons(where, buttons, program_buttons, nav, popup=False):
|
|
288
|
+
"""MenuButtons as igs.Buttons: their pictures read, their commands the
|
|
289
|
+
navigation's (in a pop-up menu, a button that only switches streams
|
|
290
|
+
closes the menu too)."""
|
|
291
|
+
out = []
|
|
292
|
+
for b, pb in zip(buttons, program_buttons):
|
|
293
|
+
w, h, sel = encode.picture(b.selected)
|
|
294
|
+
if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
|
|
295
|
+
raise BdmvError(f"{where}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
|
|
296
|
+
f"is not inside the {WIDTH} x {HEIGHT} frame")
|
|
297
|
+
act = encode.picture(b.activated, (w, h))[2] if b.activated else None
|
|
298
|
+
nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
|
|
299
|
+
commands = nav.button(pb) if b.action else []
|
|
300
|
+
if popup and b.action and parse_action(b.action)[0] in ("audio", "subtitle"):
|
|
301
|
+
commands.append(hdmv.popup_off())
|
|
302
|
+
out.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor, commands, dict(b.nav),
|
|
303
|
+
b.auto))
|
|
304
|
+
return out
|
|
230
305
|
|
|
231
306
|
|
|
232
307
|
def _lang(code):
|
|
@@ -9,6 +9,7 @@ interlaced (1080i) stream, as the format has no 1080p at those rates.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import dataclasses
|
|
12
|
+
import os
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
|
|
@@ -184,22 +185,37 @@ def picture(path, size=None, ffmpeg=None):
|
|
|
184
185
|
return w, h, p.stdout
|
|
185
186
|
|
|
186
187
|
|
|
187
|
-
def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False, ffmpeg=None
|
|
188
|
-
|
|
189
|
-
`seconds
|
|
190
|
-
|
|
188
|
+
def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False, ffmpeg=None,
|
|
189
|
+
motion=False, music=None, keys=()):
|
|
190
|
+
"""A menu clip `seconds` long: the picture `background` (fitted into the
|
|
191
|
+
HD frame; with `motion`, a video played from its start), with the IG
|
|
192
|
+
stream `sup` (igs.ig_stream) as its buttons and, with `music`, that
|
|
193
|
+
sound file as its sound track (AC-3, padded with silence to the
|
|
194
|
+
length). A still menu's playlist holds its last picture; one with
|
|
195
|
+
motion or music plays again. `keys`: times (s) that become key frames
|
|
196
|
+
(the loop point, so its mark lands on it)."""
|
|
191
197
|
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
192
198
|
if not ffmpeg:
|
|
193
199
|
raise BdmvError("encoding needs ffmpeg (with libx264)")
|
|
194
200
|
from .igs import patch_ig_stream_type
|
|
195
201
|
num, den = fps
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
picture = ["-i", background] if motion else \
|
|
203
|
+
["-loop", "1", "-framerate", f"{num}/{den}", "-t", f"{seconds:.3f}", "-i", background]
|
|
204
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y"] + picture + \
|
|
205
|
+
["-f", "sup", "-i", sup] + (["-i", music] if music else [])
|
|
206
|
+
graph = f"[0:v]{fit_filter(fps)},format=yuv420p[v]"
|
|
207
|
+
maps = ["-map", "[v]", "-map", "1:0"]
|
|
208
|
+
ids = ["-streamid", f"0:{VIDEO_PID}", "-streamid", f"1:{IG_PID}"]
|
|
209
|
+
sound = []
|
|
210
|
+
if music:
|
|
211
|
+
graph += ";[2:a:0]apad[a]"
|
|
212
|
+
maps += ["-map", "[a]"]
|
|
213
|
+
ids += ["-streamid", f"2:{AUDIO_PID}"]
|
|
214
|
+
sound = audio_args("ac3", 2)
|
|
215
|
+
keys = [t for t in keys if t and t > 0]
|
|
216
|
+
cmd += ["-filter_complex", graph] + maps + video_args(fps, MAX_VIDEO_BPS, fast, keys) + \
|
|
217
|
+
sound + ["-c:s", "copy"] + ids + \
|
|
218
|
+
["-t", f"{seconds:.3f}", "-f", "mpegts", "-mpegts_m2ts_mode", "1",
|
|
203
219
|
"-mpegts_pmt_start_pid", str(PMT_PID), out]
|
|
204
220
|
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
205
221
|
if p.returncode != 0:
|
|
@@ -207,3 +223,73 @@ def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False
|
|
|
207
223
|
f"{p.stderr.strip()[-800:]}")
|
|
208
224
|
patch_ig_stream_type(out)
|
|
209
225
|
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_video(path, ffmpeg=None):
|
|
229
|
+
"""Whether `path` is a video (moving pictures) rather than a picture."""
|
|
230
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg") or "ffmpeg"
|
|
231
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
232
|
+
p = subprocess.run([probe, "-v", "error", "-show_entries", "format=format_name", "-of",
|
|
233
|
+
"csv=p=0", path], capture_output=True, text=True)
|
|
234
|
+
name = p.stdout.strip()
|
|
235
|
+
if not name:
|
|
236
|
+
raise BdmvError(f"cannot read {path}")
|
|
237
|
+
return not (name == "image2" or name.endswith("_pipe"))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def media_seconds(path, ffmpeg=None):
|
|
241
|
+
"""How long a video or sound file lasts (seconds)."""
|
|
242
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg") or "ffmpeg"
|
|
243
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
244
|
+
p = subprocess.run([probe, "-v", "error", "-show_entries", "format=duration", "-of",
|
|
245
|
+
"csv=p=0", path], capture_output=True, text=True)
|
|
246
|
+
try:
|
|
247
|
+
return float(p.stdout.strip())
|
|
248
|
+
except ValueError:
|
|
249
|
+
raise BdmvError(f"cannot tell how long {path} is")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def add_ig_stream(clip, sup, out, ffmpeg=None):
|
|
253
|
+
"""`clip` with the IG stream `sup` (igs.ig_stream, a pop-up menu) muxed
|
|
254
|
+
in, as `out`: the clip's streams copied with their PIDs and their times,
|
|
255
|
+
the buttons with IG_PID at the clip's first picture, marked Interactive
|
|
256
|
+
Graphics in the PMT. (Segments before the first picture are before the
|
|
257
|
+
play item's start, and players leave them out: so the buttons are timed
|
|
258
|
+
at it, not at the clip's own start, which the sound may begin.)"""
|
|
259
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
260
|
+
if not ffmpeg:
|
|
261
|
+
raise BdmvError("muxing needs ffmpeg")
|
|
262
|
+
from .igs import patch_ig_stream_type
|
|
263
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
264
|
+
p = subprocess.run([probe, "-v", "error", "-show_entries", "stream=index,id", "-of",
|
|
265
|
+
"csv=p=0", clip], capture_output=True, text=True)
|
|
266
|
+
# (ffprobe lists a stream again under its program: one each, by index)
|
|
267
|
+
streams = dict(line.split(",")[:2] for line in p.stdout.split() if "," in line)
|
|
268
|
+
pids = [int(streams[k], 16) for k in sorted(streams, key=int)]
|
|
269
|
+
if not pids:
|
|
270
|
+
raise BdmvError(f"cannot read the streams of {clip}")
|
|
271
|
+
from .m2ts import scan_m2ts
|
|
272
|
+
start = scan_m2ts(clip).first_pts
|
|
273
|
+
with open(sup, "rb") as f:
|
|
274
|
+
data = bytearray(f.read())
|
|
275
|
+
i = 0
|
|
276
|
+
while i + 13 <= len(data): # each segment's PTS and DTS: the start
|
|
277
|
+
data[i + 2:i + 10] = (start & 0xFFFFFFFF).to_bytes(4, "big") * 2
|
|
278
|
+
i += 13 + int.from_bytes(data[i + 11:i + 13], "big")
|
|
279
|
+
timed = out + ".sup"
|
|
280
|
+
with open(timed, "wb") as f:
|
|
281
|
+
f.write(data)
|
|
282
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y", "-copyts", "-i", clip,
|
|
283
|
+
"-f", "sup", "-i", timed, "-map", "0", "-map", "1:0", "-c", "copy"]
|
|
284
|
+
for k, pid in enumerate(pids + [IG_PID]):
|
|
285
|
+
cmd += ["-streamid", f"{k}:{pid}"]
|
|
286
|
+
cmd += ["-f", "mpegts", "-mpegts_m2ts_mode", "1", "-mpegts_pmt_start_pid", str(PMT_PID),
|
|
287
|
+
out]
|
|
288
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
289
|
+
os.remove(timed)
|
|
290
|
+
if p.returncode != 0:
|
|
291
|
+
raise BdmvError(f"ffmpeg could not add the pop-up menu to {clip}: "
|
|
292
|
+
f"{p.stderr.strip()[-800:]}")
|
|
293
|
+
patch_ig_stream_type(out)
|
|
294
|
+
return out
|
|
295
|
+
|
|
@@ -121,7 +121,7 @@ class Navigation:
|
|
|
121
121
|
# own default subtitles over the chosen ones (_start)
|
|
122
122
|
self.stream_links = any(
|
|
123
123
|
(b.audio or b.subtitle is not None) and b.action and parse_action(b.action)[0] in PLAYS
|
|
124
|
-
for
|
|
124
|
+
for b in p.buttons())
|
|
125
125
|
|
|
126
126
|
def menu_number(self, menu_id):
|
|
127
127
|
return self.numbers.get(f"menu:{menu_id}")
|
|
@@ -74,6 +74,10 @@ class Title:
|
|
|
74
74
|
end: str = None # action after it (None: stop)
|
|
75
75
|
prohibit: list = dataclasses.field(default_factory=list) # uops.KEYS names
|
|
76
76
|
popup: bool = False # its clip carries a pop-up menu (an IG stream)
|
|
77
|
+
popup_buttons: list = dataclasses.field(default_factory=list)
|
|
78
|
+
# (Buttons of a pop-up menu the Program writes into the clip, as
|
|
79
|
+
# Disc.add_popup does; empty when the clip's IG stream is made elsewhere
|
|
80
|
+
# from a menu's buttons)
|
|
77
81
|
hold: bool = False # each chapter a picture held until Next (a slideshow)
|
|
78
82
|
|
|
79
83
|
|
|
@@ -147,8 +151,15 @@ class Program:
|
|
|
147
151
|
if m.loop is not None:
|
|
148
152
|
out.append(m.end)
|
|
149
153
|
out += [t.end for t in self.titles] + [p.end for p in self.playlists]
|
|
154
|
+
for t in self.titles:
|
|
155
|
+
out += [b.action for b in t.popup_buttons] + [b.override for b in t.popup_buttons]
|
|
150
156
|
return [a for a in out if a]
|
|
151
157
|
|
|
158
|
+
def buttons(self):
|
|
159
|
+
"""Every button: the menus', then the pop-up menus' of the titles."""
|
|
160
|
+
return [b for m in self.menus for b in m.buttons] + \
|
|
161
|
+
[b for t in self.titles for b in t.popup_buttons]
|
|
162
|
+
|
|
152
163
|
@property
|
|
153
164
|
def uses_playall(self):
|
|
154
165
|
return "playall" in self.actions()
|
|
@@ -157,7 +168,7 @@ class Program:
|
|
|
157
168
|
def overrides(self):
|
|
158
169
|
"""End-action overrides buttons use, in a fixed order (1-based numbers
|
|
159
170
|
in the override register)."""
|
|
160
|
-
return sorted({b.override for
|
|
171
|
+
return sorted({b.override for b in self.buttons() if b.override})
|
|
161
172
|
|
|
162
173
|
@property
|
|
163
174
|
def link_transitions(self):
|
|
@@ -221,6 +232,11 @@ class Program:
|
|
|
221
232
|
if b.transition and b.transition not in self.transitions:
|
|
222
233
|
raise BdmvError(f"menu {m.id!r}, button {b.id!r}: no transition "
|
|
223
234
|
f"{b.transition!r}")
|
|
235
|
+
for t in self.titles:
|
|
236
|
+
for b in t.popup_buttons:
|
|
237
|
+
if b.transition:
|
|
238
|
+
raise BdmvError(f"title {t.id!r}: a pop-up menu's button ({b.id!r}) "
|
|
239
|
+
"cannot play a transition")
|
|
224
240
|
|
|
225
241
|
|
|
226
242
|
def transition_key(button):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bdmvkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,14 +35,14 @@ only to encode.
|
|
|
35
35
|
|
|
36
36
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
37
37
|
|
|
38
|
-
**Status: 0.
|
|
38
|
+
**Status: 0.5.** Titles with chapters, sound and subtitle tracks;
|
|
39
39
|
playlists (in order, at random or shuffled) and play all; menus whose
|
|
40
40
|
buttons start titles, chapters, playlists and other menus, switch sound and
|
|
41
41
|
subtitle tracks, or open a menu on a chosen button; loop counts, end-action
|
|
42
42
|
overrides, link transitions, remote keys a title turns off, and 720p. This
|
|
43
43
|
navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
|
|
44
|
-
writes, byte for byte.
|
|
45
|
-
|
|
44
|
+
writes, byte for byte. 0.4 adds pop-up menus over a playing title, 0.5
|
|
45
|
+
menus with motion or music.
|
|
46
46
|
|
|
47
47
|
## Why
|
|
48
48
|
|
|
@@ -132,6 +132,28 @@ disc.add_menu("main", "menu.png", [
|
|
|
132
132
|
], loop=3, end="playall", seconds=10)
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
A menu's background may be a video, and a menu may have music. Such a menu
|
|
136
|
+
plays again when it ends, from `loop_point` (seconds) the second time
|
|
137
|
+
round; it lasts as long as its video, else its music, unless `seconds`
|
|
138
|
+
says:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A title can have a pop-up menu: buttons over its picture, opened with the
|
|
145
|
+
remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
|
|
146
|
+
as a menu's are; a button that only switches the sound or subtitle track
|
|
147
|
+
closes the menu.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
disc.add_popup(a, [
|
|
151
|
+
MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
|
|
152
|
+
action="menu:main"),
|
|
153
|
+
MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
|
|
154
|
+
])
|
|
155
|
+
```
|
|
156
|
+
|
|
135
157
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
136
158
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
137
159
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -196,6 +218,10 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
196
218
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
197
219
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
198
220
|
by itself;
|
|
221
|
+
* that a menu with a video and music plays again from its loop point, and
|
|
222
|
+
that one with a loop count goes on by itself after playing that often;
|
|
223
|
+
* that a pop-up menu opens over a playing title with the Pop-up Menu key and
|
|
224
|
+
its buttons start another title or a chapter;
|
|
199
225
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
200
226
|
|
|
201
227
|
The files' fixed bytes are checked as well.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bdmvkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LGPL-3.0-or-later"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Menus with motion or music: a video as the background, a sound file as
|
|
2
|
+
the menu's sound, played again from a loop point; and a menu with music and
|
|
3
|
+
a loop count that goes on by itself. Played by libbluray."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
from bdmvkit import Disc, MenuButton
|
|
12
|
+
from bdmvkit.encode import encode_clip, is_video, media_seconds
|
|
13
|
+
from bdmvkit.formats import AUDIO_PID, CODING_AC3, IG_PID
|
|
14
|
+
from bdmvkit.m2ts import stream_pids
|
|
15
|
+
|
|
16
|
+
from .test_disc import ENCODE_OK, source
|
|
17
|
+
from .test_menus import png
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from . import bluray_sim
|
|
21
|
+
LIBBLURAY = bluray_sim.load_library()
|
|
22
|
+
except Exception: # pragma: no cover
|
|
23
|
+
LIBBLURAY = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tone(path, seconds):
|
|
27
|
+
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i",
|
|
28
|
+
f"sine=frequency=330:d={seconds}:r=48000", path], check=True)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@unittest.skipUnless(ENCODE_OK and LIBBLURAY, "needs ffmpeg with libx264, and libbluray")
|
|
33
|
+
class TestMotionMenus(unittest.TestCase):
|
|
34
|
+
"""Menu "moving": a 2 s video with 3 s of music, from 0.5 s the second time
|
|
35
|
+
(the video decides its length). Menu "music": a picture with 1.5 s of
|
|
36
|
+
music, played twice, then title 1."""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def setUpClass(cls):
|
|
40
|
+
cls.tmp = d = tempfile.mkdtemp(prefix="bdmvkit-motion-")
|
|
41
|
+
a = encode_clip(source(os.path.join(d, "a.mp4"), 2), os.path.join(d, "a.m2ts"),
|
|
42
|
+
fast=True)
|
|
43
|
+
cls.video = source(os.path.join(d, "bg.mp4"), 2, tone=False)
|
|
44
|
+
cls.bg = png(os.path.join(d, "bg.png"), 1920, 1080, "navy")
|
|
45
|
+
lit = png(os.path.join(d, "lit.png"), 300, 60, "orange")
|
|
46
|
+
disc = Disc(first_play="menu:moving")
|
|
47
|
+
disc.add_title(a, end="menu:music")
|
|
48
|
+
disc.add_menu("moving", cls.video, [MenuButton("play", 160, 900, lit, action="title:1")],
|
|
49
|
+
music=tone(os.path.join(d, "m.wav"), 3), loop_point=0.5)
|
|
50
|
+
disc.add_menu("music", cls.bg, [MenuButton("play", 160, 900, lit, action="title:1")],
|
|
51
|
+
music=tone(os.path.join(d, "m2.wav"), 1.5), loop=2, end="title:1")
|
|
52
|
+
cls.out = os.path.join(d, "disc")
|
|
53
|
+
disc.write(cls.out, play_all=False, fast=True)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def tearDownClass(cls):
|
|
57
|
+
shutil.rmtree(cls.tmp, ignore_errors=True)
|
|
58
|
+
|
|
59
|
+
def clip(self, n):
|
|
60
|
+
return os.path.join(self.out, "BDMV", "STREAM", f"{n:05d}.m2ts")
|
|
61
|
+
|
|
62
|
+
def test_pictures_and_videos_told_apart(self):
|
|
63
|
+
self.assertTrue(is_video(self.video))
|
|
64
|
+
self.assertFalse(is_video(self.bg))
|
|
65
|
+
|
|
66
|
+
def test_clips_carry_the_music_and_the_buttons(self):
|
|
67
|
+
for n in (2, 3):
|
|
68
|
+
pids = stream_pids(self.clip(n))
|
|
69
|
+
self.assertEqual(pids.get(AUDIO_PID), CODING_AC3)
|
|
70
|
+
self.assertIn(IG_PID, pids)
|
|
71
|
+
# the video decides the moving menu's length; the music the other's
|
|
72
|
+
self.assertAlmostEqual(media_seconds(self.clip(2)), 2, delta=0.2)
|
|
73
|
+
self.assertAlmostEqual(media_seconds(self.clip(3)), 1.5, delta=0.2)
|
|
74
|
+
|
|
75
|
+
def test_read_back(self):
|
|
76
|
+
with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
|
|
77
|
+
moving, music = disc.playlist(2), disc.playlist(3)
|
|
78
|
+
self.assertEqual(moving["audio"], [(CODING_AC3, 3, "und")])
|
|
79
|
+
# two marks: the start, and the loop point
|
|
80
|
+
self.assertEqual(len(moving["chapters"]), 2)
|
|
81
|
+
self.assertAlmostEqual(moving["chapters"][1], 0.5, delta=0.1)
|
|
82
|
+
self.assertEqual(len(music["chapters"]), 1)
|
|
83
|
+
|
|
84
|
+
def test_played_again_from_the_loop_point(self):
|
|
85
|
+
with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
|
|
86
|
+
ev = disc.play(max_bytes=40 * 2**20)
|
|
87
|
+
seen = [e for e in ev if e[0] in ("playlist", "at_chapter")]
|
|
88
|
+
# the moving menu, then again and again from its loop point (mark 2)
|
|
89
|
+
self.assertEqual(seen[:4], [("playlist", 2), ("at_chapter", 1),
|
|
90
|
+
("playlist", 2), ("at_chapter", 2)])
|
|
91
|
+
|
|
92
|
+
def test_a_loop_count_goes_on_by_itself(self):
|
|
93
|
+
disc = Disc(first_play="menu:music")
|
|
94
|
+
disc.add_title(self.clip(1), end="stop")
|
|
95
|
+
lit = os.path.join(self.tmp, "lit.png")
|
|
96
|
+
disc.add_menu("music", self.bg, [MenuButton("play", 160, 900, lit, action="title:1")],
|
|
97
|
+
music=os.path.join(self.tmp, "m2.wav"), loop=2, end="title:1")
|
|
98
|
+
out = os.path.join(self.tmp, "disc2")
|
|
99
|
+
disc.write(out, play_all=False, fast=True)
|
|
100
|
+
with bluray_sim.Disc(LIBBLURAY, out) as d:
|
|
101
|
+
ev = d.play()
|
|
102
|
+
plays = [v for k, v in ev if k == "playlist"]
|
|
103
|
+
# the menu twice (playlist 2), then title 1
|
|
104
|
+
self.assertEqual(plays[:3], [2, 2, 1])
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pop-up menus: buttons over a playing title, muxed into its clip as an IG
|
|
2
|
+
stream, opened with the remote's Pop-up Menu key and played by libbluray."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from bdmvkit import BdmvError, Disc, MenuButton, hdmv
|
|
10
|
+
from bdmvkit.encode import encode_clip
|
|
11
|
+
from bdmvkit.formats import CODING_IG, IG_PID
|
|
12
|
+
from bdmvkit.m2ts import stream_pids
|
|
13
|
+
|
|
14
|
+
from .test_disc import ENCODE_OK, source
|
|
15
|
+
from .test_menus import png
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from . import bluray_sim
|
|
19
|
+
LIBBLURAY = bluray_sim.load_library()
|
|
20
|
+
except Exception: # pragma: no cover
|
|
21
|
+
LIBBLURAY = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@unittest.skipUnless(ENCODE_OK and LIBBLURAY, "needs ffmpeg with libx264, and libbluray")
|
|
25
|
+
class TestPopup(unittest.TestCase):
|
|
26
|
+
"""Title 1 has a pop-up menu: TWO (title 2) and SCENE (its own chapter 2)."""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def setUpClass(cls):
|
|
30
|
+
cls.tmp = d = tempfile.mkdtemp(prefix="bdmvkit-popup-")
|
|
31
|
+
a = encode_clip(source(os.path.join(d, "a.mp4"), 8), os.path.join(d, "a.m2ts"),
|
|
32
|
+
chapters=[0, 4], fast=True)
|
|
33
|
+
b = encode_clip(source(os.path.join(d, "b.mp4"), 2), os.path.join(d, "b.m2ts"),
|
|
34
|
+
fast=True)
|
|
35
|
+
lit = png(os.path.join(d, "lit.png"), 300, 60, "orange")
|
|
36
|
+
off = png(os.path.join(d, "off.png"), 300, 60, "gray")
|
|
37
|
+
disc = Disc()
|
|
38
|
+
cls.a = a
|
|
39
|
+
disc.add_title(a, chapters=[0, 4], end="stop")
|
|
40
|
+
disc.add_title(b, end="stop")
|
|
41
|
+
disc.add_popup(1, [MenuButton("two", 160, 900, lit, normal=off, action="title:2"),
|
|
42
|
+
MenuButton("scene", 760, 900, lit, normal=off,
|
|
43
|
+
action="chapter:1:2"),
|
|
44
|
+
MenuButton("sub", 1360, 900, lit, normal=off, action="subtitle:off")])
|
|
45
|
+
cls.out = os.path.join(d, "disc")
|
|
46
|
+
disc.write(cls.out, play_all=False, fast=True)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def tearDownClass(cls):
|
|
50
|
+
shutil.rmtree(cls.tmp, ignore_errors=True)
|
|
51
|
+
|
|
52
|
+
def test_in_the_clip(self):
|
|
53
|
+
clip = os.path.join(self.out, "BDMV", "STREAM", "00001.m2ts")
|
|
54
|
+
self.assertEqual(stream_pids(clip).get(IG_PID), CODING_IG)
|
|
55
|
+
# (the clip given is left as it was)
|
|
56
|
+
self.assertNotIn(IG_PID, stream_pids(self.a))
|
|
57
|
+
# the second title has none
|
|
58
|
+
self.assertNotIn(IG_PID, stream_pids(os.path.join(self.out, "BDMV", "STREAM",
|
|
59
|
+
"00002.m2ts")))
|
|
60
|
+
|
|
61
|
+
def test_opens_and_its_buttons_work(self):
|
|
62
|
+
with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
|
|
63
|
+
disc.info()
|
|
64
|
+
ev = [e for e in disc.play(keys=("live:popup", "live:enter"))
|
|
65
|
+
if e[0] in ("title", "key", "menu") and e[1] != 0xFFFF]
|
|
66
|
+
keys = [e for e in ev if e[0] in ("title", "key")]
|
|
67
|
+
# title 1, the pop-up opened over it, TWO chosen: title 2
|
|
68
|
+
self.assertEqual(keys[:4], [("title", 1), ("key", "live:popup"), ("key", "live:enter"),
|
|
69
|
+
("title", 2)])
|
|
70
|
+
drawn = [d for e in ev if e[0] == "menu" for d in e[1]]
|
|
71
|
+
xs = sorted({d[1] for d in drawn if d[0] == "draw"})
|
|
72
|
+
self.assertTrue({160, 760, 1360} <= set(xs), drawn)
|
|
73
|
+
|
|
74
|
+
def test_a_chapter_from_the_popup(self):
|
|
75
|
+
with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
|
|
76
|
+
disc.info()
|
|
77
|
+
ev = disc.play(keys=("live:popup", "live:right", "live:enter"))
|
|
78
|
+
after = ev[next(i for i, e in enumerate(ev) if e == ("key", "live:enter")):]
|
|
79
|
+
self.assertIn(("at_chapter", 2), after)
|
|
80
|
+
|
|
81
|
+
def test_a_stream_button_closes_the_menu(self):
|
|
82
|
+
disc = Disc()
|
|
83
|
+
disc.add_title("x.m2ts")
|
|
84
|
+
p = disc.program()
|
|
85
|
+
from bdmvkit.disc import _ig_buttons
|
|
86
|
+
from bdmvkit.nav import Navigation
|
|
87
|
+
lit = os.path.join(self.tmp, "lit.png")
|
|
88
|
+
nav = Navigation(p)
|
|
89
|
+
sub = MenuButton("sub", 100, 100, lit, action="subtitle:off")
|
|
90
|
+
two = MenuButton("one", 100, 300, lit, action="title:1")
|
|
91
|
+
from bdmvkit.disc import _button
|
|
92
|
+
ig = _ig_buttons("t", [sub, two], [_button(sub), _button(two)], nav, popup=True)
|
|
93
|
+
self.assertEqual(ig[0].commands[-1], hdmv.popup_off())
|
|
94
|
+
self.assertNotIn(hdmv.popup_off(), ig[1].commands)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestChecks(unittest.TestCase):
|
|
98
|
+
def test_refused(self):
|
|
99
|
+
disc = Disc()
|
|
100
|
+
with self.assertRaises(BdmvError):
|
|
101
|
+
disc.add_popup(1, [MenuButton("a", 0, 0, "a.png", action="title:1")])
|
|
102
|
+
disc.add_title("x.m2ts")
|
|
103
|
+
with self.assertRaises(BdmvError):
|
|
104
|
+
disc.add_popup(1, [])
|
|
105
|
+
disc.add_popup(1, [MenuButton("a", 0, 0, "a.png", action="title:9")])
|
|
106
|
+
with self.assertRaises(BdmvError): # leads nowhere
|
|
107
|
+
disc.program().check()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|