bdmvkit 0.3.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.3.0/bdmvkit.egg-info → bdmvkit-0.4.0}/PKG-INFO +19 -4
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/README.md +18 -3
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/__init__.py +2 -2
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/__main__.py +15 -9
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/disc.py +61 -14
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/encode.py +47 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/nav.py +1 -1
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/program.py +17 -1
- {bdmvkit-0.3.0 → bdmvkit-0.4.0/bdmvkit.egg-info}/PKG-INFO +19 -4
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/SOURCES.txt +2 -1
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/pyproject.toml +1 -1
- bdmvkit-0.4.0/tests/test_popup.py +107 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/COPYING +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/COPYING.LESSER +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/clip.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/errors.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/formats.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/hdmv.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/igs.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/m2ts.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/pgs.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/playlist.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/uops.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/entry_points.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/top_level.txt +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/setup.cfg +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_disc.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_files.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_menus.py +0 -0
- {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_nav.py +0 -0
- {bdmvkit-0.3.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,14 +35,14 @@ only to encode.
|
|
|
35
35
|
|
|
36
36
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
37
37
|
|
|
38
|
-
**Status: 0.
|
|
38
|
+
**Status: 0.4.** 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. Menus
|
|
45
|
+
with motion or music come next.
|
|
46
46
|
|
|
47
47
|
## Why
|
|
48
48
|
|
|
@@ -132,6 +132,19 @@ disc.add_menu("main", "menu.png", [
|
|
|
132
132
|
], loop=3, end="playall", seconds=10)
|
|
133
133
|
```
|
|
134
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
|
+
|
|
135
148
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
136
149
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
137
150
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -196,6 +209,8 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
196
209
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
197
210
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
198
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;
|
|
199
214
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
200
215
|
|
|
201
216
|
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.4.** 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. Menus
|
|
28
|
+
with motion or music come next.
|
|
29
29
|
|
|
30
30
|
## Why
|
|
31
31
|
|
|
@@ -115,6 +115,19 @@ disc.add_menu("main", "menu.png", [
|
|
|
115
115
|
], loop=3, end="playall", seconds=10)
|
|
116
116
|
```
|
|
117
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
|
+
|
|
118
131
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
119
132
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
120
133
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -179,6 +192,8 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
179
192
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
180
193
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
181
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;
|
|
182
197
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
183
198
|
|
|
184
199
|
The files' fixed bytes are checked as well.
|
|
@@ -1,7 +1,7 @@
|
|
|
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 and menus (`MenuButton`).
|
|
4
|
+
and subtitle tracks), playlists, menus and pop-up menus (`MenuButton`).
|
|
5
5
|
* `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
|
|
6
6
|
playlists, menu clips, transitions, actions) and its writer,
|
|
7
7
|
`nav.write_program`, with the navigation commands (`nav.Navigation`).
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
from .disc import Disc, MenuButton
|
|
16
16
|
from .errors import BdmvError
|
|
17
17
|
|
|
18
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.4.0"
|
|
19
19
|
__all__ = ["Disc", "MenuButton", "BdmvError"]
|
|
@@ -11,7 +11,8 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
|
|
|
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
13
|
"default" (a button id), "fps", "loop", "end", "seconds" and "prohibit"; a
|
|
14
|
-
title "default_audio", "default_subtitle"
|
|
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
|
|
15
16
|
"playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
|
|
16
17
|
"first_play" and "title_key".
|
|
17
18
|
"""
|
|
@@ -75,15 +76,20 @@ def main(argv=None):
|
|
|
75
76
|
def file(name):
|
|
76
77
|
return os.path.join(base, name) if name else None
|
|
77
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"))
|
|
78
88
|
for m in spec.get("menus", []):
|
|
79
|
-
disc.add_menu(m["id"], file(m["background"]), [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
b.get("subtitle"), b.get("override"))
|
|
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)))
|
|
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)))
|
|
87
93
|
res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
|
|
88
94
|
except (BdmvError, OSError, KeyError, ValueError) as e:
|
|
89
95
|
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
|
|
|
@@ -103,6 +104,7 @@ class Disc:
|
|
|
103
104
|
self.titles = []
|
|
104
105
|
self.menus = []
|
|
105
106
|
self.playlists = []
|
|
107
|
+
self.popups = {} # title number -> (buttons, default)
|
|
106
108
|
self.first_play = first_play
|
|
107
109
|
self.title_key = title_key
|
|
108
110
|
|
|
@@ -162,6 +164,19 @@ class Disc:
|
|
|
162
164
|
self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
|
|
163
165
|
end, list(prohibit), seconds))
|
|
164
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
|
+
|
|
165
180
|
def program(self, play_all=True):
|
|
166
181
|
"""The disc as a program.Program (titles "1", "2" ...; menus with no
|
|
167
182
|
clip yet: write makes them)."""
|
|
@@ -174,10 +189,11 @@ class Disc:
|
|
|
174
189
|
[program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
|
|
175
190
|
for k, (c, ch, lang) in enumerate(t.audio, 1)],
|
|
176
191
|
[program.Subtitle(_lang(lang), k == t.default_subtitle)
|
|
177
|
-
for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit)
|
|
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]]))
|
|
178
195
|
menus = [program.Menu(m.id, None,
|
|
179
|
-
[
|
|
180
|
-
override=b.override) for b in m.buttons],
|
|
196
|
+
[_button(b) for b in m.buttons],
|
|
181
197
|
fps=m.fps, default=m.default, loop=m.loop, end=m.end,
|
|
182
198
|
still_time=max(1, round(m.seconds)) if m.loop is not None
|
|
183
199
|
else None,
|
|
@@ -203,6 +219,10 @@ class Disc:
|
|
|
203
219
|
for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
|
|
204
220
|
pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
|
|
205
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)
|
|
206
226
|
out = write_program(prog, out_dir, move=move)
|
|
207
227
|
return {"titles": len(self.titles), "menus": len(self.menus),
|
|
208
228
|
"playlists": out["playlists"], "chapters": out["chapters"]}
|
|
@@ -210,16 +230,7 @@ class Disc:
|
|
|
210
230
|
def _encode_menu(self, m, pm, nav, fast):
|
|
211
231
|
"""Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
|
|
212
232
|
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))
|
|
233
|
+
buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
|
|
223
234
|
_, frame_rate = FORMATS[m.fps]
|
|
224
235
|
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
225
236
|
sup = os.path.join(work, "menu.sup")
|
|
@@ -229,6 +240,42 @@ class Disc:
|
|
|
229
240
|
encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
|
|
230
241
|
|
|
231
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
|
+
|
|
232
279
|
def _lang(code):
|
|
233
280
|
code = (code or "").lower()
|
|
234
281
|
return code if len(code) == 3 and code.isalpha() else lang3(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
|
|
|
@@ -207,3 +208,49 @@ def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False
|
|
|
207
208
|
f"{p.stderr.strip()[-800:]}")
|
|
208
209
|
patch_ig_stream_type(out)
|
|
209
210
|
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def add_ig_stream(clip, sup, out, ffmpeg=None):
|
|
214
|
+
"""`clip` with the IG stream `sup` (igs.ig_stream, a pop-up menu) muxed
|
|
215
|
+
in, as `out`: the clip's streams copied with their PIDs and their times,
|
|
216
|
+
the buttons with IG_PID at the clip's first picture, marked Interactive
|
|
217
|
+
Graphics in the PMT. (Segments before the first picture are before the
|
|
218
|
+
play item's start, and players leave them out: so the buttons are timed
|
|
219
|
+
at it, not at the clip's own start, which the sound may begin.)"""
|
|
220
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
221
|
+
if not ffmpeg:
|
|
222
|
+
raise BdmvError("muxing needs ffmpeg")
|
|
223
|
+
from .igs import patch_ig_stream_type
|
|
224
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
225
|
+
p = subprocess.run([probe, "-v", "error", "-show_entries", "stream=index,id", "-of",
|
|
226
|
+
"csv=p=0", clip], capture_output=True, text=True)
|
|
227
|
+
# (ffprobe lists a stream again under its program: one each, by index)
|
|
228
|
+
streams = dict(line.split(",")[:2] for line in p.stdout.split() if "," in line)
|
|
229
|
+
pids = [int(streams[k], 16) for k in sorted(streams, key=int)]
|
|
230
|
+
if not pids:
|
|
231
|
+
raise BdmvError(f"cannot read the streams of {clip}")
|
|
232
|
+
from .m2ts import scan_m2ts
|
|
233
|
+
start = scan_m2ts(clip).first_pts
|
|
234
|
+
with open(sup, "rb") as f:
|
|
235
|
+
data = bytearray(f.read())
|
|
236
|
+
i = 0
|
|
237
|
+
while i + 13 <= len(data): # each segment's PTS and DTS: the start
|
|
238
|
+
data[i + 2:i + 10] = (start & 0xFFFFFFFF).to_bytes(4, "big") * 2
|
|
239
|
+
i += 13 + int.from_bytes(data[i + 11:i + 13], "big")
|
|
240
|
+
timed = out + ".sup"
|
|
241
|
+
with open(timed, "wb") as f:
|
|
242
|
+
f.write(data)
|
|
243
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y", "-copyts", "-i", clip,
|
|
244
|
+
"-f", "sup", "-i", timed, "-map", "0", "-map", "1:0", "-c", "copy"]
|
|
245
|
+
for k, pid in enumerate(pids + [IG_PID]):
|
|
246
|
+
cmd += ["-streamid", f"{k}:{pid}"]
|
|
247
|
+
cmd += ["-f", "mpegts", "-mpegts_m2ts_mode", "1", "-mpegts_pmt_start_pid", str(PMT_PID),
|
|
248
|
+
out]
|
|
249
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
250
|
+
os.remove(timed)
|
|
251
|
+
if p.returncode != 0:
|
|
252
|
+
raise BdmvError(f"ffmpeg could not add the pop-up menu to {clip}: "
|
|
253
|
+
f"{p.stderr.strip()[-800:]}")
|
|
254
|
+
patch_ig_stream_type(out)
|
|
255
|
+
return out
|
|
256
|
+
|
|
@@ -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.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,14 +35,14 @@ only to encode.
|
|
|
35
35
|
|
|
36
36
|
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
37
37
|
|
|
38
|
-
**Status: 0.
|
|
38
|
+
**Status: 0.4.** 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. Menus
|
|
45
|
+
with motion or music come next.
|
|
46
46
|
|
|
47
47
|
## Why
|
|
48
48
|
|
|
@@ -132,6 +132,19 @@ disc.add_menu("main", "menu.png", [
|
|
|
132
132
|
], loop=3, end="playall", seconds=10)
|
|
133
133
|
```
|
|
134
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
|
+
|
|
135
148
|
Underneath, `bdmvkit.program` describes a disc made of encoded clips
|
|
136
149
|
(titles, playlists, menu clips that carry their buttons, transitions) and
|
|
137
150
|
`bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
|
|
@@ -196,6 +209,8 @@ The tests encode real clips, write a disc, and read it back with
|
|
|
196
209
|
the titles, chapters, playlists and menus they link to, that a shuffled
|
|
197
210
|
playlist plays each item once, and that a menu with a loop count goes on
|
|
198
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;
|
|
199
214
|
* the commands every kind of action, title, playlist and menu turns into.
|
|
200
215
|
|
|
201
216
|
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.4.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,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
|